diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
index c89189ec..9cc7f48b 100644
--- a/.github/CODE_OF_CONDUCT.md
+++ b/.github/CODE_OF_CONDUCT.md
@@ -1,3 +1,3 @@
# Code of Conduct
-The Doppar Code of Conduct can be found in the [Doppar documentation](https://doppar.com/versions/3.x/contributions.html#code-of-conduct).
+The Doppar Code of Conduct can be found in the [Doppar documentation](https://doppar.com/versions/4.x/contributions#code-of-conduct).
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index ba5b1c69..53c79593 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -1,3 +1,3 @@
# Doppar Contribution Guide
-The Doppar contributing guide can be found in the [Doppar documentation](https://doppar.com/versions/3.x/contributions.html#contribution-guide).
\ No newline at end of file
+The Doppar contributing guide can be found in the [Doppar documentation](https://doppar.com/versions/4.x/contributions#contribution-guide).
\ No newline at end of file
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 796aed59..868ed507 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,7 +1,25 @@
-
+
+## Checklist
-In addition, please describe the benefit to end users; the reasons it does not break any existing features; how it makes building web applications easier, etc.
--->
\ No newline at end of file
+- [ ] Tests have been added or updated
+- [ ] Documentation has been updated if necessary
+- [ ] Code follows the project coding standards
+- [ ] All tests pass locally
\ No newline at end of file
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index 21938681..1c361e6f 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -4,7 +4,7 @@
## Reporting a Vulnerability
-If you discover a security vulnerability within Doppar, please send an email to Mahedi Hasan at mahedi@doppar.com. All security vulnerabilities will be promptly addressed. Please see [Reporting a Vulnerability](https://doppar.com/versions/3.x/contributions.html#security-vulnerabilities)
+If you discover a security vulnerability within Doppar, please send an email to Mahedi Hasan at mahedi@doppar.com. All security vulnerabilities will be promptly addressed. Please see [Reporting a Vulnerability](https://doppar.com/versions/4.x/contributions#security-vulnerabilities)
### Public PGP Key
diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md
index ea10bead..9b472e00 100644
--- a/.github/SUPPORT.md
+++ b/.github/SUPPORT.md
@@ -1,3 +1,3 @@
# Support Questions
-The Doppar support guide can be found in the [Doppar documentation](https://doppar.com/versions/3.x/contributions.html).
\ No newline at end of file
+The Doppar support guide can be found in the [Doppar documentation](https://doppar.com/versions/4.x/contributions).
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..005c672e
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
+version: 2
+
+updates:
+ - package-ecosystem: composer
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 10
+
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/security-analysis.yml b/.github/workflows/security-analysis.yml
new file mode 100644
index 00000000..1914aec9
--- /dev/null
+++ b/.github/workflows/security-analysis.yml
@@ -0,0 +1,71 @@
+name: security analysis
+
+on:
+ push:
+ branches:
+ - master
+ - '*.x'
+ pull_request:
+ schedule:
+ - cron: '0 3 * * *'
+
+permissions:
+ contents: read
+
+jobs:
+ composer-audit:
+ runs-on: ubuntu-24.04
+
+ name: Composer audit
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.5
+ tools: composer:v2
+ coverage: none
+
+ - name: Set Framework version
+ run: composer config version "2.x-dev"
+
+ - name: Install dependencies
+ uses: nick-fields/retry@v3
+ with:
+ timeout_minutes: 5
+ max_attempts: 5
+ command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress
+
+ - name: Audit dependencies for known vulnerabilities
+ run: composer audit --no-dev --format=table --locked
+
+ semgrep:
+ runs-on: ubuntu-24.04
+
+ name: Semgrep
+
+ permissions:
+ contents: read
+ security-events: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Run Semgrep
+ uses: semgrep/semgrep-action@v1
+ with:
+ config: p/security-audit p/php
+ generateSarif: '1'
+
+ - name: Upload SARIF results
+ if: always()
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: semgrep.sarif
diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
index f9ba055b..3d98bb2b 100644
--- a/.github/workflows/static-analysis.yml
+++ b/.github/workflows/static-analysis.yml
@@ -16,7 +16,7 @@ jobs:
matrix:
directory: [src]
- name: ${{ matrix.directory == 'src'}}
+ name: PHPStan (${{ matrix.directory }})
steps:
- name: Checkout code
@@ -25,7 +25,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: 8.3
+ php-version: 8.5
tools: composer:v2
coverage: none
@@ -37,4 +37,7 @@ jobs:
with:
timeout_minutes: 5
max_attempts: 5
- command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress
\ No newline at end of file
+ command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress
+
+ - name: Run PHPStan
+ run: composer analyse -- --no-progress --error-format=github
\ No newline at end of file
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 21a34be6..cced540c 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -31,16 +31,9 @@ jobs:
strategy:
fail-fast: true
matrix:
- php: [8.3, 8.4, 8.5]
- phpunit: ['12.5.0', '12.5.8']
+ php: [8.5]
+ phpunit: ['13.3.0', '13.3.1']
stability: [prefer-lowest, prefer-stable]
- exclude:
- - php: 8.3
- phpunit: '12.0.0'
- - php: 8.4
- phpunit: '12.0.0'
- - php: 8.5
- phpunit: '12.0.0'
name: PHP ${{ matrix.php }} - PHPUnit ${{ matrix.phpunit }} - ${{ matrix.stability }}
@@ -61,7 +54,7 @@ jobs:
REDIS_LIBS: liblz4-dev, liblzf-dev, libzstd-dev
- name: Set Framework version
- run: composer config version "2.x-dev"
+ run: composer config version "4.x-dev"
- name: Install dependencies
uses: nick-fields/retry@v3
@@ -123,14 +116,14 @@ jobs:
coverage: none
- name: Set Framework version
- run: composer config version "2.x-dev"
+ run: composer config version "4.x-dev"
- name: Install dependencies
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 5
- command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress --with="phpunit/phpunit:~12.5.8"
+ command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress --with="phpunit/phpunit:~13.3.1"
- name: Execute MySQL model query tests
if: matrix.driver == 'mysql'
@@ -168,16 +161,9 @@ jobs:
strategy:
fail-fast: true
matrix:
- php: [8.3, 8.4, 8.5]
- phpunit: ['12.5.0', '12.5.8']
+ php: [8.5]
+ phpunit: ['13.3.0', '13.3.1']
stability: [prefer-lowest, prefer-stable]
- exclude:
- - php: 8.3
- phpunit: '12.0.0'
- - php: 8.4
- phpunit: '12.0.0'
- - php: 8.5
- phpunit: '12.0.0'
name: PHP ${{ matrix.php }} - PHPUnit ${{ matrix.phpunit }} - ${{ matrix.stability }} - Windows
@@ -199,7 +185,7 @@ jobs:
coverage: none
- name: Set Framework version
- run: composer config version "2.x-dev"
+ run: composer config version "4.x-dev"
- name: Install dependencies
uses: nick-fields/retry@v3
diff --git a/.gitignore b/.gitignore
index b4e1fdc6..cf119c9c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
/.phpunit.cache
+/.phpstan.cache
/vendor
/storage
composer.phar
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 79c264a3..1c6be7ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,155 @@
# Release Notes
+## v4.8.4 - 2026-10-31
+
+* Fix vendor:publish dumping all assets for unknown tags by [@xentixar](https://github.com/xentixar) in https://github.com/doppar/framework/pull/396
+
+## v4.8.3 - 2026-10-31
+
+* Session Interface Contract instead of concrete Session class by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/381
+* Add a Contract for Password Hashing Services by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/382
+* Add a Contract for Encryption Services by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/383
+* Add a Contract for Request Abortion Services by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/384
+* Add a Contract for Mail Services by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/385
+* Add a Contract for Storage Services by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/386
+* Add a Contract for URL Generation Services by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/387
+* Address review: accept mixed in isValid, resolve url() via contract, reset builder state by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/388
+* Introduce a string service contract by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/389
+* Introduce a signal bus contract by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/390
+* Fix save() leaving the model dirty after a quiet save by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/391
+* Fix save() leaving the model dirty after an insert by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/392
+* Fix StringService edge cases: mask, isPalindrome, random, truncate and more by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/393
+* Fix validator regex alternation, array input 500s and duplicated errors by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/394
+* Fix whereIn/orWhereIn with empty lists, qualify OR columns, add whereNotIn to the query builder by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/395
+
+## v4.8.2 - 2026-10-26
+
+* Honor overridden query() on instance calls to the model by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/380
+
+## v4.8.1 - 2026-10-19
+
+* Fix SQL injection via unvalidated table, view and column identifiers by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/379
+
+## v4.8.0 - 2026-10-18
+
+* Optional relationship keys and typed relation classes by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/377
+* (feat) Create and save records through relationships by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/378
+
+## v4.7.1 - 2026-10-17
+
+* Fix temporal lookups that bring back deleted records by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/372
+* Fix: Temporal diff() incorrectly compares cast values as strings by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/373
+* Fix: Temporal orWhere() filtering accepts every model by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/374
+* Fix: Suspended child contexts survive after their parent ends by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/375
+* Fix: Context-ending listener failures can mask the original application failure by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/376
+* (feat) Branchable temporal records with explainable history by [@abdulmajidcse](https://github.com/abdulmajidcse) in https://github.com/doppar/framework/pull/367
+
+## v4.7.0 - 2026-10-17
+
+* Fix withoutHook() and add withHooks() for bulk queries by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/369
+* Freeze #[Immutable] services registered with a closure or instance() by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/370
+* Clearer errors for frozen services by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/371
+
+## v4.6.0 - 2026-10-16
+
+* (feat) Application: Go-style defer() for guaranteed cleanup by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/365
+* (feat) deliver domain notifications only after a transaction commits by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/366
+* (feat) Request-scoped lifecycle and context propagation by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/368
+
+## v4.5.2 - 2026-10-12
+
+* Fix: Encryption ignores APP_KEY from env.toml and silently uses a public test key by [@abdulmajidcse](https://github.com/abdulmajidcse) in https://github.com/doppar/framework/pull/364
+
+## v4.5.1 - 2026-10-11
+
+* Drop pre-8.5 PDO MySQL constant fallbacks and tidy migrate:temporal output by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/356
+
+## v4.5.0 - 2026-10-09
+
+* Fix: query builder treats column names like date/time as callbacks by [@abdulmajidcse](https://github.com/abdulmajidcse) in https://github.com/doppar/framework/pull/353
+* (feat) ORM: soft deletes via #[SoftDeletes] by [@abdulmajidcse](https://github.com/abdulmajidcse) in https://github.com/doppar/framework/pull/354
+* Fix callback conditions in present() ifExists() being dropped or rewritten and add whereNotIn() to the Entity builder by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/355
+
+## v4.4.0 - 2026-10-03
+
+* server:start: Doppar banner and per-request log with execution time by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/346
+* (feat) Container: using() for temporary, self-restoring overrides by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/348
+* Migrations: rollback, reset, refresh, status, db:wipe, pretend, locking and transactional runs by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/347
+* Collection: bug fixes, dot notation, multi-key sorting, 21 new methods, higher-order proxies by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/352
+* Cache: rate limiting fixes, Redis/prefix safety, Cache::store(), Cache::tags() by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/351
+* Pool console: full signature syntax, original exceptions, and resilient command discovery by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/350
+* (feat) Routing: route parameter constraints by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/349
+
+## v4.3.0 - 2026-10-02
+
+* Refactor big-data processing trait, add chunkById(), remove Fiber variants by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/343
+* (feat) Add blank, filled, with, rescue, retry and benchmark helpers by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/344
+* Migration builder: table-level indexes, column modifiers, drop/rename/change, and fixes by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/345
+
+## v4.2.0 - 2026-09-28
+
+* Make PresenterBundle directly returnable as a JSON API resource by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/342
+* (feat) Router: compile controller actions into cached "action plans" instead of using Reflection on every request by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/341
+
+## v4.1.1 - 2026-09-26
+
+* Pull request template file change and use latest version in action file by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/336
+* Add Router::pushGlobalMiddleware() so launchers can register global middleware by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/337
+* remove ErrorHistoryRecorder class from skeleton by adding it in framework by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/338
+* Fix Paginator prev/next links leaking merged request input into URLs by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/339
+* Fix cron:run and the scheduler: non-blocking background tasks by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/340
+
+## v4.1.0 - 2026-09-25
+
+* Fix: Request State Isolation in Persistent Workers by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/330
+* fix: correct facade [@method](https://github.com/method) docblocks so PHPStan passes by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/334
+* Extract auth into Authable base class by [@sanzgrapher](https://github.com/sanzgrapher) in https://github.com/doppar/framework/pull/333
+
+## v4.0.1 - 2026-09-20
+
+* fix: error page paths on Windows by [@rrr63](https://github.com/rrr63) in https://github.com/doppar/framework/pull/329
+
+## v4.0.0 - 2026-09-19
+
+* merged with master by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/290
+* merged with master (#290) by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/291
+* Introduce Doppar 4.x Application Skeleton by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/289
+* Rename ServiceProvider to ServiceLauncher by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/292
+* Redesign Error Page for 4.x Version by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/293
+* Remove CastToDate for 4.x by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/294
+* Relocate Framework Attributes for 4.x by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/295
+* Prompt for launcher selection in vendor:publish by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/297
+* Migrate Mailer from PHPMailer to Symfony Mailer (Doppar 4.x) by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/298
+* session regenerate after successfull login: by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/299
+* fix: mass-assignment on update: by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/300
+* fix: SQL identifier injection by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/302
+* fix: Route response()->view() through the Odo rendering engine by [@rrr63](https://github.com/rrr63) in https://github.com/doppar/framework/pull/301
+* fix : cross-platform absolute path handling in base_path() by [@rrr63](https://github.com/rrr63) in https://github.com/doppar/framework/pull/303
+* feat: add attribute-based DTO validation by [@rrr63](https://github.com/rrr63) in https://github.com/doppar/framework/pull/304
+* fix: base_path() ambiguity crashes Router::getControllerClasses() on getPrefixesPsr4() by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/305
+* Fix #4 done: path traversal in Storage by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/306
+* Fix #5 encryption — APP_KEY decode bug + move to AEAD by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/307
+* Fix #9 after_created hook ordering by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/308
+* Fix Model::__get() swallowing \Throwable by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/309
+* resolve terminating callback dependencies via container by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/310
+* fix: enforce enum column values on Postgres and SQLite by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/311
+* fix: Schema::table() generated CREATE TABLE instead of ALTER TABLE on every driver by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/312
+* Fix: DI resolution-order bug by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/313
+* base path is now injected via the constructor, no global BASE_PATH constant by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/315
+* refactor: Router depends on GatewayInterface instead of extending Kernel by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/314
+* Environment configuration switches from .env to env.toml by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/316
+* Fix 2FA QR code generation for chillerlan/php-qrcode v6 by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/317
+* Redesign the 4.x welcome page across all frontend install stubs by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/318
+* welcome page related change unit test issue fix: by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/319
+* finalize the doppar 4.x version welcome page by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/320
+* readme.md updated for 4.x version: by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/321
+* Separate Request::capture() from superglobal reads by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/322
+* fix: Harden Support\File against path traversal and spoofed MIME types by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/323
+* Detect real file content for the mimes validation rule by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/324
+* Bump dev master 3.x to 4.x by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/325
+* readme.md updated by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/326
+* README.md updated by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/328
+
## v3.26.6 - 2026-07-24
* Add model helpers for clearing and reloading cached relationships by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/288
diff --git a/README.md b/README.md
index 23e2eea9..d84b944c 100644
--- a/README.md
+++ b/README.md
@@ -2,36 +2,71 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+Doppar isn't one package pretending to be a framework. It's a core plus a set of first-party packages — queue, search, auth, HTTP, process orchestration — released together, tested together, and built to the same standard. You don't assemble a Doppar app from unrelated third-party pieces; you pick the parts you need from one coherent set.
+
+## The ecosystem
-## About Doppar
-Most PHP frameworks make you negotiate. You get expressiveness, but you pay in performance. You get speed, but you inherit complexity. Doppar ends that negotiation.
+| Package | What it does |
+|---|---|
+| [Ai](https://github.com/doppar/ai) | AI integration, wired into the framework's own conventions. |
+| [Queue](https://github.com/doppar/queue) | Database-backed background jobs with process-isolated timeouts — a stuck job gets killed, not left to hang the worker. |
+| [Ripple](https://github.com/doppar/ripple) | Build reactive interfaces in pure PHP. |
+| [Embeds](https://github.com/doppar/embeds) | Semantic search for your models. Locally-computed embeddings, no external API required. |
+| [Guard](https://github.com/doppar/guard) | Authorization — gates and policies without wiring your own. |
+| [Flarion](https://github.com/doppar/flarion) | API authentication. |
+| [Oauthic](https://github.com/doppar/oauthic) | Social login (OAuth) as a first-party package, not a community patchwork. |
+| [Orion](https://github.com/doppar/orion) | External process orchestration — run shell commands individually, in concurrency-limited pools, or piped into pipelines. |
+| [Airbend](https://github.com/doppar/airbend) | Real-time broadcasting. |
+| [Bloom](https://github.com/doppar/bloom) | Bloom filter implementations for probabilistic set membership testing. |
+| [Axios](https://github.com/doppar/axios) | A modern HTTP client for outbound requests. |
+| [Notifier](https://github.com/doppar/notifier) | Notification delivery across channels. |
+| [Insight](https://github.com/doppar/insight) | A profiling and debugging toolbar for local development. |
+| [Twig-bridge](https://github.com/doppar/twig-bridge) | Twig templates, if you'd rather not use Doppar's own view engine. |
-Every layer of the framework — from routing to data access — is engineered for both beauty and throughput simultaneously. Repeated executions are memoized intelligently. Dependencies are minimal by design. The result is a framework that reads like prose and runs like a machine.
+Every package here is versioned and released alongside the core. Add what your app needs; the rest stays out of your `composer.json`.
-Write code you're proud of. Ship software that holds up. Whether you're a seasoned PHP developer or just diving in, Doppar makes it easy to build powerful applications quickly and cleanly.
+## Get started
-> This repository contains the Doppar framework core. To build an application with Doppar, use the main [Doppar repository](https://github.com/doppar/doppar).
+- **Documentation:** [Doppar](https://doppar.com/versions/4.x/installation)
+- **What's new in 4.x:** [Releases](https://doppar.com/versions/4.x/releases)
+- **AI:** [AI](https://ai.doppar.com)
+- **Ripple:** [Ripple](https://ripple.doppar.com)
+- **News and updates:** [Blog](https://blog.doppar.com)
+- **Doppar tour:** [Tour](https://tour.doppar.com)
## Contributing
-Thank you for considering contributing to the Doppar framework! The contribution guide can be found in the [Doppar documentation](https://doppar.com/versions/3.x/contributions.html).
+Thank you for considering contributing to the Doppar framework! The contribution guide can be found in the [Doppar documentation](https://doppar.com/versions/4.x/contributions).
+
+## Concurrent tasks
+
+Independent non-blocking operations can run together in a named task group:
+
+```php
+use Phaseolies\Support\Concurrency\Task;
+use Phaseolies\Support\Concurrency\TaskGroup;
+
+$results = TaskGroup::run([
+ 'profile' => function (Task $task) use ($client) {
+ return $task->await(function ($resolve, $reject) use ($client) {
+ $request = $client->fetch('/profile', $resolve, $reject);
+
+ return fn () => $request->cancel();
+ });
+ },
+ 'settings' => fn () => loadSettings(),
+], timeout: 2.0);
+```
+
+The cleanup callback returned by `Task::await()` cancels the pending operation
+when a sibling fails or the shared deadline expires. Task groups use the
+configured React `LoopInterface`; when that loop is already running, call the
+group from a Fiber so the parent Fiber can suspend without restarting or
+stopping the loop. Blocking PDO, filesystem, and HTTP calls remain blocking.
## Code of Conduct
-In order to ensure that the Doppar community is welcoming to all, please review and abide by the [Code of Conduct](https://doppar.com/versions/3.x/contributions.html#code-of-conduct).
+In order to ensure that the Doppar community is welcoming to all, please review and abide by the [Code of Conduct](https://doppar.com/versions/4.x/contributions#code-of-conduct).
## Security Vulnerabilities
diff --git a/composer.json b/composer.json
index 960a3068..3fb56640 100644
--- a/composer.json
+++ b/composer.json
@@ -19,26 +19,28 @@
}
],
"require": {
- "php": "^8.3",
- "chillerlan/php-qrcode": "^5.0",
- "dragonmantank/cron-expression": "^3.4",
- "monolog/monolog": "^3.0",
- "nesbot/carbon": "^3.8",
- "paragonie/constant_time_encoding": "^3.0",
- "phpmailer/phpmailer": "^6.9",
+ "php": "^8.5",
+ "chillerlan/php-qrcode": "^6.0",
+ "devium/toml": "^1.1",
+ "dragonmantank/cron-expression": "^3.6",
+ "monolog/monolog": "^3.10",
+ "nesbot/carbon": "^3.13",
+ "paragonie/constant_time_encoding": "^3.1",
"psr/simple-cache": "^3.0",
"ramsey/collection": "^2.1",
"react/event-loop": "^1.6",
- "spomky-labs/otphp": "^11.3",
- "symfony/cache": "^7.4",
- "symfony/console": "^7.4",
- "symfony/process": "^7.4",
- "symfony/var-dumper": "^7.4",
- "vlucas/phpdotenv": "^5.6"
+ "spomky-labs/otphp": "^11.5",
+ "symfony/cache": "^8.1",
+ "symfony/console": "^8.1",
+ "symfony/mailer": "^8.1",
+ "symfony/mime": "^8.1",
+ "symfony/process": "^8.1",
+ "symfony/var-dumper": "^8.1"
},
"require-dev": {
"mockery/mockery": "^1.6",
- "phpunit/phpunit": "^12.5"
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^13.3"
},
"autoload": {
"psr-4": {
@@ -58,11 +60,12 @@
"test:sqlite": "@test",
"test:mysql": "vendor/bin/phpunit --group mysql",
"test:pgsql": "vendor/bin/phpunit --group pgsql",
- "test:all": "vendor/bin/phpunit"
+ "test:all": "vendor/bin/phpunit",
+ "analyse": "vendor/bin/phpstan analyse --memory-limit=1G"
},
"extra": {
"branch-alias": {
- "dev-master": "3.x-dev"
+ "dev-master": "4.x-dev"
}
},
"suggest": {
@@ -70,7 +73,7 @@
"ext-pdo": "Provides the core database abstraction required for all DB operations.",
"ext-pcntl": "Enables full functionality of the queue worker and support for console signal handling.",
"ext-redis": "Required for using Redis-backed cache.",
- "symfony/cache": "Supplies the PSR-6 cache integration layer (version ^7.4)."
+ "symfony/cache": "Supplies the PSR-6 cache integration layer (version ^8.1)."
},
"config": {
"sort-packages": true,
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
new file mode 100644
index 00000000..6fe6d97a
--- /dev/null
+++ b/phpstan-baseline.neon
@@ -0,0 +1,2527 @@
+parameters:
+ ignoreErrors:
+ -
+ message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/Application.php
+
+ -
+ message: '#^Call to method forgetActors\(\) on an unknown class auth\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Application.php
+
+ -
+ message: '#^Instanceof between Phaseolies\\Http\\Response and Phaseolies\\Http\\Response will always evaluate to true\.$#'
+ identifier: instanceof.alwaysTrue
+ count: 1
+ path: src/Phaseolies/Application.php
+
+ -
+ message: '#^PHPDoc tag @var with type Phaseolies\\Providers\\GhostableProvider is not subtype of native type Phaseolies\\Providers\\ServiceProvider\.$#'
+ identifier: varTag.nativeType
+ count: 1
+ path: src/Phaseolies/Application.php
+
+ -
+ message: '#^Parameter \#1 \$abstract of method Phaseolies\\Application\:\:make\(\) expects class\-string\, string given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Application.php
+
+ -
+ message: '#^Property Phaseolies\\Application\:\:\$cachedConfig \(null\) does not accept true\.$#'
+ identifier: assign.propertyType
+ count: 1
+ path: src/Phaseolies/Application.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Support\\Router\:\:handle\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/ApplicationBuilder.php
+
+ -
+ message: '#^PHPDoc tag @var for property Phaseolies\\ApplicationBuilder\:\:\$request contains generic type Phaseolies\\Http\\Request\ but class Phaseolies\\Http\\Request is not generic\.$#'
+ identifier: generics.notGeneric
+ count: 1
+ path: src/Phaseolies/ApplicationBuilder.php
+
+ -
+ message: '#^Parameter \#1 \$abstract of method Phaseolies\\Application\:\:make\(\) expects class\-string\, string given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/ApplicationBuilder.php
+
+ -
+ message: '#^Parameter \#1 \$abstract of method Phaseolies\\Application\:\:make\(\) expects class\-string\, string given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/ApplicationBuilder.php
+
+ -
+ message: '#^Access to undefined constant chillerlan\\QRCode\\QRCode\:\:ECC_M\.$#'
+ identifier: classConstant.notFound
+ count: 1
+ path: src/Phaseolies/Auth/Security/Authenticate.php
+
+ -
+ message: '#^Access to undefined constant chillerlan\\QRCode\\QRCode\:\:OUTPUT_MARKUP_SVG\.$#'
+ identifier: classConstant.notFound
+ count: 1
+ path: src/Phaseolies/Auth/Security/Authenticate.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Auth\\Authable\:\:where\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Auth/Security/Authenticate.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Crypt\:\:decrypt\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 3
+ path: src/Phaseolies/Auth/Security/Authenticate.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Crypt\:\:encrypt\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 5
+ path: src/Phaseolies/Auth/Security/Authenticate.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Hash\:\:check\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 3
+ path: src/Phaseolies/Auth/Security/Authenticate.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Hash\:\:make\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 2
+ path: src/Phaseolies/Auth/Security/Authenticate.php
+
+ -
+ message: '#^Class Doppar\\Flarion\\ApiAuthenticate not found\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Auth/Security/Authenticate.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$size$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Auth/Security/Authenticate.php
+
+ -
+ message: '#^Ternary operator condition is always true\.$#'
+ identifier: ternary.alwaysTrue
+ count: 1
+ path: src/Phaseolies/Auth/Security/Authenticate.php
+
+ -
+ message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/Cache/CacheStore.php
+
+ -
+ message: '#^PHPDoc tag @param for parameter \$owner with type string\|null is not subtype of native type string\.$#'
+ identifier: parameter.phpDocType
+ count: 1
+ path: src/Phaseolies/Cache/CacheStore.php
+
+ -
+ message: '#^Parameter \#3 \$ttl of method Phaseolies\\Cache\\CacheStore\:\:set\(\) expects null, int\|Phaseolies\\Cache\\DateInterval given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Cache/CacheStore.php
+
+ -
+ message: '#^Parameter \$ttl of method Phaseolies\\Cache\\CacheStore\:\:stash\(\) has invalid type Phaseolies\\Cache\\DateInterval\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Cache/CacheStore.php
+
+ -
+ message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#'
+ identifier: notIdentical.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Cache/CacheStore.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Pool\:\:call\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/AppBoostCommand.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Cache\:\:clear\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/ClearCacheCommand.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Route\:\:clearRouteCache\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/ClearRouteCacheCommand.php
+
+ -
+ message: '#^Call to method getCommands\(\) on an unknown class App\\Schedule\\Schedule\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/Cron/CronListCommand.php
+
+ -
+ message: '#^Call to method schedule\(\) on an unknown class App\\Schedule\\Schedule\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/Cron/CronListCommand.php
+
+ -
+ message: '#^Instantiated class App\\Schedule\\Schedule not found\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/Cron/CronListCommand.php
+
+ -
+ message: '#^Call to method getCommands\(\) on an unknown class App\\Schedule\\Schedule\.$#'
+ identifier: class.notFound
+ count: 2
+ path: src/Phaseolies/Console/Commands/Cron/CronRunCommand.php
+
+ -
+ message: '#^Call to method schedule\(\) on an unknown class App\\Schedule\\Schedule\.$#'
+ identifier: class.notFound
+ count: 2
+ path: src/Phaseolies/Console/Commands/Cron/CronRunCommand.php
+
+ -
+ message: '#^Instantiated class App\\Schedule\\Schedule not found\.$#'
+ identifier: class.notFound
+ count: 2
+ path: src/Phaseolies/Console/Commands/Cron/CronRunCommand.php
+
+ -
+ message: '#^Unreachable statement \- code above always terminates\.$#'
+ identifier: deadCode.unreachable
+ count: 1
+ path: src/Phaseolies/Console/Commands/KeyGenerateCommand.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Hash\:\:make\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/MakePasswordCommand.php
+
+ -
+ message: '#^Method Phaseolies\\Database\\Migration\\MigrationCreator\:\:create\(\) invoked with 7 parameters, 2\-4 required\.$#'
+ identifier: arguments.count
+ count: 1
+ path: src/Phaseolies/Console/Commands/Migrations/AddColumnMigrationCommand.php
+
+ -
+ message: '#^PHPDoc tag @param has invalid value \(string \\\$className\)\: Unexpected token "\\\\\$className", expected variable at offset 82 on line 4$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Console/Commands/Migrations/CreateSeedCommand.php
+
+ -
+ message: '#^Call to method run\(\) on an unknown class Database\\Seeders\\DatabaseSeeder\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/Migrations/DBSeedCommand.php
+
+ -
+ message: '#^Instantiated class Database\\Seeders\\DatabaseSeeder not found\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/Migrations/DBSeedCommand.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\DB\:\:connection\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/Migrations/MigrateRefreshCommand.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Schema\:\:connection\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 2
+ path: src/Phaseolies/Console/Commands/Migrations/MigrateRefreshCommand.php
+
+ -
+ message: '#^Using nullsafe property access "\?\-\>trackActor" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
+ identifier: nullsafe.neverNull
+ count: 1
+ path: src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Route\:\:cacheRoutes\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/RouteCacheCommand.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Pool\:\:call\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Console/Commands/RouteListCommand.php
+
+ -
+ message: '#^Call to function is_int\(\) with int\\|int\<1, max\> will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/Console/Commands/Server/ServerStartCommand.php
+
+ -
+ message: '#^Parameter \#1 \$port of method Phaseolies\\Console\\Commands\\Server\\ServerStartCommand\:\:isPortOk\(\) expects int\|null, array\|string\|null given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Console/Commands/Server/ServerStartCommand.php
+
+ -
+ message: '#^PHPDoc tag @param has invalid value \(string string\)\: Unexpected token "string", expected variable at offset 113 on line 5$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Console/Console.php
+
+ -
+ message: '#^Call to an undefined method Symfony\\Component\\Console\\Helper\\HelperInterface\:\:ask\(\)\.$#'
+ identifier: method.notFound
+ count: 5
+ path: src/Phaseolies/Console/Schedule/Command.php
+
+ -
+ message: '#^Call to an undefined method Symfony\\Component\\Console\\Question\\ChoiceQuestion\:\:setDefault\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Console/Schedule/Command.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:error\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Console/Schedule/Command.php
+
+ -
+ message: '#^Offset 1 on array\{0\: non\-falsy\-string, 1\: string, 2\: non\-empty\-string, 3\?\: string\} on left side of \?\? always exists and is not nullable\.$#'
+ identifier: nullCoalesce.offset
+ count: 1
+ path: src/Phaseolies/Console/Schedule/Command.php
+
+ -
+ message: '#^Trait Phaseolies\\Console\\Schedule\\InteractsWithSchedule is used zero times and is not analysed\.$#'
+ identifier: trait.unused
+ count: 1
+ path: src/Phaseolies/Console/Schedule/InteractsWithSchedule.php
+
+ -
+ message: '#^Parameter \#3 \$env of class Symfony\\Component\\Process\\Process constructor expects array\\|null, non\-empty\-array\ given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Console/Schedule/SchedulePool.php
+
+ -
+ message: '#^Function now invoked with 1 parameter, 0 required\.$#'
+ identifier: arguments.count
+ count: 1
+ path: src/Phaseolies/Console/Schedule/ScheduledCommand.php
+
+ -
+ message: '#^Left side of && is always true\.$#'
+ identifier: booleanAnd.leftAlwaysTrue
+ count: 1
+ path: src/Phaseolies/Console/Schedule/ScheduledCommand.php
+
+ -
+ message: '#^Method Phaseolies\\Console\\Schedule\\ScheduledCommand\:\:getRateLimit\(\) should return string\|null but returns int\|null\.$#'
+ identifier: return.type
+ count: 1
+ path: src/Phaseolies/Console/Schedule/ScheduledCommand.php
+
+ -
+ message: '#^Parameter \#1 \$hour of method Carbon\\Traits\\Date\:\:setTime\(\) expects int, string given\.$#'
+ identifier: argument.type
+ count: 2
+ path: src/Phaseolies/Console/Schedule/ScheduledCommand.php
+
+ -
+ message: '#^Parameter \#2 \$minute of method Carbon\\Traits\\Date\:\:setTime\(\) expects int, string given\.$#'
+ identifier: argument.type
+ count: 2
+ path: src/Phaseolies/Console/Schedule/ScheduledCommand.php
+
+ -
+ message: '#^Parameter \#2 \$string of function explode expects string, int\\|int\<1, max\> given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Console/Schedule/ScheduledCommand.php
+
+ -
+ message: '#^Property Phaseolies\\Console\\Schedule\\ScheduledCommand\:\:\$additionalConditions \(array\) in isset\(\) is not nullable\.$#'
+ identifier: isset.property
+ count: 1
+ path: src/Phaseolies/Console/Schedule/ScheduledCommand.php
+
+ -
+ message: '#^Property Phaseolies\\Console\\Schedule\\ScheduledCommand\:\:\$rateLimit \(int\|null\) does not accept string\.$#'
+ identifier: assign.propertyType
+ count: 1
+ path: src/Phaseolies/Console/Schedule/ScheduledCommand.php
+
+ -
+ message: '#^Property Phaseolies\\Console\\Schedule\\ScheduledCommand\:\:\$rateLimit \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: src/Phaseolies/Console/Schedule/ScheduledCommand.php
+
+ -
+ message: '#^Trait Phaseolies\\DI\\Concerns\\EnforcesImmutability is used zero times and is not analysed\.$#'
+ identifier: trait.unused
+ count: 1
+ path: src/Phaseolies/DI/Concerns/EnforcesImmutability.php
+
+ -
+ message: '#^Call to an undefined method ReflectionType\:\:getName\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/DI/Container.php
+
+ -
+ message: '#^Call to an undefined method ReflectionType\:\:isBuiltin\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/DI/Container.php
+
+ -
+ message: '#^Call to function is_callable\(\) with callable\(\)\: mixed will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/DI/Container.php
+
+ -
+ message: '#^Call to function is_object\(\) with \*NEVER\* will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/DI/Container.php
+
+ -
+ message: '#^Right side of && is always false\.$#'
+ identifier: booleanAnd.rightAlwaysFalse
+ count: 1
+ path: src/Phaseolies/DI/Container.php
+
+ -
+ message: '#^Strict comparison using \!\=\= between mixed and null will always evaluate to true\.$#'
+ identifier: notIdentical.alwaysTrue
+ count: 2
+ path: src/Phaseolies/DI/Container.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 1
+ path: src/Phaseolies/DI/Container.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Database\:\:\$conditions\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Database/Database.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Database\:\:\$pdo\.$#'
+ identifier: property.notFound
+ count: 3
+ path: src/Phaseolies/Database/Database.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Database\:\:\$table\.$#'
+ identifier: property.notFound
+ count: 7
+ path: src/Phaseolies/Database/Database.php
+
+ -
+ message: '#^Dead catch \- Throwable is never thrown in the try block\.$#'
+ identifier: catch.neverThrown
+ count: 1
+ path: src/Phaseolies/Database/Database.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 1
+ path: src/Phaseolies/Database/Database.php
+
+ -
+ message: '#^Variable \$stmt in empty\(\) always exists and is not falsy\.$#'
+ identifier: empty.variable
+ count: 1
+ path: src/Phaseolies/Database/Database.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$name$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Database/Drivers/SQLiteDriver.php
+
+ -
+ message: '#^Access to an undefined property \$this\(Phaseolies\\Database\\Entity\\Builder\)\:\:\$originalAttributes\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Access to an undefined property \$this\(Phaseolies\\Database\\Entity\\Builder\)\:\:\$primaryKey\.$#'
+ identifier: property.notFound
+ count: 2
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Access to an undefined property \$this\(Phaseolies\\Database\\Entity\\Builder\)\:\:\$timeStamps\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Builder\:\:\$attributes\.$#'
+ identifier: property.notFound
+ count: 4
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Builder\:\:\$connection\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Builder\:\:\$creatable\.$#'
+ identifier: property.notFound
+ count: 2
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Builder\:\:\$originalAttributes\.$#'
+ identifier: property.notFound
+ count: 4
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Builder\:\:\$pageSize\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Builder\:\:\$primaryKey\.$#'
+ identifier: property.notFound
+ count: 4
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Builder\:\:\$relations\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Builder\:\:\$timeStamps\.$#'
+ identifier: property.notFound
+ count: 2
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method \$this\(Phaseolies\\Database\\Entity\\Builder\)\:\:fireAfterHooks\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method \$this\(Phaseolies\\Database\\Entity\\Builder\)\:\:fireBeforeHooks\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method \$this\(Phaseolies\\Database\\Entity\\Builder\)\:\:firePropertyWatches\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method \$this\(Phaseolies\\Database\\Entity\\Builder\)\:\:newQuery\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method \$this\(Phaseolies\\Database\\Entity\\Builder\)\:\:pruneNonColumnDirtyAttributes\(\)\.$#'
+ identifier: method.notFound
+ count: 2
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Database\\Entity\\Builder\:\:fill\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Database\\Entity\\Builder\:\:fireAfterHooks\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Database\\Entity\\Builder\:\:fireBeforeHooks\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Database\\Entity\\Builder\:\:getTable\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Database\\Entity\\Builder\:\:newQuery\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Database\\Entity\\Builder\:\:pruneNonColumnAttributes\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Database\\Entity\\Builder\:\:setRelation\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Database\\Entity\\Builder\:\:toArray\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\URL\:\:current\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 2
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 2
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to function is_callable\(\) with null will always evaluate to false\.$#'
+ identifier: function.impossibleType
+ count: 2
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to function is_int\(\) with int will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to function unset\(\) contains undefined variable \$chunkQuery\.$#'
+ identifier: unset.variable
+ count: 2
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Call to function unset\(\) contains undefined variable \$results\.$#'
+ identifier: unset.variable
+ count: 2
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^If condition is always false\.$#'
+ identifier: if.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Instanceof between \$this\(Phaseolies\\Database\\Entity\\Builder\) and Phaseolies\\Support\\Collection will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Method Phaseolies\\Database\\Entity\\Builder\:\:absent\(\) invoked with 2 parameters, 1 required\.$#'
+ identifier: arguments.count
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Method Phaseolies\\Database\\Entity\\Builder\:\:createFromModel\(\) should return static\(Phaseolies\\Database\\Entity\\Builder\) but returns Phaseolies\\Database\\Entity\\Model\.$#'
+ identifier: return.type
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^PHPDoc tag @param has invalid value \(int chunkSize\)\: Unexpected token "chunkSize", expected variable at offset 89 on line 4$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$boolean$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$page$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Parameter \#1 \.\.\.\$arrays of function array_merge expects array, null given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Parameter \#2 \$type of method Phaseolies\\Database\\Entity\\Builder\:\:formatDateTime\(\) expects string, true given\.$#'
+ identifier: argument.type
+ count: 2
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Static call to instance method Phaseolies\\Database\\Entity\\Builder\:\:create\(\)\.$#'
+ identifier: method.staticCall
+ count: 3
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Strict comparison using \=\=\= between mixed and null will always evaluate to false\.$#'
+ identifier: identical.alwaysFalse
+ count: 2
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Undefined variable\: \$inspectQuery$#'
+ identifier: variable.undefined
+ count: 4
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 6
+ path: src/Phaseolies/Database/Entity/Builder.php
+
+ -
+ message: '#^Offset ''when'' on array\{handler\: \(callable\(\)\: mixed\)\|string, when\: bool\|\(callable\(\)\: mixed\)\} on left side of \?\? always exists and is not nullable\.$#'
+ identifier: nullCoalesce.offset
+ count: 1
+ path: src/Phaseolies/Database/Entity/Hooks/HookHandler.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$__action\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Model.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$__actor\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Model.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$__changed_cols\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Model.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$__history_id\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Model.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$__valid_from\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Model.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$modelClass\.$#'
+ identifier: property.notFound
+ count: 2
+ path: src/Phaseolies/Database/Entity/Model.php
+
+ -
+ message: '#^Offset ''key'' does not exist on string\.$#'
+ identifier: offsetAccess.notFound
+ count: 4
+ path: src/Phaseolies/Database/Entity/Model.php
+
+ -
+ message: '#^Offset ''method'' does not exist on string\.$#'
+ identifier: offsetAccess.notFound
+ count: 5
+ path: src/Phaseolies/Database/Entity/Model.php
+
+ -
+ message: '#^Property Phaseolies\\Database\\Entity\\Model\:\:\$lastLocalKey \(string\) on left side of \?\? is not nullable\.$#'
+ identifier: nullCoalesce.property
+ count: 1
+ path: src/Phaseolies/Database/Entity/Model.php
+
+ -
+ message: '#^Property Phaseolies\\Database\\Entity\\Model\:\:\$table \(string\) in isset\(\) is not nullable\.$#'
+ identifier: isset.property
+ count: 2
+ path: src/Phaseolies/Database/Entity/Model.php
+
+ -
+ message: '#^Unreachable statement \- code above always terminates\.$#'
+ identifier: deadCode.unreachable
+ count: 1
+ path: src/Phaseolies/Database/Entity/Model.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\URL\:\:current\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 2
+ path: src/Phaseolies/Database/Entity/Query/Builder.php
+
+ -
+ message: '#^Call to function is_int\(\) with int will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/Database/Entity/Query/Builder.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$boolean$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Query/Builder.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$modelClass$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Query/Builder.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$page$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Query/Builder.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$rowPerPage$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Database/Entity/Query/Builder.php
+
+ -
+ message: '#^Parameter \#2 \$type of method Phaseolies\\Database\\Entity\\Query\\Builder\:\:formatDateTime\(\) expects string, true given\.$#'
+ identifier: argument.type
+ count: 2
+ path: src/Phaseolies/Database/Entity/Query/Builder.php
+
+ -
+ message: '#^Strict comparison using \=\=\= between mixed and null will always evaluate to false\.$#'
+ identifier: identical.alwaysFalse
+ count: 2
+ path: src/Phaseolies/Database/Entity/Query/Builder.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 1
+ path: src/Phaseolies/Database/Entity/Query/Builder.php
+
+ -
+ message: '#^Call to function is_bool\(\) with bool will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/Database/Entity/Watches/WatchesHandler.php
+
+ -
+ message: '#^Call to function is_null\(\) with mixed will always evaluate to false\.$#'
+ identifier: function.impossibleType
+ count: 1
+ path: src/Phaseolies/Database/Migration/ColumnDefinition.php
+
+ -
+ message: '#^Variable \$keys in empty\(\) always exists and is not falsy\.$#'
+ identifier: empty.variable
+ count: 1
+ path: src/Phaseolies/Database/Migration/Grammars/SQLiteGrammar.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\DB\:\:connection\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 4
+ path: src/Phaseolies/Database/Migration/MigrationRepository.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Schema\:\:connection\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Database/Migration/MigrationRepository.php
+
+ -
+ message: '#^Variable \$connection on left side of \?\? always exists and is not nullable\.$#'
+ identifier: nullCoalesce.variable
+ count: 1
+ path: src/Phaseolies/Database/Migration/Migrator.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\DB\:\:connection\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 6
+ path: src/Phaseolies/Database/Migration/Schema.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 1
+ path: src/Phaseolies/Database/Migration/Schema.php
+
+ -
+ message: '#^PHPDoc tag @param has invalid value \(array\)\: Unexpected token "\\n ", expected variable at offset 98 on line 4$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Database/Procedure/ProcedureResult.php
+
+ -
+ message: '#^Method Phaseolies\\Database\\Temporal\\TemporalManager\:\:resolveActor\(\) never returns int so it can be removed from the return type\.$#'
+ identifier: return.unusedType
+ count: 1
+ path: src/Phaseolies/Database/Temporal/TemporalManager.php
+
+ -
+ message: '#^Using nullsafe property access "\?\-\>suffix" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
+ identifier: nullsafe.neverNull
+ count: 1
+ path: src/Phaseolies/Database/Temporal/TemporalManager.php
+
+ -
+ message: '#^Class App\\Http\\Exceptions\\BeforeExceptionHandler not found\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Error/ErrorHandler.php
+
+ -
+ message: '#^PHPDoc tag @param has invalid value \(int\)\: Unexpected token "\\n \* ", expected variable at offset 98 on line 5$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Error/Handlers/CliErrorHandler.php
+
+ -
+ message: '#^PHPDoc tag @return with type void is incompatible with native type string\.$#'
+ identifier: return.phpDocType
+ count: 1
+ path: src/Phaseolies/Error/Handlers/CliErrorHandler.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 2
+ path: src/Phaseolies/Error/Traces/Frame.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$id\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Error/WebErrorRenderer.php
+
+ -
+ message: '#^Array has 2 duplicate keys with value ''request_body'' \(''request_body'', ''request_body''\)\.$#'
+ identifier: array.duplicateKey
+ count: 1
+ path: src/Phaseolies/Error/WebErrorRenderer.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Route\:\:currentRouteAction\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Error/WebErrorRenderer.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Route\:\:currentRouteName\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Error/WebErrorRenderer.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Route\:\:getCurrentMiddlewareNames\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Error/WebErrorRenderer.php
+
+ -
+ message: '#^Parameter \#1 \$content of function response expects null, string given\.$#'
+ identifier: argument.type
+ count: 3
+ path: src/Phaseolies/Error/WebErrorRenderer.php
+
+ -
+ message: '#^Using nullsafe method call on non\-nullable type Phaseolies\\Auth\\ActorManager\|Phaseolies\\Auth\\Security\\Authenticate\. Use \-\> instead\.$#'
+ identifier: nullsafe.neverNull
+ count: 1
+ path: src/Phaseolies/Error/WebErrorRenderer.php
+
+ -
+ message: '#^If condition is always true\.$#'
+ identifier: if.alwaysTrue
+ count: 1
+ path: src/Phaseolies/Facade/BaseFacade.php
+
+ -
+ message: '#^Unreachable statement \- code above always terminates\.$#'
+ identifier: deadCode.unreachable
+ count: 1
+ path: src/Phaseolies/Facade/BaseFacade.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Crypt\:\:decrypt\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Crypt\:\:encrypt\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:alert\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:critical\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:debug\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:emergency\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:error\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:info\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:notice\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:warning\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Call to static method create\(\) on an unknown class Faker\\Factory\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Condition "null is null" in conditional return type is always true\.$#'
+ identifier: conditionalType.alwaysTrue
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Function __\(\) never returns array so it can be removed from the return type\.$#'
+ identifier: return.unusedType
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Function fake\(\) has invalid return type Faker\\Generator\.$#'
+ identifier: class.notFound
+ count: 2
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^PHPDoc tag @param has invalid value \(int\)\: Unexpected token "\\n \* ", expected variable at offset 108 on line 4$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^PHPDoc tag @param has invalid value \(string\)\: Unexpected token "\\n \* ", expected variable at offset 86 on line 4$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^Variable \$content on left side of \?\? always exists and is always null\.$#'
+ identifier: nullCoalesce.variable
+ count: 1
+ path: src/Phaseolies/Helpers/helpers.php
+
+ -
+ message: '#^PHPDoc tag @param for parameter \$callback with type string is incompatible with native type Closure\.$#'
+ identifier: parameter.phpDocType
+ count: 1
+ path: src/Phaseolies/Http/Controllers/Controller.php
+
+ -
+ message: '#^Property Phaseolies\\Http\\Controllers\\Controller\:\:\$soloCounter \(int\) on left side of \?\? is not nullable\.$#'
+ identifier: nullCoalesce.property
+ count: 1
+ path: src/Phaseolies/Http/Controllers/Controller.php
+
+ -
+ message: '#^Ternary operator condition is always true\.$#'
+ identifier: ternary.alwaysTrue
+ count: 1
+ path: src/Phaseolies/Http/Controllers/Controller.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 1
+ path: src/Phaseolies/Http/Exceptions/HttpException.php
+
+ -
+ message: '#^Method Phaseolies\\Http\\Exceptions\\StreamedResponseException\:\:render\(\) has invalid return type Illuminate\\Http\\Response\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Http/Exceptions/StreamedResponseException.php
+
+ -
+ message: '#^Method Phaseolies\\Http\\Exceptions\\StreamedResponseException\:\:render\(\) should return Illuminate\\Http\\Response but returns Phaseolies\\Http\\Response\.$#'
+ identifier: return.type
+ count: 1
+ path: src/Phaseolies/Http/Exceptions/StreamedResponseException.php
+
+ -
+ message: '#^Method Phaseolies\\Http\\HeaderBag\:\:getIterator\(\) should return ArrayIterator\\> but returns ArrayIterator\\>\.$#'
+ identifier: return.type
+ count: 1
+ path: src/Phaseolies/Http/HeaderBag.php
+
+ -
+ message: '#^Instanceof between array\|null and Closure will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Http/ParameterBag.php
+
+ -
+ message: '#^Argument of an invalid type Phaseolies\\Http\\InputBag supplied for foreach, only iterables are supported\.$#'
+ identifier: foreach.nonIterable
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Call to an undefined method ReflectionType\:\:getName\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Call to an undefined method ReflectionType\:\:isBuiltin\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\App\:\:setLocale\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Str\:\:contains\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 2
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Str\:\:startsWith\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Str\:\:substr\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Method Phaseolies\\Http\\Request\:\:header\(\) invoked with 2 parameters, 1 required\.$#'
+ identifier: arguments.count
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Method Phaseolies\\Http\\Request\:\:headers\(\) should return array\ but returns array\\>\.$#'
+ identifier: return.type
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Method Phaseolies\\Http\\Request\:\:prefers\(\) should return string\|null but return statement is missing\.$#'
+ identifier: return.missing
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Non\-static access to static property Phaseolies\\Http\\Request\:\:\$trustedHeaderSet\.$#'
+ identifier: staticProperty.nonStaticAccess
+ count: 5
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Non\-static access to static property Phaseolies\\Http\\Request\:\:\$trustedProxies\.$#'
+ identifier: staticProperty.nonStaticAccess
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^PHPDoc tag @return has invalid value \(string\|null\.\)\: Unexpected token "\.", expected TOKEN_HORIZONTAL_WS at offset 108 on line 5$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^PHPDoc tag @return with type array\|Phaseolies\\Http\\Response\|null is not subtype of native type array\|Phaseolies\\Http\\Response\.$#'
+ identifier: return.phpDocType
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^PHPDoc tag @throws with type Phaseolies\\Http\\SuspiciousOperationException is not subtype of Throwable$#'
+ identifier: throws.notThrowable
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Property Phaseolies\\Http\\Request\:\:\$errors \(array\\) on left side of \?\? is not nullable\.$#'
+ identifier: nullCoalesce.property
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Strict comparison using \!\=\= between resource\|string and false will always evaluate to true\.$#'
+ identifier: notIdentical.alwaysTrue
+ count: 3
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Strict comparison using \=\=\= between null and string will always evaluate to false\.$#'
+ identifier: identical.alwaysFalse
+ count: 2
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Strict comparison using \=\=\= between resource\|string and false will always evaluate to false\.$#'
+ identifier: identical.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Http/Request.php
+
+ -
+ message: '#^Expression on left side of \?\? is not nullable\.$#'
+ identifier: nullCoalesce.expr
+ count: 2
+ path: src/Phaseolies/Http/Response.php
+
+ -
+ message: '#^If condition is always true\.$#'
+ identifier: if.alwaysTrue
+ count: 1
+ path: src/Phaseolies/Http/Response.php
+
+ -
+ message: '#^Instanceof between array\ and Phaseolies\\Http\\HeaderBag will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Http/Response.php
+
+ -
+ message: '#^Method Phaseolies\\Http\\Request\:\:isHead\(\) invoked with 1 parameter, 0 required\.$#'
+ identifier: arguments.count
+ count: 1
+ path: src/Phaseolies/Http/Response.php
+
+ -
+ message: '#^PHPDoc tag @return with type Phaseolies\\Http\\Response is not subtype of native type static\(Phaseolies\\Http\\Response\)\.$#'
+ identifier: return.phpDocType
+ count: 4
+ path: src/Phaseolies/Http/Response.php
+
+ -
+ message: '#^PHPDoc tag @return with type void is incompatible with native type static\(Phaseolies\\Http\\Response\)\.$#'
+ identifier: return.phpDocType
+ count: 1
+ path: src/Phaseolies/Http/Response.php
+
+ -
+ message: '#^Parameter \#2 \$datetime of static method DateTimeImmutable\:\:createFromFormat\(\) expects string, int\<\-172799, max\> given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Http/Response.php
+
+ -
+ message: '#^Parameter \#2 \$value of method Phaseolies\\Http\\HeaderBag\:\:addCacheControlDirective\(\) expects bool\|string, int given\.$#'
+ identifier: argument.type
+ count: 4
+ path: src/Phaseolies/Http/Response.php
+
+ -
+ message: '#^Parameter \#2 \$values of method Phaseolies\\Http\\Response\\ResponseHeaderBag\:\:set\(\) expects array\\|string\|null, int given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Http/Response.php
+
+ -
+ message: '#^Parameter \#2 \$values of method Phaseolies\\Http\\Response\\ResponseHeaderBag\:\:set\(\) expects array\\|string\|null, int\|null given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Http/Response.php
+
+ -
+ message: '#^Unreachable statement \- code above always terminates\.$#'
+ identifier: deadCode.unreachable
+ count: 1
+ path: src/Phaseolies/Http/Response.php
+
+ -
+ message: '#^Method Phaseolies\\Http\\Response\\BaseJsonResponse\:\:update\(\) should return \$this\(Phaseolies\\Http\\Response\\BaseJsonResponse\) but returns static\(Phaseolies\\Http\\Response\\BaseJsonResponse\)\.$#'
+ identifier: return.type
+ count: 2
+ path: src/Phaseolies/Http/Response/BaseJsonResponse.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 1
+ path: src/Phaseolies/Http/Response/BaseJsonResponse.php
+
+ -
+ message: '#^Call to function in_array\(\) with arguments ''lax''\|''none''\|''strict''\|null, array\{''lax'', ''strict'', ''none'', null\} and true will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/Http/Response/Cookie.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 1
+ path: src/Phaseolies/Http/Response/Cookie.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Str\:\:snake\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Http/Response/RedirectResponse.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Str\:\:startsWith\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Http/Response/RedirectResponse.php
+
+ -
+ message: '#^PHPDoc tag @return with type Phaseolies\\Http\\Response is not subtype of native type Phaseolies\\Http\\Response\\RedirectResponse\.$#'
+ identifier: return.phpDocType
+ count: 1
+ path: src/Phaseolies/Http/Response/RedirectResponse.php
+
+ -
+ message: '#^Expression on left side of \?\? is not nullable\.$#'
+ identifier: nullCoalesce.expr
+ count: 1
+ path: src/Phaseolies/Http/Response/ResponseHeaderBag.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$partitioned$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Http/Response/ResponseHeaderBag.php
+
+ -
+ message: '#^Method Phaseolies\\Http\\Request\:\:isGet\(\) invoked with 1 parameter, 0 required\.$#'
+ identifier: arguments.count
+ count: 1
+ path: src/Phaseolies/Http/Response/Stream/BinaryFileResponse.php
+
+ -
+ message: '#^Method Phaseolies\\Http\\Request\:\:isHead\(\) invoked with 1 parameter, 0 required\.$#'
+ identifier: arguments.count
+ count: 1
+ path: src/Phaseolies/Http/Response/Stream/BinaryFileResponse.php
+
+ -
+ message: '#^Parameter \#2 \$datetime of static method DateTimeImmutable\:\:createFromFormat\(\) expects string, int\|false given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Http/Response/Stream/BinaryFileResponse.php
+
+ -
+ message: '#^Parameter \#2 \$values of method Phaseolies\\Http\\Response\\ResponseHeaderBag\:\:set\(\) expects array\\|string\|null, int given\.$#'
+ identifier: argument.type
+ count: 2
+ path: src/Phaseolies/Http/Response/Stream/BinaryFileResponse.php
+
+ -
+ message: '#^Strict comparison using \=\=\= between false and int will always evaluate to false\.$#'
+ identifier: identical.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Http/Response/Stream/BinaryFileResponse.php
+
+ -
+ message: '#^Strict comparison using \=\=\= between false and string will always evaluate to false\.$#'
+ identifier: identical.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Http/Response/Stream/BinaryFileResponse.php
+
+ -
+ message: '#^Instanceof between mixed and ArrayObject will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Http/ResponseFactory.php
+
+ -
+ message: '#^Instanceof between mixed and JsonSerializable will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Http/ResponseFactory.php
+
+ -
+ message: '#^Instanceof between mixed and Phaseolies\\Database\\Entity\\Builder will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Http/ResponseFactory.php
+
+ -
+ message: '#^Instanceof between mixed and Phaseolies\\Database\\Entity\\Model will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Http/ResponseFactory.php
+
+ -
+ message: '#^Instanceof between mixed and Phaseolies\\Support\\Collection will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Http/ResponseFactory.php
+
+ -
+ message: '#^Instanceof between mixed and stdClass will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Http/ResponseFactory.php
+
+ -
+ message: '#^Trait Phaseolies\\Http\\Support\\InteractsWithTrustedProxies is used zero times and is not analysed\.$#'
+ identifier: trait.unused
+ count: 1
+ path: src/Phaseolies/Http/Support/InteractsWithTrustedProxies.php
+
+ -
+ message: '#^Trait Phaseolies\\Http\\Support\\InteractsWithVerifyTwoFactorUser is used zero times and is not analysed\.$#'
+ identifier: trait.unused
+ count: 1
+ path: src/Phaseolies/Http/Support/InteractsWithVerifyTwoFactorUser.php
+
+ -
+ message: '#^Parameter \#1 \$content of function response expects null, string given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Http/Support/RequestAbortion.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 1
+ path: src/Phaseolies/Http/Validation/Bind.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Http\\Validation\\FormRequest\:\:sanitize\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Http/Validation/FormRequest.php
+
+ -
+ message: '#^PHPDoc tag @throws with type Phaseolies\\Http\\Validation\\HttpResponseException is not subtype of Throwable$#'
+ identifier: throws.notThrowable
+ count: 1
+ path: src/Phaseolies/Http/Validation/FormRequest.php
+
+ -
+ message: '#^Call to method getIO\(\) on an unknown class Composer\\Script\\Event\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Installer.php
+
+ -
+ message: '#^Parameter \$event of method Phaseolies\\Installer\:\:postCreateProject\(\) has invalid type Composer\\Script\\Event\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Installer.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$channel$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Logger/Contracts/AbstractHandler.php
+
+ -
+ message: '#^Method Phaseolies\\Middleware\\CacheHeaders\:\:__invoke\(\) has invalid return type Phaseolies\\Middleware\\Phaseolies\\Http\\Response\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Middleware/CacheHeaders.php
+
+ -
+ message: '#^Method Phaseolies\\Middleware\\CacheHeaders\:\:__invoke\(\) should return Phaseolies\\Http\\Response but returns mixed\.$#'
+ identifier: return.type
+ count: 1
+ path: src/Phaseolies/Middleware/CacheHeaders.php
+
+ -
+ message: '#^PHPDoc tag @param has invalid value \(\\Closure\(\\Phaseolies\\Http\\Request\) \$next\)\: Unexpected token "\(", expected variable at offset 124 on line 5$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Middleware/CacheHeaders.php
+
+ -
+ message: '#^PHPDoc tag @return with type Phaseolies\\Middleware\\Phaseolies\\Http\\Response is not subtype of native type Phaseolies\\Http\\Response\.$#'
+ identifier: return.phpDocType
+ count: 1
+ path: src/Phaseolies/Middleware/CacheHeaders.php
+
+ -
+ message: '#^Strict comparison using \!\=\= between string\|null and false will always evaluate to true\.$#'
+ identifier: notIdentical.alwaysTrue
+ count: 1
+ path: src/Phaseolies/Middleware/CacheHeaders.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Str\:\:toUpper\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Middleware/CsrfTokenMiddleware.php
+
+ -
+ message: '#^Dead catch \- Exception is never thrown in the try block\.$#'
+ identifier: catch.neverThrown
+ count: 1
+ path: src/Phaseolies/Middleware/CsrfTokenMiddleware.php
+
+ -
+ message: '#^Method Phaseolies\\Middleware\\CsrfTokenMiddleware\:\:shouldAddXsrfTokenCookie\(\) invoked with 1 parameter, 0 required\.$#'
+ identifier: arguments.count
+ count: 1
+ path: src/Phaseolies/Middleware/CsrfTokenMiddleware.php
+
+ -
+ message: '#^PHPDoc tag @param has invalid value \(\\Closure\(\\Phaseolies\\Http\\Request\) \$next\)\: Unexpected token "\(", expected variable at offset 128 on line 5$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Middleware/CsrfTokenMiddleware.php
+
+ -
+ message: '#^PHPDoc tag @throws with type Phaseolies\\Middleware\\HttpException is not subtype of Throwable$#'
+ identifier: throws.notThrowable
+ count: 1
+ path: src/Phaseolies/Middleware/CsrfTokenMiddleware.php
+
+ -
+ message: '#^Parameter \#1 \$string of function sha1 expects string, int\|null given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Middleware/ThrottleRequests.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Route\:\:group\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Providers/RouteServiceProvider.php
+
+ -
+ message: '#^Method Phaseolies\\Providers\\ServiceProvider\:\:pathsForProviderOrGroup\(\) should return array\|null but returns string\.$#'
+ identifier: return.type
+ count: 1
+ path: src/Phaseolies/Providers/ServiceProvider.php
+
+ -
+ message: '#^Property Phaseolies\\Providers\\ServiceProvider\:\:\$publishGroups \(array\\>\) in isset\(\) is not nullable\.$#'
+ identifier: isset.property
+ count: 1
+ path: src/Phaseolies/Providers/ServiceProvider.php
+
+ -
+ message: '#^Property Phaseolies\\Providers\\ServiceProvider\:\:\$publishes \(array\\) in isset\(\) is not nullable\.$#'
+ identifier: isset.property
+ count: 1
+ path: src/Phaseolies/Providers/ServiceProvider.php
+
+ -
+ message: '#^Property Phaseolies\\Support\\Collection\:\:\$data \(array\) on left side of \?\? is not nullable\.$#'
+ identifier: nullCoalesce.property
+ count: 1
+ path: src/Phaseolies/Support/Collection.php
+
+ -
+ message: '#^Property Phaseolies\\Support\\Collection\:\:\$model \(string\) on left side of \?\? is not nullable\.$#'
+ identifier: nullCoalesce.property
+ count: 2
+ path: src/Phaseolies/Support/Collection.php
+
+ -
+ message: '#^PHPDoc tag @return with type void is incompatible with native type bool\.$#'
+ identifier: return.phpDocType
+ count: 1
+ path: src/Phaseolies/Support/CookieJar.php
+
+ -
+ message: '#^Variable \$cookies on left side of \?\? always exists and is not nullable\.$#'
+ identifier: nullCoalesce.variable
+ count: 1
+ path: src/Phaseolies/Support/CookieJar.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: array\)\: Unexpected token "\\n \* ", expected type at offset 430 on line 10$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: array\)\: Unexpected token "\\n \* ", expected type at offset 657 on line 14$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 327 on line 7$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 477 on line 11$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 763 on line 16$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(Model \$user\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 714 on line 15$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(Model \$user, string \$code\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 605 on line 13$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(string \$code\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 535 on line 12$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(string \$qrCodeUrl\)\: string\)\: Unexpected token "\\n \*", expected type at offset 832 on line 17$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static id\(\)\: \?int\)\: Unexpected token "\\n \* ", expected type at offset 383 on line 9$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static onceUsingId\(int \$id\)\: \?User\)\: Unexpected token "\\n \* ", expected type at offset 263 on line 5$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static user\(\)\: \?User\)\: Unexpected token "\\n \* ", expected type at offset 295 on line 6$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Auth.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(string \$name, string \$owner\)\: AtomicLock\)\: Unexpected token "\\n \* ", expected type at offset 1405 on line 18$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Cache.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: PDO\)\: Unexpected token "\\n \* ", expected type at offset 631 on line 14$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: array\)\: Unexpected token "\\n \* ", expected type at offset 489 on line 11$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: int\)\: Unexpected token "\\n \* ", expected type at offset 297 on line 7$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: int\)\: Unexpected token "\\n \* ", expected type at offset 402 on line 9$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 114 on line 3$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 148 on line 4$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 184 on line 5$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: void\)\: Unexpected token "\\n \*", expected type at offset 1532 on line 26$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(Model \$model\)\: string\)\: Unexpected token "\\n \* ", expected type at offset 591 on line 13$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(string \$table\)\: Builder\)\: Unexpected token "\\n \* ", expected type at offset 451 on line 10$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(string \$table\)\: \\Phaseolies\\Database\\Entity\\Query\\Builder\)\: Unexpected token "\\n \* ", expected type at offset 1221 on line 21$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(string \$table\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 541 on line 12$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/DB.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: array\)\: Unexpected token "\\n \* ", expected type at offset 2932 on line 58$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 1633 on line 30$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 1667 on line 31$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 1708 on line 32$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 1886 on line 36$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 1927 on line 37$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 2897 on line 57$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3103 on line 61$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3142 on line 62$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3182 on line 63$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3222 on line 64$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3253 on line 65$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3291 on line 66$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3328 on line 67$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 661 on line 14$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 695 on line 15$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: int\)\: Unexpected token "\\n \* ", expected type at offset 2068 on line 40$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: int\)\: Unexpected token "\\n \* ", expected type at offset 433 on line 10$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 1020 on line 21$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 1747 on line 33$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 1785 on line 34$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2103 on line 41$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2863 on line 56$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: string\)\: Unexpected token "\\n \* ", expected type at offset 1252 on line 25$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: string\)\: Unexpected token "\\n \* ", expected type at offset 804 on line 17$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\?\\DateTimeInterface \$date\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2219 on line 43$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\?string \$body\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 52 on line 2$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(HttpException \$exception\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 1217 on line 24$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(Request \$request\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3067 on line 60$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(Request \$request\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 856 on line 18$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\\DateTimeInterface \$date\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2036 on line 39$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(array \$headers\)\: Response\)\: Unexpected token "\\n \* ", expected type at offset 573 on line 12$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(array \$options\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2820 on line 55$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(int \$seconds\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2553 on line 50$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(int \$seconds\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2606 on line 51$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(int \$statusCode\)\: string\)\: Unexpected token "\\n \* ", expected type at offset 980 on line 20$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(int \$targetLevel, bool \$flush\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 1145 on line 23$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(int \$value\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2303 on line 45$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(int \$value\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2357 on line 46$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(int \$value\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2419 on line 47$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(int \$value\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2473 on line 48$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(string \$charset\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 224 on line 6$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(string \$version\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 757 on line 16$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static getBody\(\)\: \?string\)\: Unexpected token "\\n \* ", expected type at offset 89 on line 3$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static getCharset\(\)\: \?string\)\: Unexpected token "\\n \* ", expected type at offset 264 on line 7$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static getDate\(\)\: \?\\DateTimeImmutable\)\: Unexpected token "\\n \* ", expected type at offset 1976 on line 38$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static getEtag\(\)\: \?string\)\: Unexpected token "\\n \* ", expected type at offset 2700 on line 53$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static getExpires\(\)\: \?\\DateTimeImmutable\)\: Unexpected token "\\n \* ", expected type at offset 2155 on line 42$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static getLastModified\(\)\: \?\\DateTimeImmutable\)\: Unexpected token "\\n \* ", expected type at offset 2663 on line 52$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static getMaxAge\(\)\: \?int\)\: Unexpected token "\\n \* ", expected type at offset 2255 on line 44$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static getTtl\(\)\: \?int\)\: Unexpected token "\\n \* ", expected type at offset 2506 on line 49$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Response.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: array\)\: Unexpected token "\\n \* ", expected type at offset 529 on line 12$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Session.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: string\)\: Unexpected token "\\n \* ", expected type at offset 563 on line 13$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Session.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 253 on line 6$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Session.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 432 on line 10$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Session.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 639 on line 15$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Session.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 801 on line 19$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Session.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(\)\: void\)\: Unexpected token "\\n \*", expected type at offset 843 on line 20$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Session.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(string \$id\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 605 on line 14$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Session.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(string \$key\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 356 on line 8$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Session.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(\(string \$key\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 400 on line 9$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Session.php
+
+ -
+ message: '#^PHPDoc tag @method has invalid value \(static token\(\)\: \?string\)\: Unexpected token "\\n \* ", expected type at offset 674 on line 16$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/Facades/Session.php
+
+ -
+ message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Storage\:\:getDiskPath\(\)\.$#'
+ identifier: staticMethod.notFound
+ count: 1
+ path: src/Phaseolies/Support/File.php
+
+ -
+ message: '#^Call to function is_null\(\) with string will always evaluate to false\.$#'
+ identifier: function.impossibleType
+ count: 1
+ path: src/Phaseolies/Support/File.php
+
+ -
+ message: '#^Method Phaseolies\\Support\\File\:\:generateUniqueName\(\) invoked with 1 parameter, 0 required\.$#'
+ identifier: arguments.count
+ count: 2
+ path: src/Phaseolies/Support/File.php
+
+ -
+ message: '#^PHPDoc tag @method for method Phaseolies\\Support\\Lens\:\:flat\(\) parameter \#2 \$depth default value contains unresolvable type\.$#'
+ identifier: methodTag.unresolvableType
+ count: 1
+ path: src/Phaseolies/Support/Lens.php
+
+ -
+ message: '#^Unsafe call to private method Phaseolies\\Support\\Lens\:\:make\(\) through static\:\:\.$#'
+ identifier: staticClassAccess.privateMethod
+ count: 1
+ path: src/Phaseolies/Support/Lens.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 1
+ path: src/Phaseolies/Support/Lens.php
+
+ -
+ message: '#^Property Phaseolies\\Support\\Mail\\Driver\\MailMailDriver\:\:\$config is never read, only written\.$#'
+ identifier: property.onlyWritten
+ count: 1
+ path: src/Phaseolies/Support/Mail/Driver/MailMailDriver.php
+
+ -
+ message: '#^Property Phaseolies\\Support\\Mail\\Driver\\QmailMailDriver\:\:\$config is never read, only written\.$#'
+ identifier: property.onlyWritten
+ count: 1
+ path: src/Phaseolies/Support/Mail/Driver/QmailMailDriver.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$email\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Phaseolies/Support/Mail/MailService.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Support\\Mail\\Mailable\:\:attachment\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Support/Mail/MailService.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Support\\Mail\\Mailable\:\:subject\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Support/Mail/MailService.php
+
+ -
+ message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 2
+ path: src/Phaseolies/Support/Mail/MailService.php
+
+ -
+ message: '#^Class Phaseolies\\Support\\Mail\\MailService constructor invoked with 1 parameter, 0 required\.$#'
+ identifier: arguments.count
+ count: 1
+ path: src/Phaseolies/Support/Mail/MailService.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Support\\Mail\\Mailable\:\:content\(\)\.$#'
+ identifier: method.notFound
+ count: 3
+ path: src/Phaseolies/Support/Mail/Mailable/View.php
+
+ -
+ message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/Support/Mail/Mailable/View.php
+
+ -
+ message: '#^Using nullsafe property access "\?\-\>view" in empty\(\) is unnecessary\. Use \-\> instead\.$#'
+ identifier: nullsafe.neverNull
+ count: 1
+ path: src/Phaseolies/Support/Mail/Mailable/View.php
+
+ -
+ message: '#^Unsafe usage of new static\(\)\.$#'
+ identifier: new.static
+ count: 1
+ path: src/Phaseolies/Support/Presenter/Presenter.php
+
+ -
+ message: '#^Access to an undefined property Phaseolies\\Support\\Router\:\:\$routeMiddleware\.$#'
+ identifier: property.notFound
+ count: 2
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Call to an undefined method \$this\(Phaseolies\\Support\\Router\)\:\:applyMiddleware\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Support\\Router\:\:handle\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Call to an undefined method ReflectionType\:\:getName\(\)\.$#'
+ identifier: method.notFound
+ count: 4
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Call to an undefined method ReflectionType\:\:isBuiltin\(\)\.$#'
+ identifier: method.notFound
+ count: 4
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 2
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Call to function is_string\(\) with \*NEVER\* will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Cannot call method bindTo\(\) on request\|string\.$#'
+ identifier: method.nonObject
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Cannot call method validateDto\(\) on request\|string\.$#'
+ identifier: method.nonObject
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Class App\\Http\\Controllers\\Controller not found\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Instanceof between \*NEVER\* and Closure will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Instanceof between mixed and ArrayObject will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Instanceof between mixed and JsonSerializable will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Instanceof between mixed and Phaseolies\\Database\\Entity\\Builder will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Instanceof between mixed and Phaseolies\\Database\\Entity\\Model will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Instanceof between mixed and Ramsey\\Collection\\Collection will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Instanceof between mixed and stdClass will always evaluate to false\.$#'
+ identifier: instanceof.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Method Phaseolies\\Support\\Router\:\:middleware\(\) has invalid return type Phaseolies\\Support\\Route\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Offset 0 on non\-empty\-list\&callable\(string\)\: void on left side of \?\? always exists and is not nullable\.$#'
+ identifier: nullCoalesce.offset
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$key$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^PHPDoc tag @return with type Phaseolies\\Support\\Route is not subtype of native type Phaseolies\\Support\\Router\.$#'
+ identifier: return.phpDocType
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Parameter \#1 \$abstract of method Phaseolies\\Application\:\:make\(\) expects class\-string\, string given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Property Phaseolies\\Http\\Requests\\Attributes\\BindPayload\:\:\$strict \(bool\) on left side of \?\? is not nullable\.$#'
+ identifier: nullCoalesce.property
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Property Phaseolies\\Support\\Router\\Attributes\\Route\:\:\$middleware \(array\) on left side of \?\? is not nullable\.$#'
+ identifier: nullCoalesce.property
+ count: 1
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^Variable \$attributes on left side of \?\? always exists and is not nullable\.$#'
+ identifier: nullCoalesce.variable
+ count: 2
+ path: src/Phaseolies/Support/Router.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$data$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Support/Session.php
+
+ -
+ message: '#^Call to function is_null\(\) with string will always evaluate to false\.$#'
+ identifier: function.impossibleType
+ count: 1
+ path: src/Phaseolies/Support/Storage/FileSystem.php
+
+ -
+ message: '#^Instanceof between Iterator and Traversable will always evaluate to true\.$#'
+ identifier: instanceof.alwaysTrue
+ count: 1
+ path: src/Phaseolies/Support/StreamCollection.php
+
+ -
+ message: '#^Result of && is always false\.$#'
+ identifier: booleanAnd.alwaysFalse
+ count: 1
+ path: src/Phaseolies/Support/StreamCollection.php
+
+ -
+ message: '#^Unreachable statement \- code above always terminates\.$#'
+ identifier: deadCode.unreachable
+ count: 1
+ path: src/Phaseolies/Support/StreamCollection.php
+
+ -
+ message: '#^Offset 0 on array\{list\\} on left side of \?\? always exists and is not nullable\.$#'
+ identifier: nullCoalesce.offset
+ count: 1
+ path: src/Phaseolies/Support/StringService.php
+
+ -
+ message: '#^PHPDoc tag @param has invalid value \(string\)\: Unexpected token "\\n ", expected variable at offset 219 on line 7$#'
+ identifier: phpDoc.parseError
+ count: 1
+ path: src/Phaseolies/Support/StringService.php
+
+ -
+ message: '#^Expression on left side of \?\? is not nullable\.$#'
+ identifier: nullCoalesce.expr
+ count: 1
+ path: src/Phaseolies/Support/UrlGenerator.php
+
+ -
+ message: '#^Strict comparison using \=\=\= between mixed and null will always evaluate to false\.$#'
+ identifier: identical.alwaysFalse
+ count: 2
+ path: src/Phaseolies/Support/Validation/Sanitizer.php
+
+ -
+ message: '#^Call to an undefined method Phaseolies\\Support\\View\\View\:\:prepare\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Phaseolies/Support/View/View.php
+
+ -
+ message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
+ identifier: function.alreadyNarrowedType
+ count: 1
+ path: src/Phaseolies/Support/View/View.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$view$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Support/View/View.php
+
+ -
+ message: '#^Variable \$message might not be defined\.$#'
+ identifier: variable.undefined
+ count: 1
+ path: src/Phaseolies/Support/View/errors/401.odo.php
+
+ -
+ message: '#^Variable \$message might not be defined\.$#'
+ identifier: variable.undefined
+ count: 1
+ path: src/Phaseolies/Support/View/errors/403.odo.php
+
+ -
+ message: '#^Variable \$message might not be defined\.$#'
+ identifier: variable.undefined
+ count: 1
+ path: src/Phaseolies/Support/View/errors/404.odo.php
+
+ -
+ message: '#^Variable \$message might not be defined\.$#'
+ identifier: variable.undefined
+ count: 1
+ path: src/Phaseolies/Support/View/errors/429.odo.php
+
+ -
+ message: '#^Variable \$message might not be defined\.$#'
+ identifier: variable.undefined
+ count: 1
+ path: src/Phaseolies/Support/View/errors/500.odo.php
+
+ -
+ message: '#^Call to function is_string\(\) with array will always evaluate to false\.$#'
+ identifier: function.impossibleType
+ count: 1
+ path: src/Phaseolies/Utilities/Attributes/Route.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$v4Bytes$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Utilities/IpUtils.php
+
+ -
+ message: '#^PHPDoc tag @param references unknown parameter\: \$v6Bytes$#'
+ identifier: parameter.notFound
+ count: 1
+ path: src/Phaseolies/Utilities/IpUtils.php
+
+ -
+ message: '#^PHPDoc tag @return with type string\|null is incompatible with native type array\.$#'
+ identifier: return.phpDocType
+ count: 1
+ path: src/Phaseolies/Utilities/Paginator.php
+
+ -
+ message: '#^Strict comparison using \=\=\= between 1 and 1 will always evaluate to true\.$#'
+ identifier: identical.alwaysTrue
+ count: 1
+ path: src/Phaseolies/Utilities/Paginator.php
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 00000000..5ed7822e
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,9 @@
+includes:
+ - phpstan-baseline.neon
+
+parameters:
+ level: 5
+ paths:
+ - src
+ tmpDir: .phpstan.cache
+ reportUnmatchedIgnoredErrors: false
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 7ff869cc..d042a2fe 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -10,10 +10,11 @@
+
-
\ No newline at end of file
+
diff --git a/src/Phaseolies/Application.php b/src/Phaseolies/Application.php
index 847c64c4..7b1c2004 100644
--- a/src/Phaseolies/Application.php
+++ b/src/Phaseolies/Application.php
@@ -4,24 +4,31 @@
use Phaseolies\Auth\ActorManager;
use Phaseolies\Support\Router;
-use Phaseolies\Providers\GhostableProvider;
-use Phaseolies\Providers\ServiceProvider;
+use Phaseolies\Launchers\GhostableLauncher;
+use Phaseolies\Launchers\ServiceLauncher;
use Phaseolies\Http\DispatchResult;
use Phaseolies\Http\Response;
use Phaseolies\Http\Request;
+use Phaseolies\Http\Contracts\GatewayInterface;
use Phaseolies\Http\Exceptions\HttpException;
use Phaseolies\Error\ErrorHandler;
use Phaseolies\DI\Container;
use Phaseolies\Config\Config;
+use Phaseolies\Support\DeferScope;
+use Phaseolies\Support\ExecutionContext;
+use Phaseolies\Support\Concurrency\TaskGroup;
+use React\EventLoop\Loop;
+use React\EventLoop\LoopInterface;
use Phaseolies\ApplicationBuilder;
-use Dotenv\Dotenv;
+use Devium\Toml\Toml;
+use RuntimeException;
class Application extends Container
{
/**
* The current version of the Doppar framework.
*/
- const VERSION = '3.26.6';
+ const VERSION = '4.8.4';
/**
* The base path of the application installation.
@@ -56,7 +63,7 @@ class Application extends Container
*
* @var string
*/
- protected $resourcesPath;
+ protected $templatesPath;
/**
* The path to the client-side assets directory.
@@ -84,7 +91,7 @@ class Application extends Container
*
* @var string
*/
- protected $databasePath;
+ protected $schemaPath;
/**
* The path to the public directory.
@@ -105,7 +112,7 @@ class Application extends Container
*
* @var string
*/
- protected $environmentFile = '.env';
+ protected $environmentFile = 'env.toml';
/**
* The environment name.
@@ -122,7 +129,7 @@ class Application extends Container
protected $isRunningInConsole = null;
/**
- * The registered service providers.
+ * The registered service launchers.
*
* @var array
*/
@@ -131,28 +138,28 @@ class Application extends Container
/**
* The queued ghost providers keyed by class name.
*
- * @var array, ServiceProvider>
+ * @var array, ServiceLauncher>
*/
protected array $ghostProviders = [];
/**
* Map of service identifiers to ghost provider classes.
*
- * @var array>
+ * @var array>
*/
protected array $ghostServices = [];
/**
* Tracks ghost providers that have already been loaded.
*
- * @var array, true>
+ * @var array, true>
*/
protected array $loadedGhostProviders = [];
/**
* Tracks ghost providers currently being loaded.
*
- * @var array, true>
+ * @var array, true>
*/
protected array $loadingGhostProviders = [];
@@ -173,7 +180,7 @@ class Application extends Container
/**
* Tracks providers whose boot method has already run.
*
- * @var array, true>
+ * @var array, true>
*/
protected array $bootedProviderClasses = [];
@@ -210,20 +217,38 @@ class Application extends Container
*/
protected array $terminatingCallbacks = [];
+ /**
+ * Active defer scopes, innermost last.
+ *
+ * @var array
+ */
+ protected array $deferScopes = [];
+
+ /**
+ * Custom reporter for exceptions thrown by deferred callbacks.
+ *
+ * @var callable|null
+ */
+ protected $deferFailureReporter = null;
+
/**
* Application constructor.
*
* Initializes the application by:
* - Setting the application instance in the container.
+ * - Recording the base path and deriving every other framework
+ * path from it, before anything below can ask for one.
* - Loading environment variables from .env before anything else.
* - Setting up exception handling.
* - Loading configuration.
- * - Defining necessary folder paths.
* - Registering and booting core service providers.
+ *
+ * @param string $basePath
*/
- public function __construct()
+ public function __construct(string $basePath)
{
parent::setInstance($this);
+ $this->withBasePath($basePath);
$this->loadEnvironmentVariables();
$this->withExceptionHandler();
$this->withConfiguration();
@@ -239,7 +264,7 @@ public function __construct()
*/
public function langPath($path = ''): string
{
- return $this->getPath($this->buildPathFragment('lang', $path));
+ return $this->getPath($this->buildPathFragment('templates/lang', $path));
}
/**
@@ -250,9 +275,7 @@ public function langPath($path = ''): string
*/
public function configure(Application $app): ApplicationBuilder
{
- return (new ApplicationBuilder($app))
- ->withTimezone()
- ->withMiddlewareStack();
+ return (new ApplicationBuilder($app))->withTimezone();
}
/**
@@ -263,7 +286,7 @@ public function configure(Application $app): ApplicationBuilder
*/
public function withBasePath(string $basePath): self
{
- $this->basePath = $basePath;
+ $this->basePath = rtrim($basePath, '\/');
$this->setNecessaryFolderPath();
@@ -283,7 +306,7 @@ public function withExceptionHandler(): self
}
/**
- * Load environment variables from .env file
+ * Load environment variables from env.toml
*
* @return void
*/
@@ -293,8 +316,39 @@ protected function loadEnvironmentVariables(): void
return;
}
- $dotenv = Dotenv::createImmutable(base_path());
- $dotenv->safeLoad();
+ $tomlFile = $this->basePath('env.toml');
+
+ if (is_file($tomlFile)) {
+ $this->loadTomlEnvironment($tomlFile);
+ }
+ }
+
+ /**
+ * Load environment variables from a flat env.toml file.
+ *
+ * @param string $file
+ * @return void
+ *
+ * @throws RuntimeException
+ */
+ protected function loadTomlEnvironment(string $file): void
+ {
+ $values = Toml::decode(file_get_contents($file), asArray: true);
+
+ foreach ($values as $key => $value) {
+ if (is_array($value)) {
+ throw new RuntimeException(
+ "env.toml key \"{$key}\" must be a scalar value — nested tables and arrays are not supported."
+ );
+ }
+
+ if (getenv($key) !== false) {
+ continue;
+ }
+
+ $_ENV[$key] = $value;
+ $_SERVER[$key] = $value;
+ }
}
/**
@@ -331,7 +385,7 @@ public function getEnvironment(): string
*/
protected function registerCoreProviders(): self
{
- $providers = [...($this->loadCoreProviders()), ...(config('app.providers') ?? [])];
+ $providers = [...($this->loadCoreProviders()), ...(config('app.launchers') ?? [])];
$this->registerProviders($providers);
@@ -348,7 +402,7 @@ protected function registerProviders(array $providers = []): void
foreach ($providers as $provider) {
$providerInstance = new $provider($this);
- if ($providerInstance instanceof ServiceProvider) {
+ if ($providerInstance instanceof ServiceLauncher) {
if ($this->shouldQueueGhostProvider($providerInstance)) {
$this->queueGhostProvider($providerInstance);
continue;
@@ -362,21 +416,21 @@ protected function registerProviders(array $providers = []): void
/**
* Determine if the provider should be queued as a ghost provider
*
- * @param ServiceProvider $providerInstance
+ * @param ServiceLauncher $providerInstance
* @return bool
*/
- protected function shouldQueueGhostProvider(ServiceProvider $providerInstance): bool
+ protected function shouldQueueGhostProvider(ServiceLauncher $providerInstance): bool
{
- return !$this->runningInConsole() && $providerInstance instanceof GhostableProvider;
+ return !$this->runningInConsole() && $providerInstance instanceof GhostableLauncher;
}
/**
* Register and track an eager provider instance.
*
- * @param ServiceProvider $providerInstance
+ * @param ServiceLauncher $providerInstance
* @return void
*/
- protected function registerProviderInstance(ServiceProvider $providerInstance): void
+ protected function registerProviderInstance(ServiceLauncher $providerInstance): void
{
$providerInstance->register();
@@ -386,12 +440,12 @@ protected function registerProviderInstance(ServiceProvider $providerInstance):
/**
* Queue a ghost provider until one of its services is requested.
*
- * @param ServiceProvider $providerInstance
+ * @param ServiceLauncher $providerInstance
* @return void
*/
- protected function queueGhostProvider(ServiceProvider $providerInstance): void
+ protected function queueGhostProvider(ServiceLauncher $providerInstance): void
{
- /** @var GhostableProvider $providerInstance */
+ /** @var ServiceLauncher&GhostableLauncher $providerInstance */
$providerClass = $providerInstance::class;
$this->ghostProviders[$providerClass] = $providerInstance;
@@ -450,14 +504,13 @@ protected function bootProviders(): void
*/
protected function setNecessaryFolderPath(): void
{
- $this->basePath = $this->basePath();
$this->configPath = $this->configPath();
$this->appPath = $this->appPath();
$this->bootstrapPath = $this->bootstrapPath();
- $this->databasePath = $this->databasePath();
+ $this->schemaPath = $this->schemaPath();
$this->publicPath = $this->publicPath();
$this->storagePath = $this->storagePath();
- $this->resourcesPath = $this->resourcesPath();
+ $this->templatesPath = $this->templatesPath();
$this->clientPath = $this->clientPath();
}
@@ -479,14 +532,14 @@ protected function getPath(string $folder): string
}
/**
- * Gets the resources path.
+ * Gets the templates path.
*
* @param string $path
* @return string
*/
- public function resourcesPath($path = ''): string
+ public function templatesPath($path = ''): string
{
- return $this->resourcesPath = $this->getPath($this->buildPathFragment('resources', $path));
+ return $this->templatesPath = $this->getPath($this->buildPathFragment('templates', $path));
}
/**
@@ -497,7 +550,7 @@ public function resourcesPath($path = ''): string
*/
public function clientPath($path = ''): string
{
- return $this->clientPath = $this->getPath($this->buildPathFragment('resources/client', $path));
+ return $this->clientPath = $this->getPath($this->buildPathFragment('templates/client', $path));
}
/**
@@ -508,7 +561,7 @@ public function clientPath($path = ''): string
*/
public function bootstrapPath($path = ''): string
{
- return $this->bootstrapPath = $this->getPath($this->buildPathFragment('bootstrap', $path));
+ return $this->bootstrapPath = $this->getPath($this->buildPathFragment('runtime', $path));
}
/**
@@ -517,9 +570,9 @@ public function bootstrapPath($path = ''): string
* @param string $path
* @return string
*/
- public function databasePath($path = ''): string
+ public function schemaPath($path = ''): string
{
- return $this->databasePath = $this->getPath($this->buildPathFragment('database', $path));
+ return $this->schemaPath = $this->getPath($this->buildPathFragment('schema', $path));
}
/**
@@ -568,17 +621,24 @@ protected function buildPathFragment(string $prefix, string $path = ''): string
*/
public function appPath(): string
{
- return $this->appPath = $this->basePath();
+ return $this->appPath = $this->getPath('src');
}
/**
* Gets the base path of the application.
*
+ * @param string $path
* @return string
*/
- public function basePath(): string
+ public function basePath(string $path = ''): string
{
- return $this->basePath = base_path();
+ if ($path === '') {
+ return $this->basePath;
+ }
+
+ $normalizedPath = trim(str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path), DIRECTORY_SEPARATOR);
+
+ return rtrim($this->basePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $normalizedPath;
}
/**
@@ -588,7 +648,7 @@ public function basePath(): string
*/
public function configPath($path = ''): string
{
- return $this->configPath = $this->getPath("config/{$path}");
+ return $this->configPath = $this->getPath("runtime/config/{$path}");
}
/**
@@ -748,9 +808,9 @@ public function bindApplicationNecessaryPath(): void
$this->singleton('path.config', fn() => $this->configPath());
$this->singleton('path.public', fn() => $this->publicPath());
$this->singleton('path.storage', fn() => $this->storagePath());
- $this->singleton('path.resources', fn() => $this->resourcesPath());
+ $this->singleton('path.templates', fn() => $this->templatesPath());
$this->singleton('path.client', fn() => $this->clientPath());
- $this->singleton('path.database', fn() => $this->databasePath());
+ $this->singleton('path.database', fn() => $this->schemaPath());
}
/**
@@ -761,7 +821,12 @@ public function bindApplicationNecessaryPath(): void
protected function bindSingletonClasses(): void
{
$this->bindApplicationNecessaryPath();
- $this->singleton('request', Request::class);
+
+ $this->singleton('request', fn() => Request::capture());
+
+ $this->singleton(LoopInterface::class, fn() => Loop::get());
+
+ $this->bindHttpGateway();
$this->singleton('route', Router::class);
$this->router = app('route');
@@ -781,11 +846,23 @@ protected function bindSingletonClasses(): void
fn() =>
new \Phaseolies\Database\Migration\Migrator(
new \Phaseolies\Database\Migration\MigrationRepository(),
- database_path('migrations')
+ schema_path('migrations')
)
);
}
+ /**
+ * Bind the application's HTTP middleware gateway
+ *
+ * @return void
+ */
+ protected function bindHttpGateway(): void
+ {
+ if (!$this->has(GatewayInterface::class) && class_exists(\App\Http\Gateway::class)) {
+ $this->singleton(GatewayInterface::class, \App\Http\Gateway::class);
+ }
+ }
+
/**
* Loads the core service providers for the application.
*
@@ -794,12 +871,12 @@ protected function bindSingletonClasses(): void
protected function loadCoreProviders(): array
{
return [
- \Phaseolies\Providers\FacadeServiceProvider::class,
- \Phaseolies\Providers\LanguageServiceProvider::class,
- \Phaseolies\Providers\SessionServiceProvider::class,
- \Phaseolies\Providers\RouteServiceProvider::class,
- \Phaseolies\Providers\CacheServiceProvider::class,
- \Phaseolies\Providers\RateLimiterServiceProvider::class,
+ \Phaseolies\Launchers\FacadeLauncher::class,
+ \Phaseolies\Launchers\LanguageLauncher::class,
+ \Phaseolies\Launchers\SessionLauncher::class,
+ \Phaseolies\Launchers\RouteLauncher::class,
+ \Phaseolies\Launchers\CacheLauncher::class,
+ \Phaseolies\Launchers\RateLimiterLauncher::class,
];
}
@@ -813,13 +890,23 @@ public function getProviders(): array
return $this->serviceProviders;
}
+ /**
+ * Get all registered service launchers.
+ *
+ * @return array
+ */
+ public function getLaunchers(): array
+ {
+ return $this->serviceProviders;
+ }
+
/**
* Get a specific provider by class name
*
* @param string $provider
- * @return ServiceProvider|null
+ * @return ServiceLauncher|null
*/
- public function getProvider(string $provider): ?ServiceProvider
+ public function getProvider(string $provider): ?ServiceLauncher
{
foreach ($this->serviceProviders as $serviceProvider) {
if (get_class($serviceProvider) === $provider) {
@@ -830,6 +917,14 @@ public function getProvider(string $provider): ?ServiceProvider
return null;
}
+ /**
+ * Get a registered launcher by class name.
+ */
+ public function getLauncher(string $launcher): ?ServiceLauncher
+ {
+ return $this->getProvider($launcher);
+ }
+
/**
* Determine if the application has a binding or queued ghost for the given service.
*
@@ -861,13 +956,13 @@ public function loadGhostProvider(string $abstract): bool
$providerInstance = $this->ghostProviders[$providerClass] ?? null;
- if (!$providerInstance instanceof ServiceProvider) {
+ if (!$providerInstance instanceof ServiceLauncher) {
return false;
}
$this->loadingGhostProviders[$providerClass] = true;
- foreach (($providerInstance instanceof GhostableProvider ? $providerInstance->ghosts() : []) as $ghost) {
+ foreach (($providerInstance instanceof GhostableLauncher ? $providerInstance->ghosts() : []) as $ghost) {
unset($this->ghostServices[$ghost]);
}
@@ -889,10 +984,10 @@ public function loadGhostProvider(string $abstract): bool
/**
* Boot a provider instance once.
*
- * @param ServiceProvider $providerInstance
+ * @param ServiceLauncher $providerInstance
* @return void
*/
- protected function bootProviderInstance(ServiceProvider $providerInstance): void
+ protected function bootProviderInstance(ServiceLauncher $providerInstance): void
{
$providerClass = $providerInstance::class;
@@ -900,7 +995,7 @@ protected function bootProviderInstance(ServiceProvider $providerInstance): void
return;
}
- $providerInstance->boot();
+ $providerInstance->launch();
$this->bootedProviderClasses[$providerClass] = true;
}
@@ -1045,14 +1140,253 @@ public function terminating(callable $callback): self
* @param \Throwable|null $exception
* @return void
*/
- public function terminate(Request $request, ?Response $response = null, ?\Throwable $exception = null): void
- {
+ public function terminate(
+ Request $request,
+ ?Response $response = null,
+ ?\Throwable $exception = null,
+ ?DeferScope $deferScope = null,
+ ?ExecutionContext $context = null
+ ): void {
+ if ($context !== null) {
+ $this->resumeContext($context);
+ }
+
+ if ($deferScope !== null) {
+ // Make the request scope current again so terminating callbacks
+ // can still defer cleanup into it.
+ $this->resumeDeferScope($deferScope);
+ }
+
+ $terminationFailure = null;
+
try {
foreach ($this->terminatingCallbacks as $callback) {
$this->callTerminatingCallback($callback, $request, $response, $exception);
}
+ } catch (\Throwable $e) {
+ // Remember it so deferred-callback failures are reported against the
+ // exception that is actually ending the request, then let it propagate.
+ $terminationFailure = $e;
+
+ throw $e;
} finally {
- $this->cleanupRequestScopedServices();
+ try {
+ if ($deferScope !== null) {
+ $this->endDeferScope($deferScope, $terminationFailure ?? $exception);
+ }
+ } finally {
+ $cleanupFailure = null;
+
+ try {
+ $this->cleanupRequestScopedServices();
+ } catch (\Throwable $e) {
+ $cleanupFailure = $e;
+ }
+
+ try {
+ if ($context !== null) {
+ $this->endContext($context, $terminationFailure ?? $cleanupFailure);
+ }
+ } finally {
+ if ($cleanupFailure !== null) {
+ // Never replace the exception that is already ending the request.
+ if ($terminationFailure !== null) {
+ $this->reportTeardownFailure('Request cleanup failed', $cleanupFailure);
+ } else {
+ throw $cleanupFailure;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Register a callback to run when the current execution ends.
+ *
+ * @param callable $callback
+ * @return self
+ * @throws \LogicException
+ */
+ public function defer(callable $callback): self
+ {
+ $scope = end($this->deferScopes);
+
+ if (!$scope instanceof DeferScope) {
+ throw new \LogicException(
+ 'defer() was called outside an active execution scope. '
+ . 'Run the code through runInDeferScope(), an HTTP dispatch or a console command.'
+ );
+ }
+
+ $scope->push($callback);
+
+ return $this;
+ }
+
+ /**
+ * Determine if a defer scope is currently active.
+ *
+ * @return bool
+ */
+ public function hasDeferScope(): bool
+ {
+ return $this->deferScopes !== [];
+ }
+
+ /**
+ * Start a new, independent defer scope and make it the current one.
+ *
+ * @return DeferScope
+ */
+ public function beginDeferScope(): DeferScope
+ {
+ return $this->deferScopes[] = new DeferScope();
+ }
+
+ /**
+ * Detach a scope from the active stack without running its callbacks.
+ *
+ *
+ * @param DeferScope $scope
+ * @return void
+ */
+ public function suspendDeferScope(DeferScope $scope): void
+ {
+ $index = array_search($scope, $this->deferScopes, true);
+
+ if ($index === false) {
+ return;
+ }
+
+ while (count($this->deferScopes) > $index + 1) {
+ $this->endDeferScope(end($this->deferScopes));
+ }
+
+ array_pop($this->deferScopes);
+ }
+
+ /**
+ * Make a previously suspended scope the current scope again.
+ *
+ * @param DeferScope $scope
+ * @return void
+ */
+ public function resumeDeferScope(DeferScope $scope): void
+ {
+ if ($scope->isClosed() || in_array($scope, $this->deferScopes, true)) {
+ return;
+ }
+
+ $this->deferScopes[] = $scope;
+ }
+
+ /**
+ * End a defer scope: run its callbacks and discard it.
+ *
+ * @param DeferScope $scope
+ * @param \Throwable|null $exception
+ * @return void
+ */
+ public function endDeferScope(DeferScope $scope, ?\Throwable $exception = null): void
+ {
+ $index = array_search($scope, $this->deferScopes, true);
+
+ if ($index === false) {
+ // Not current: either already ended or suspended. Run a
+ // suspended scope in place; a closed scope is a no-op.
+ $this->deferScopes[] = $scope;
+ $index = count($this->deferScopes) - 1;
+ }
+
+ while (count($this->deferScopes) > $index) {
+ // Run while still current, so callbacks may defer further cleanup.
+ $ending = end($this->deferScopes);
+ $failures = $ending->run();
+
+ if (end($this->deferScopes) === $ending) {
+ array_pop($this->deferScopes);
+ }
+
+ foreach ($failures as $failure) {
+ $this->reportDeferFailure($failure, $exception);
+ }
+ }
+ }
+
+ /**
+ * Run a callback inside its own defer scope.
+ *
+ * @param callable $callback
+ * @return mixed
+ */
+ public function runInDeferScope(callable $callback): mixed
+ {
+ $scope = $this->beginDeferScope();
+
+ try {
+ return $callback();
+ } catch (\Throwable $e) {
+ $this->endDeferScope($scope, $e);
+
+ throw $e;
+ } finally {
+ $this->endDeferScope($scope);
+ }
+ }
+
+ /**
+ * Set a custom reporter for exceptions thrown by deferred callbacks.
+ *
+ * @param callable(\Throwable, ?\Throwable): void|null $reporter
+ * @return self
+ */
+ public function reportDeferredExceptionsUsing(?callable $reporter): self
+ {
+ $this->deferFailureReporter = $reporter;
+
+ return $this;
+ }
+
+ /**
+ * Report an exception thrown by a deferred callback.
+ *
+ * @param \Throwable $failure
+ * @param \Throwable|null $original
+ * @return void
+ */
+ protected function reportDeferFailure(\Throwable $failure, ?\Throwable $original): void
+ {
+ $reported = false;
+
+ try {
+ if ($this->deferFailureReporter !== null) {
+ ($this->deferFailureReporter)($failure, $original);
+ $reported = true;
+ } elseif ($this->has('log')) {
+ \Phaseolies\Support\Facades\Log::error('Deferred callback failed: ' . $failure->getMessage(), [
+ 'exception' => $failure,
+ ]);
+ $reported = true;
+ }
+ } catch (\Throwable) {
+ // Reporting must never break cleanup; fall through to the last resort.
+ }
+
+ if (!$reported) {
+ // No reporter or logger took it (absent, or it threw). Cleanup failures
+ // are never propagated, so leave a best-effort trace rather than none.
+ try {
+ error_log(sprintf(
+ 'Deferred callback failed: %s: %s in %s:%d',
+ get_class($failure),
+ $failure->getMessage(),
+ $failure->getFile(),
+ $failure->getLine()
+ ));
+ } catch (\Throwable) {
+ // Nothing left to try.
+ }
}
}
@@ -1074,6 +1408,13 @@ protected function cleanupRequestScopedServices(): void
foreach (['session', 'request', 'response', 'redirect'] as $abstract) {
$this->forgetResolved($abstract);
}
+
+ // re-resolves fresh instance instead of reusing it.
+ $this->forgetRequestScopedInstances();
+
+ // Undo any config() mutation made while handling
+ // this request, so it doesn't leak into the next one.
+ Config::resetRuntimeOverrides();
}
/**
@@ -1125,15 +1466,9 @@ protected function callTerminatingCallback(array $callback, Request $request, ?R
$resolved = true;
}
- if (!$resolved && $parameter->isDefaultValueAvailable()) {
- $arguments[] = $parameter->getDefaultValue();
- $resolved = true;
- }
-
if (!$resolved) {
- throw new \RuntimeException(
- "Unresolvable terminating callback parameter '\${$parameter->getName()}'."
- );
+ $primitives = $namedContext;
+ $arguments[] = $this->resolveDependency($parameter, $primitives);
}
}
@@ -1207,18 +1542,69 @@ public function handle(Request $request): Response
*/
public function dispatch($request): DispatchResult
{
+ $this->snapshotBootBindings();
+
+ $context = $this->beginContext(ExecutionContext::REQUEST, $this->requestContextMetadata($request));
+ $deferScope = $this->beginDeferScope();
+
try {
$this->instance('request', $request);
$response = $this->handle($request);
+ TaskGroup::assertNothingPending($context->id());
+
$response->prepare($request)->send();
- return new DispatchResult($this, $request, $response);
+ $this->suspendDeferScope($deferScope);
+ $this->suspendContext($context);
+
+ return new DispatchResult($this, $request, $response, null, $deferScope, $context);
} catch (HttpException $e) {
- Response::dispatchHttpException($e);
+ TaskGroup::cancelPending($e, $context->id());
+
+ try {
+ Response::dispatchHttpException($e);
+ } catch (\Throwable $renderFailure) {
+ $this->endDeferScope($deferScope, $renderFailure);
+ $this->endContext($context, $renderFailure);
+
+ throw $renderFailure;
+ }
- return new DispatchResult($this, $request, null, $e);
+ $this->suspendDeferScope($deferScope);
+ $this->suspendContext($context);
+
+ return new DispatchResult($this, $request, null, $e, $deferScope, $context);
+ } catch (\Throwable $e) {
+ TaskGroup::cancelPending($e, $context->id());
+ $this->endDeferScope($deferScope, $e);
+ $this->endContext($context, $e);
+
+ throw $e;
+ }
+ }
+
+ /**
+ * Describe a request for its execution context. Must never throw.
+ *
+ * @param mixed $request
+ * @return array
+ */
+ protected function requestContextMetadata($request): array
+ {
+ $metadata = [];
+
+ try {
+ $metadata = array_filter([
+ 'method' => $request->method(),
+ 'path' => $request->getPath(),
+ 'request_id' => $request->header('X-Request-Id'),
+ ], static fn($value) => $value !== null && $value !== '');
+ } catch (\Throwable) {
+ // Metadata is best-effort; the request must still be served.
}
+
+ return $metadata;
}
}
diff --git a/src/Phaseolies/ApplicationBuilder.php b/src/Phaseolies/ApplicationBuilder.php
index 0e0542b9..f42d602d 100644
--- a/src/Phaseolies/ApplicationBuilder.php
+++ b/src/Phaseolies/ApplicationBuilder.php
@@ -3,24 +3,13 @@
namespace Phaseolies;
use Phaseolies\Support\TimezoneHandler;
-use Phaseolies\Middleware\Contracts\Middleware as ContractsMiddleware;
class ApplicationBuilder
{
- /**
- * Holds the current HTTP request instance
- *
- * @var \Phaseolies\Http\Request
- */
- protected $request;
-
/**
* @param Application $app
*/
- public function __construct(protected Application $app)
- {
- $this->request = $this->app->make('request');
- }
+ public function __construct(protected Application $app) {}
/**
* Set the application timezone
@@ -39,66 +28,7 @@ public function withTimezone(): self
}
/**
- * Configures the application with middleware stack handling
- *
- * @return self
- * @throws \Exception
- */
- public function withMiddlewareStack(): self
- {
- $middlewareStack = $this->buildMiddlewareStack();
-
- $handler = $this->processMiddlewareStack($middlewareStack);
-
- $this->app->router->handle($this->request, $handler);
-
- return $this;
- }
-
- /**
- * Constructs the middleware stack based on request type
- *
- * @return array
- */
- protected function buildMiddlewareStack(): array
- {
- $middlewareStack = $this->app->router->middleware ?? [];
-
- $groupKey = $this->request->isApiRequest() ? 'api' : 'web';
- $groupMiddleware = $this->app->router->middlewareGroups[$groupKey] ?? [];
-
- return array_merge($middlewareStack, $groupMiddleware);
- }
-
- /**
- * Processes the middleware stack into a handler pipeline.
- *
- * @param array $middlewareStack
- * @return callable
- * @throws \Exception
- */
- protected function processMiddlewareStack(array $middlewareStack): callable
- {
- $response = fn() => $this->app->make('response');
-
- foreach ($middlewareStack as $middlewareClass) {
- $middlewareInstance = $this->app->make($middlewareClass);
- if (!$middlewareInstance instanceof ContractsMiddleware) {
- throw new \Exception(
- "Failed to register middleware {$middlewareClass}: it must implement " . ContractsMiddleware::class . "."
- );
- }
-
- $response = function ($request) use ($middlewareInstance, $response) {
- return $middlewareInstance($request, $response);
- };
- }
-
- return $response;
- }
-
- /**
- * Finalizes the builder process and returns the configured application.
+ * Finalizes the builder process and returns the configured application
*
* @return Application
*/
diff --git a/src/Phaseolies/Auth/Authable.php b/src/Phaseolies/Auth/Authable.php
new file mode 100644
index 00000000..decf20bb
--- /dev/null
+++ b/src/Phaseolies/Auth/Authable.php
@@ -0,0 +1,108 @@
+{$this->getKeyName()};
+ }
+
+ /**
+ * Get the password for the user.
+ *
+ * @return string|null
+ */
+ public function getAuthPassword(): ?string
+ {
+ return $this->password;
+ }
+
+ /**
+ * Get the remember token value.
+ *
+ * @return string|null
+ */
+ public function getRememberToken(): ?string
+ {
+ return $this->remember_token;
+ }
+
+ /**
+ * Set the remember token value.
+ *
+ * @param string|null $value
+ * @return void
+ */
+ public function setRememberToken(?string $value): void
+ {
+ $this->remember_token = $value;
+ }
+
+ /**
+ * Get the two-factor authentication secret.
+ *
+ * @return string|null
+ */
+ public function getTwoFactorSecret(): ?string
+ {
+ return $this->two_factor_secret;
+ }
+
+ /**
+ * Set the two-factor authentication secret.
+ *
+ * @param string|null $value
+ * @return void
+ */
+ public function setTwoFactorSecret(?string $value): void
+ {
+ $this->two_factor_secret = $value;
+ }
+
+ /**
+ * Get the two-factor recovery codes.
+ *
+ * @return string|null
+ */
+ public function getTwoFactorRecoveryCodes(): ?string
+ {
+ return $this->two_factor_recovery_codes;
+ }
+
+ /**
+ * Set the two-factor recovery codes.
+ *
+ * @param string|null $value
+ * @return void
+ */
+ public function setTwoFactorRecoveryCodes(?string $value): void
+ {
+ $this->two_factor_recovery_codes = $value;
+ }
+}
diff --git a/src/Phaseolies/Auth/Security/Authenticate.php b/src/Phaseolies/Auth/Security/Authenticate.php
index 07035501..4e413fd6 100644
--- a/src/Phaseolies/Auth/Security/Authenticate.php
+++ b/src/Phaseolies/Auth/Security/Authenticate.php
@@ -4,7 +4,7 @@
use Phaseolies\Support\Facades\Hash;
use Phaseolies\Support\Facades\Crypt;
-use Phaseolies\Database\Entity\Model;
+use Phaseolies\Auth\Authable;
class Authenticate
{
@@ -32,16 +32,16 @@ class Authenticate
/**
* The current stateless user (for onceUsingId).
*
- * @var Model|null
+ * @var Authable|null
*/
private $statelessUser = null;
/**
* Per-instance resolved user cache
*
- * @var Model|null
+ * @var Authable|null
*/
- private ?Model $resolvedUser = null;
+ private ?Authable $resolvedUser = null;
/**
* Create a new actor instance.
@@ -80,11 +80,20 @@ public function __get($name)
/**
* Resolve a fresh instance of the configured auth model.
*
- * @return Model
+ * @return Authable
+ * @throws \InvalidArgumentException
*/
- protected function getModel(): Model
+ protected function getModel(): Authable
{
- return app($this->config['model']);
+ $model = app($this->config['model']);
+
+ if (!$model instanceof Authable) {
+ throw new \InvalidArgumentException(
+ 'Auth model must extend ' . Authable::class . ', ' . get_debug_type($model) . ' given'
+ );
+ }
+
+ return $model;
}
/**
@@ -145,7 +154,7 @@ public function try(array $credentials = [], bool $remember = false): bool
$user = $authModel::where($customAuthKey, $authKeyValue)->first();
- if (!$user || !Hash::check($password, $user->password)) {
+ if (!$user || !Hash::check($password, $user->getAuthPassword())) {
return false;
}
@@ -157,24 +166,24 @@ public function try(array $credentials = [], bool $remember = false): bool
/**
* Log in a user instance.
*
- * @param Model $user
+ * @param Authable $user
* @param bool $remember
* @return bool
* @throws \InvalidArgumentException
*/
- public function login($user, bool $remember = false): bool
+ public function login(Authable $user, bool $remember = false): bool
{
$authModel = $this->getModel();
$modelClass = $authModel::class;
if (!$user instanceof $modelClass) {
throw new \InvalidArgumentException(
- "Argument #1 ($user) must be an instance of $modelClass " . gettype($user) . ' given'
+ 'Argument #1 ($user) must be an instance of ' . $modelClass . ', ' . get_debug_type($user) . ' given'
);
}
if ($this->hasTwoFactorEnabled($user) && !$this->isApiRequest()) {
- session()->put($this->getTwoFactorUserKey(), $user->id);
+ session()->put($this->getTwoFactorUserKey(), $user->getAuthIdentifier());
session()->put($this->getTwoFactorRememberKey(), $remember);
return true;
@@ -194,9 +203,9 @@ public function login($user, bool $remember = false): bool
*
* @param int $id
* @param bool $remember
- * @return Model|null
+ * @return Authable|null
*/
- public function loginUsingId(int $id, bool $remember = false): ?Model
+ public function loginUsingId(int $id, bool $remember = false): ?Authable
{
$authModel = $this->getModel();
@@ -213,9 +222,9 @@ public function loginUsingId(int $id, bool $remember = false): ?Model
* Log in a user by their ID for a single request (no session/cookie).
*
* @param int $id
- * @return Model|null
+ * @return Authable|null
*/
- public function onceUsingId(int $id): ?Model
+ public function onceUsingId(int $id): ?Authable
{
$authModel = $this->getModel();
@@ -233,9 +242,9 @@ public function onceUsingId(int $id): ?Model
/**
* Get the currently authenticated user.
*
- * @return Model|null
+ * @return Authable|null
*/
- public function user(): ?Model
+ public function user(): ?Authable
{
if ($this->resolvedUser !== null) {
return $this->resolvedUser;
@@ -299,14 +308,14 @@ public function user(): ?Model
$user = $authModel::find($id);
- if (!$user || !$user->remember_token) {
+ if (!$user || !$user->getRememberToken()) {
$this->expireRememberCookie();
return null;
}
- if (Hash::check($token, $user->remember_token)) {
+ if (Hash::check($token, $user->getRememberToken())) {
if ($this->hasTwoFactorEnabled($user)) {
- session()->put($this->getTwoFactorUserKey(), $user->id);
+ session()->put($this->getTwoFactorUserKey(), $user->getAuthIdentifier());
session()->put($this->getTwoFactorRememberKey(), true);
}
@@ -317,7 +326,7 @@ public function user(): ?Model
// Token didn't match - possible theft attempt
$this->expireRememberCookie();
- $user->remember_token = null;
+ $user->setRememberToken(null);
$user->save();
}
@@ -343,8 +352,8 @@ public function logout(): void
{
$user = $this->user();
- if ($user && $user?->remember_token) {
- $user->remember_token = null;
+ if ($user && $user->getRememberToken()) {
+ $user->setRememberToken(null);
$user->withoutHook();
$user->save();
}
@@ -370,11 +379,13 @@ public function logout(): void
/**
* Set the authenticated user in the session.
*
- * @param Model $user
+ * @param Authable $user
*/
- private function setUser(Model $user): void
+ private function setUser(Authable $user): void
{
- session()->put($this->getSessionKey(), $user->id);
+ session()->regenerate();
+
+ session()->put($this->getSessionKey(), $user->getAuthIdentifier());
$this->resolvedUser = $user;
}
@@ -393,11 +404,11 @@ public function viaRemember(): bool
/**
* Get the authenticated user id
*
- * @return int|null
+ * @return int|string|null
*/
- public function id(): ?int
+ public function id(): int|string|null
{
- return $this->user()?->id ?? null;
+ return $this->user()?->getAuthIdentifier();
}
/**
diff --git a/src/Phaseolies/Auth/Security/Contracts/HashingInterface.php b/src/Phaseolies/Auth/Security/Contracts/HashingInterface.php
new file mode 100644
index 00000000..a5b9a139
--- /dev/null
+++ b/src/Phaseolies/Auth/Security/Contracts/HashingInterface.php
@@ -0,0 +1,38 @@
+remember_token = Hash::make($token);
+ $user->setRememberToken(Hash::make($token));
$user->save();
- $cookieValue = $user->id . '|' . $token . '|' . Hash::make($user->id . $token);
+ $id = $user->getAuthIdentifier();
+ $cookieValue = $id . '|' . $token . '|' . Hash::make($id . $token);
session()->put($this->getViaRememberKey(), true);
diff --git a/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php b/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php
index 6c5993f8..1e937442 100644
--- a/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php
+++ b/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php
@@ -4,10 +4,12 @@
use chillerlan\QRCode\QROptions;
use chillerlan\QRCode\QRCode;
+use chillerlan\QRCode\Output\QRMarkupSVG;
+use chillerlan\QRCode\Common\EccLevel;
use Symfony\Component\Clock\NativeClock;
use Psr\Clock\ClockInterface;
use Phaseolies\Support\Facades\Crypt;
-use Phaseolies\Database\Entity\Model;
+use Phaseolies\Auth\Authable;
use ParagonIE\ConstantTime\Base32;
use OTPHP\TOTP;
@@ -32,7 +34,7 @@ public function enableTwoFactorAuth(): array
{
$user = $this->user();
- if (!is_null($user->two_factor_secret)) {
+ if (!is_null($user->getTwoFactorSecret())) {
throw new \Exception("2FA Already enabled");
}
@@ -55,8 +57,8 @@ public function enableTwoFactorAuth(): array
$recoveryCodes = $this->generateRecoveryCodes();
- $user->two_factor_secret = Crypt::encrypt($secret);
- $user->two_factor_recovery_codes = Crypt::encrypt(json_encode($recoveryCodes));
+ $user->setTwoFactorSecret(Crypt::encrypt($secret));
+ $user->setTwoFactorRecoveryCodes(Crypt::encrypt(json_encode($recoveryCodes)));
$user->save();
return [
@@ -75,8 +77,8 @@ public function disableTwoFactorAuth(): bool
{
$user = $this->user();
- $user->two_factor_secret = null;
- $user->two_factor_recovery_codes = null;
+ $user->setTwoFactorSecret(null);
+ $user->setTwoFactorRecoveryCodes(null);
return $user->save();
}
@@ -107,12 +109,12 @@ public function verifyTwoFactorCode(string $code): bool
$authModel = $this->getModel();
$user = $authModel::find(session($this->getTwoFactorUserKey()));
- if (is_null($user->two_factor_secret)) {
+ if (is_null($user->getTwoFactorSecret())) {
return false;
}
try {
- $secret = Crypt::decrypt($user->two_factor_secret);
+ $secret = Crypt::decrypt($user->getTwoFactorSecret());
$totp = TOTP::create(
$secret,
@@ -132,26 +134,26 @@ public function verifyTwoFactorCode(string $code): bool
/**
* Verify a recovery code
*
- * @param Model $user
+ * @param Authable $user
* @param string $code
* @return bool
*/
- public function verifyRecoveryCode(Model $user, string $code): bool
+ public function verifyRecoveryCode(Authable $user, string $code): bool
{
- if (is_null($user->two_factor_recovery_codes)) {
+ if (is_null($user->getTwoFactorRecoveryCodes())) {
return false;
}
- $codes = Crypt::decrypt($user->two_factor_recovery_codes);
+ $codes = Crypt::decrypt($user->getTwoFactorRecoveryCodes());
foreach ($codes as $key => $recoveryCode) {
if (strtoupper(trim($code)) === $recoveryCode) {
unset($codes[$key]);
if (!empty($codes)) {
- $user->two_factor_recovery_codes = Crypt::encrypt(json_encode($codes));
+ $user->setTwoFactorRecoveryCodes(Crypt::encrypt(json_encode($codes)));
} else {
- $user->two_factor_recovery_codes = null;
+ $user->setTwoFactorRecoveryCodes(null);
}
$user->save();
@@ -172,7 +174,7 @@ public function generateNewRecoveryCodes(): array
$recoveryCodes = $this->generateRecoveryCodes();
$user = $this->user();
- $user->two_factor_recovery_codes = Crypt::encrypt(json_encode($recoveryCodes));
+ $user->setTwoFactorRecoveryCodes(Crypt::encrypt(json_encode($recoveryCodes)));
$user->save();
return $recoveryCodes;
@@ -181,12 +183,12 @@ public function generateNewRecoveryCodes(): array
/**
* Check if user has 2FA enabled
*
- * @param Model $user
+ * @param Authable $user
* @return bool
*/
- public function hasTwoFactorEnabled(Model $user): bool
+ public function hasTwoFactorEnabled(Authable $user): bool
{
- return !is_null($user->two_factor_secret);
+ return !is_null($user->getTwoFactorSecret());
}
/**
@@ -224,10 +226,10 @@ public function completeTwoFactorLogin(): bool
public function generateTwoFactorQrCode(string $qrCodeUrl): string
{
$options = new QROptions([
- 'version' => 10,
- 'outputType' => QRCode::OUTPUT_MARKUP_SVG,
- 'eccLevel' => QRCode::ECC_M,
- 'addQuietzone' => true,
+ 'version' => 10,
+ 'outputInterface' => QRMarkupSVG::class,
+ 'eccLevel' => EccLevel::M,
+ 'addQuietzone' => true,
]);
return (new QRCode($options))->render($qrCodeUrl);
diff --git a/src/Phaseolies/Auth/Security/PasswordHashing.php b/src/Phaseolies/Auth/Security/PasswordHashing.php
index 22cc72f8..378beae0 100644
--- a/src/Phaseolies/Auth/Security/PasswordHashing.php
+++ b/src/Phaseolies/Auth/Security/PasswordHashing.php
@@ -2,7 +2,9 @@
namespace Phaseolies\Auth\Security;
-class PasswordHashing
+use Phaseolies\Auth\Security\Contracts\HashingInterface;
+
+class PasswordHashing implements HashingInterface
{
/**
* Hash a password using the configured algorithm and options.
@@ -11,7 +13,7 @@ class PasswordHashing
* @return string
* @throws \RuntimeException
*/
- public function make($password)
+ public function make(string $password): string
{
$driver = config('hashing.driver', 'bcrypt');
@@ -40,29 +42,33 @@ public function make($password)
/**
* Verify if a given password matches the stored hash.
*
- * @param string $password
- * @param string $hashedPassword
+ * @param ?string $password
+ * @param ?string $hashedPassword
* @return bool
*/
- public function check($password, $hashedPassword)
+ public function check(?string $password, ?string $hashedPassword): bool
{
+ if ($password === null || $hashedPassword === null) {
+ return false;
+ }
+
return password_verify($password, $hashedPassword);
}
/**
* Check if a password hash needs rehashing (security upgrade).
*
- * @param string $hashedPassword
+ * @param ?string $hashedPassword
* @return bool
*/
- public function needsRehash($hashedPassword)
+ public function needsRehash(?string $hashedPassword): bool
{
$driver = config('hashing.driver', 'bcrypt');
switch ($driver) {
case 'bcrypt':
$rounds = config('hashing.bcrypt.rounds', 10);
- return password_needs_rehash($hashedPassword, PASSWORD_BCRYPT, ['cost' => $rounds]);
+ return $hashedPassword === null || password_needs_rehash($hashedPassword, PASSWORD_BCRYPT, ['cost' => $rounds]);
case 'argon':
case 'argon2id':
@@ -71,7 +77,7 @@ public function needsRehash($hashedPassword)
'time_cost' => config('hashing.argon.time', PASSWORD_ARGON2_DEFAULT_TIME_COST),
'threads' => config('hashing.argon.threads', PASSWORD_ARGON2_DEFAULT_THREADS),
];
- return password_needs_rehash($hashedPassword, PASSWORD_ARGON2ID, $options);
+ return $hashedPassword === null || password_needs_rehash($hashedPassword, PASSWORD_ARGON2ID, $options);
default:
throw new \RuntimeException("Unsupported hashing driver: {$driver}");
diff --git a/src/Phaseolies/Cache/CacheStore.php b/src/Phaseolies/Cache/CacheStore.php
index 3e3d0e8a..be620428 100644
--- a/src/Phaseolies/Cache/CacheStore.php
+++ b/src/Phaseolies/Cache/CacheStore.php
@@ -2,10 +2,14 @@
namespace Phaseolies\Cache;
-use DateTime;
use Symfony\Component\Cache\Adapter\AdapterInterface;
+use Symfony\Component\Cache\Adapter\TagAwareAdapter;
+use Symfony\Component\Cache\CacheItem;
use Phaseolies\Cache\Lock\AtomicLock;
+use Psr\Cache\CacheItemInterface;
use Symfony\Contracts\Cache\CacheInterface as ContractsCacheInterface;
+use Symfony\Contracts\Cache\ItemInterface;
+use Symfony\Contracts\Cache\NamespacedPoolInterface;
class CacheStore implements IncrementableCacheInterface
{
@@ -23,17 +27,47 @@ class CacheStore implements IncrementableCacheInterface
*/
protected string $prefix;
+ /**
+ * Seconds an item lives when no ttl is given
+ *
+ * @var int|null
+ */
+ protected ?int $defaultTtl;
+
+ /**
+ * Resolves another configured store by name, see store().
+ *
+ * @var \Closure|null
+ */
+ protected ?\Closure $storeResolver = null;
+
+ /**
+ * The name this store was configured under, if known.
+ *
+ * @var string|null
+ */
+ protected ?string $name = null;
+
+ /**
+ * Pool that holds tagged items, created on first use.
+ *
+ * @var TagAwareAdapter|null
+ */
+ protected ?TagAwareAdapter $taggedPool = null;
+
/**
* Create a new cache store instance.
*
* @param AdapterInterface $adapter
* @param string|null $prefix
+ * @param int|null $defaultTtl
* @return void
*/
- public function __construct(AdapterInterface $adapter, ?string $prefix = null)
+ public function __construct(AdapterInterface $adapter, ?string $prefix = null, ?int $defaultTtl = null)
{
$this->adapter = $adapter;
$this->prefix = (string) ($prefix ?? config('caching.prefix'));
+ $this->defaultTtl = $defaultTtl;
}
/**
@@ -92,8 +126,10 @@ public function set($key, $value, $ttl = null): bool
$item = $this->adapter->getItem($key);
$item->set($value);
- if ($ttl !== null) {
- $item->expiresAfter($this->convertTtlToSeconds($ttl));
+ $seconds = $this->ttlFor($ttl);
+
+ if ($seconds !== null) {
+ $item->expiresAfter($seconds);
}
return $this->adapter->save($item);
@@ -121,7 +157,14 @@ public function delete($key): bool
#[\Override]
public function clear(): bool
{
- return $this->adapter->clear($this->prefix);
+ $cleared = $this->adapter->clear($this->prefix);
+
+ // Tagged items live in their own namespace and are not reached by the call above.
+ if ($pool = $this->taggedPool()) {
+ $cleared = $pool->clear() && $cleared;
+ }
+
+ return $cleared;
}
/**
@@ -169,7 +212,7 @@ public function setMultiple($values, $ttl = null): bool
$values = $this->normalizeValueMap($values);
$success = true;
- $ttl = $this->convertTtlToSeconds($ttl);
+ $ttl = $this->ttlFor($ttl);
foreach ($values as $key => $value) {
$prefixedKey = $this->prefixedValidatedKey($key);
@@ -237,13 +280,9 @@ public function increment($key, $value = 1): int|bool
return false;
}
- $current = (int) $item->get();
- $new = $current + $value;
+ $new = (int) $item->get() + $value;
$item->set($new);
-
- if ($item->getMetadata()['expiry'] ?? null) {
- $item->expiresAt(DateTime::createFromFormat('U', (string) $item->getMetadata()['expiry']));
- }
+ $this->keepExpiry($item);
return $this->adapter->save($item) ? $new : false;
});
@@ -267,13 +306,9 @@ public function decrement($key, $value = 1): int|bool
return false;
}
- $current = (int) $item->get();
- $new = $current - $value;
+ $new = (int) $item->get() - $value;
$item->set($new);
-
- if ($item->getMetadata()['expiry'] ?? null) {
- $item->expiresAt(DateTime::createFromFormat('U', (string) $item->getMetadata()['expiry']));
- }
+ $this->keepExpiry($item);
return $this->adapter->save($item) ? $new : false;
});
@@ -290,7 +325,7 @@ public function decrement($key, $value = 1): int|bool
public function add($key, $value, $ttl = null): bool
{
$key = $this->prefixedValidatedKey($key);
- $seconds = $this->convertTtlToSeconds($ttl);
+ $seconds = $this->ttlFor($ttl);
if ($this->adapter instanceof ContractsCacheInterface) {
$created = false;
@@ -404,6 +439,43 @@ protected function validateKeys($keys): array
return $validated;
}
+ /**
+ * The ttl an item is saved with: the given one, or the store default.
+ *
+ * @param null|int|\DateInterval $ttl
+ * @return int|null
+ */
+ protected function ttlFor($ttl): ?int
+ {
+ return $this->convertTtlToSeconds($ttl) ?? $this->defaultTtl;
+ }
+
+ /**
+ * The ttl an item is saved with, in seconds: the given one, or the store default.
+ *
+ * @param null|int|\DateInterval $ttl
+ * @return int|null
+ */
+ public function ttlSeconds($ttl): ?int
+ {
+ return $this->ttlFor($ttl);
+ }
+
+ /**
+ * Save an item with the expiry it already had.
+ *
+ * @param CacheItemInterface $item
+ * @return void
+ */
+ protected function keepExpiry(CacheItemInterface $item): void
+ {
+ $expiry = $item instanceof CacheItem ? ($item->getMetadata()[ItemInterface::METADATA_EXPIRY] ?? null) : null;
+
+ if ($expiry !== null) {
+ $item->expiresAt((new \DateTimeImmutable())->setTimestamp((int) ceil($expiry)));
+ }
+ }
+
/**
* Convert TTL to seconds.
*
@@ -550,21 +622,13 @@ public function getAdapter(): AdapterInterface
* Get an item from the cache, or execute the callback and store the result.
*
* @param string $key
- * @param int|DateInterval $ttl
+ * @param int|\DateInterval|null $ttl
* @param \Closure $callback
* @return mixed
*/
public function stash(string $key, $ttl, \Closure $callback): mixed
{
- if ($this->has($key)) {
- return $this->get($key);
- }
-
- $value = $callback();
-
- $this->set($key, $value, $ttl);
-
- return $value;
+ return $this->remember($key, $this->ttlFor($ttl), $callback);
}
/**
@@ -576,15 +640,7 @@ public function stash(string $key, $ttl, \Closure $callback): mixed
*/
public function stashForever(string $key, \Closure $callback): mixed
{
- if ($this->has($key)) {
- return $this->get($key);
- }
-
- $value = $callback();
-
- $this->forever($key, $value);
-
- return $value;
+ return $this->remember($key, null, $callback);
}
/**
@@ -607,6 +663,195 @@ public function stashWhen(string $key, \Closure $callback, bool $condition, $ttl
: $this->stash($key, $ttl, $callback);
}
+ /**
+ * @param string $key
+ * @param int|null $seconds
+ * @param \Closure $callback
+ * @return mixed
+ */
+ protected function remember(string $key, ?int $seconds, \Closure $callback): mixed
+ {
+ $key = $this->prefixedValidatedKey($key);
+
+ if ($this->adapter instanceof ContractsCacheInterface) {
+ return $this->adapter->get($key, function (ItemInterface $item) use ($seconds, $callback) {
+ $item->expiresAfter($seconds);
+
+ return $callback();
+ });
+ }
+
+ $item = $this->adapter->getItem($key);
+
+ if ($item->isHit()) {
+ return $item->get();
+ }
+
+ $value = $callback();
+ $item->set($value);
+
+ if ($seconds !== null) {
+ $item->expiresAfter($seconds);
+ }
+
+ $this->adapter->save($item);
+
+ return $value;
+ }
+
+ /**
+ * Get an item and remove it in one step.
+ *
+ * @param mixed $key
+ * @param mixed $default
+ * @return mixed
+ */
+ public function pull($key, $default = null): mixed
+ {
+ $key = $this->prefixedValidatedKey($key);
+ $item = $this->adapter->getItem($key);
+
+ if (!$item->isHit()) {
+ return $default;
+ }
+
+ $value = $item->get();
+ $this->adapter->deleteItem($key);
+
+ return $value;
+ }
+
+ /**
+ * Determine whether an item is absent from the cache.
+ *
+ * @param mixed $key
+ * @return bool
+ */
+ public function missing($key): bool
+ {
+ return !$this->has($key);
+ }
+
+ /**
+ * Use another store from the `caching.stores` config
+ *
+ * @param string|null $name
+ * @return self
+ * @throws \RuntimeException When the store is not configured
+ */
+ public function store(?string $name = null): self
+ {
+ if ($name === null || $name === $this->name) {
+ return $this;
+ }
+
+ if ($this->storeResolver === null) {
+ throw new \RuntimeException("Cache store [{$name}] cannot be resolved: this store was not created from the cache config.");
+ }
+
+ return ($this->storeResolver)($name);
+ }
+
+ /**
+ * The key prefix this store was created with.
+ *
+ * @return string
+ */
+ public function getPrefix(): string
+ {
+ return $this->prefix;
+ }
+
+ /**
+ * Take over the settings of another store
+ *
+ * @param CacheStore $store
+ * @return static
+ */
+ public function inheritSettingsFrom(CacheStore $store): static
+ {
+ $this->defaultTtl = $store->defaultTtl;
+ $this->name = $store->name;
+ $this->storeResolver = $store->storeResolver;
+
+ return $this;
+ }
+
+ /**
+ * Teach this store how to find the others. Used by the cache launcher.
+ *
+ * @param string $name
+ * @param \Closure $resolver fn(string $name): static
+ * @return static
+ */
+ public function resolveStoresUsing(string $name, \Closure $resolver): static
+ {
+ $this->name = $name;
+ $this->storeResolver = $resolver;
+
+ return $this;
+ }
+
+ /**
+ * Get a cache whose items carry tags, so a whole group can be removed at once
+ *
+ * @param string|array $names
+ * @return TaggedCache
+ * @throws \RuntimeException
+ */
+ public function tags(string|array $names): TaggedCache
+ {
+ $pool = $this->taggedPool();
+
+ if ($pool === null) {
+ throw new \RuntimeException('This cache store does not support tags.');
+ }
+
+ return new TaggedCache($this, $pool, array_values((array) $names));
+ }
+
+ /**
+ * Get the tagged pool, creating it if necessary.
+ *
+ * @return TagAwareAdapter|null
+ */
+ protected function taggedPool(): ?TagAwareAdapter
+ {
+ if ($this->taggedPool === null && ($pool = $this->taggedNamespaceOf($this->adapter)) !== null) {
+ $this->taggedPool = new TagAwareAdapter($pool);
+ }
+
+ return $this->taggedPool;
+ }
+
+ /**
+ * The separate namespace tagged items are kept in, if the adapter can have one.
+ *
+ * @param object $adapter
+ * @return AdapterInterface|null
+ */
+ private function taggedNamespaceOf(object $adapter): ?AdapterInterface
+ {
+ if (!$adapter instanceof NamespacedPoolInterface) {
+ return null;
+ }
+
+ $pool = $adapter->withSubNamespace('tagged');
+
+ return $pool instanceof AdapterInterface ? $pool : null;
+ }
+
+ /**
+ * Validate a key and return it as the adapter will see it.
+ *
+ * @param mixed $key
+ * @return string
+ */
+ public function validatedKey($key): string
+ {
+ return $this->normalizeKey($key);
+ }
+
/**
* Get a lock instance.
*
diff --git a/src/Phaseolies/Cache/RateLimiter.php b/src/Phaseolies/Cache/RateLimiter.php
index 5bddf9cb..eee3efb4 100644
--- a/src/Phaseolies/Cache/RateLimiter.php
+++ b/src/Phaseolies/Cache/RateLimiter.php
@@ -51,8 +51,6 @@ public function attempt(string $key, int $maxAttempts, int $decaySeconds): RateL
$this->cache->set($key, 1, $decaySeconds);
$this->cache->set($timerKey, $resetAt, $decaySeconds);
$hits = 1;
- } elseif ($hits <= $maxAttempts) {
- $this->cache->set($timerKey, $resetAt, $decaySeconds);
}
}
@@ -147,8 +145,6 @@ public function hit(string $key, int $decaySeconds): int
return 1;
}
- $this->cache->set($timerKey, $resetAt, $decaySeconds);
-
return $hits;
} catch (InvalidArgumentException $e) {
throw $e;
diff --git a/src/Phaseolies/Cache/TaggedCache.php b/src/Phaseolies/Cache/TaggedCache.php
new file mode 100644
index 00000000..cae82660
--- /dev/null
+++ b/src/Phaseolies/Cache/TaggedCache.php
@@ -0,0 +1,193 @@
+ $tags
+ */
+ public function __construct(
+ protected CacheStore $store,
+ protected TagAwareAdapter $pool,
+ protected array $tags
+ ) {
+ if ($this->tags === []) {
+ throw new \InvalidArgumentException('At least one cache tag is required.');
+ }
+ }
+
+ /**
+ * Get a tagged item.
+ *
+ * @param mixed $key
+ * @param mixed $default
+ * @return mixed
+ */
+ public function get($key, $default = null): mixed
+ {
+ $item = $this->pool->getItem($this->store->validatedKey($key));
+
+ return $item->isHit() ? $item->get() : $default;
+ }
+
+ /**
+ * Store an item with this cache's tags.
+ *
+ * @param mixed $key
+ * @param mixed $value
+ * @param null|int|\DateInterval $ttl
+ * @return bool
+ */
+ public function set($key, $value, $ttl = null): bool
+ {
+ $item = $this->pool->getItem($this->store->validatedKey($key));
+ $item->set($value)->tag($this->tags);
+
+ $seconds = $this->store->ttlSeconds($ttl);
+
+ if ($seconds !== null) {
+ $item->expiresAfter($seconds);
+ }
+
+ return $this->pool->save($item);
+ }
+
+ /**
+ * Store an item with this cache's tags until it is flushed.
+ *
+ * @param mixed $key
+ * @param mixed $value
+ * @return bool
+ */
+ public function forever($key, $value): bool
+ {
+ $item = $this->pool->getItem($this->store->validatedKey($key));
+ $item->set($value)->tag($this->tags)->expiresAfter(null);
+
+ return $this->pool->save($item);
+ }
+
+ /**
+ * Determine whether a tagged item exists.
+ *
+ * @param mixed $key
+ * @return bool
+ */
+ public function has($key): bool
+ {
+ return $this->pool->hasItem($this->store->validatedKey($key));
+ }
+
+ /**
+ * Remove one tagged item.
+ *
+ * @param mixed $key
+ * @return bool
+ */
+ public function delete($key): bool
+ {
+ return $this->pool->deleteItem($this->store->validatedKey($key));
+ }
+
+ /**
+ * Remove one tagged item.
+ *
+ * @param mixed $key
+ * @return bool
+ */
+ public function forget($key): bool
+ {
+ return $this->delete($key);
+ }
+
+ /**
+ * Get a tagged item and remove it in one step.
+ *
+ * @param mixed $key
+ * @param mixed $default
+ * @return mixed
+ */
+ public function pull($key, $default = null): mixed
+ {
+ $key = $this->store->validatedKey($key);
+ $item = $this->pool->getItem($key);
+
+ if (!$item->isHit()) {
+ return $default;
+ }
+
+ $value = $item->get();
+ $this->pool->deleteItem($key);
+
+ return $value;
+ }
+
+ /**
+ * Get a tagged item, or run the callback and store its result with these tags.
+ *
+ * @param string $key
+ * @param int|\DateInterval|null $ttl
+ * @param \Closure $callback
+ * @return mixed
+ */
+ public function stash(string $key, $ttl, \Closure $callback): mixed
+ {
+ return $this->remember($key, $this->store->ttlSeconds($ttl), $callback);
+ }
+
+ /**
+ * Get a tagged item, or run the callback and store its result until it is flushed.
+ *
+ * @param string $key
+ * @param \Closure $callback
+ * @return mixed
+ */
+ public function stashForever(string $key, \Closure $callback): mixed
+ {
+ return $this->remember($key, null, $callback);
+ }
+
+ /**
+ * Remove every item that has any of this cache's tags.
+ *
+ * @return bool
+ */
+ public function flush(): bool
+ {
+ return $this->pool->invalidateTags($this->tags);
+ }
+
+ /**
+ * The tags this cache writes with.
+ *
+ * @return array
+ */
+ public function getTags(): array
+ {
+ return $this->tags;
+ }
+
+ /**
+ * @param string $key
+ * @param int|null $seconds
+ * @param \Closure $callback
+ * @return mixed
+ */
+ protected function remember(string $key, ?int $seconds, \Closure $callback): mixed
+ {
+ return $this->pool->get(
+ $this->store->validatedKey($key),
+ function (ItemInterface $item) use ($seconds, $callback) {
+ $item->tag($this->tags)->expiresAfter($seconds);
+
+ return $callback();
+ }
+ );
+ }
+}
diff --git a/src/Phaseolies/Config/Config.php b/src/Phaseolies/Config/Config.php
index 2749752f..35e72c45 100644
--- a/src/Phaseolies/Config/Config.php
+++ b/src/Phaseolies/Config/Config.php
@@ -48,6 +48,13 @@ final class Config
*/
protected static ?array $configFiles = null;
+ /**
+ * A snapshot of `$config` exactly as it stood right after boot
+ *
+ * @var array
+ */
+ protected static array $bootSnapshot = [];
+
/**
* Initialize the configuration system.
*
@@ -58,9 +65,20 @@ public static function initialize(): void
if (self::$cacheFile === null) {
self::$cacheFile = storage_path('framework/cache/config.php');
self::loadFromCache();
+ self::$bootSnapshot = self::$config;
}
}
+ /**
+ * Undo any runtime mutation made by `set()` since boot
+ *
+ * @return void
+ */
+ public static function resetRuntimeOverrides(): void
+ {
+ self::$config = self::$bootSnapshot;
+ }
+
/**
* Get all config file paths, cached for the request lifetime.
*
@@ -68,7 +86,7 @@ public static function initialize(): void
*/
protected static function getConfigFiles(): array
{
- return self::$configFiles ??= glob(base_path('config/*.php')) ?: [];
+ return self::$configFiles ??= glob(base_path('runtime/config/*.php')) ?: [];
}
/**
@@ -87,7 +105,7 @@ protected static function getCacheKey(): string
$hashes[] = md5_file($file) . '|' . filemtime($file);
}
- $envFile = base_path('.env');
+ $envFile = base_path('env.toml');
if (file_exists($envFile)) {
$hashes[] = md5_file($envFile) . '|' . filemtime($envFile);
}
@@ -243,9 +261,6 @@ public static function set(string $key, mixed $value): void
} else {
$current = $value;
}
-
- self::$configModified = true;
- self::cacheConfig();
}
/**
@@ -278,7 +293,9 @@ public static function has(string $key): bool
*/
public static function clearCache(): void
{
- if (file_exists(self::$cacheFile)) @unlink(self::$cacheFile);
+ if (self::$cacheFile !== null && file_exists(self::$cacheFile)) {
+ @unlink(self::$cacheFile);
+ }
self::$config = [];
self::$fileHashes = [];
diff --git a/src/Phaseolies/Console/Command.php b/src/Phaseolies/Console/Command.php
index 5c8563a1..0445e496 100644
--- a/src/Phaseolies/Console/Command.php
+++ b/src/Phaseolies/Console/Command.php
@@ -2,6 +2,7 @@
namespace Phaseolies\Console;
+use Symfony\Component\Console\Command\Command as SymfonyCommand;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
use Phaseolies\Application;
@@ -52,7 +53,7 @@ public function registerCommands(Console $console): void
return 'Phaseolies\\Console\\Commands' . $relativePath;
}, $commandFiles);
- $userDefineCommandsDir = base_path('app/Schedule/Commands');
+ $userDefineCommandsDir = base_path('src/Schedule/Commands');
if (is_dir($userDefineCommandsDir)) {
$files = [];
$dirIterator = new RecursiveDirectoryIterator($userDefineCommandsDir);
@@ -74,9 +75,47 @@ public function registerCommands(Console $console): void
$commands = [];
foreach ($commandClasses as $command) {
- $commands[] = $this->app->make($command);
+ // One broken command must not take every other `pool` command down with it.
+ try {
+ if (!$this->isCommandClass($command)) {
+ continue;
+ }
+
+ $commands[] = $this->app->make($command);
+ } catch (\Throwable $e) {
+ $this->reportSkippedCommand($command, $e);
+ }
}
$console->addCommands($commands);
}
+
+ /**
+ * Tell the user a command could not be loaded.
+ *
+ * @param string $class
+ * @param \Throwable $e
+ * @return void
+ */
+ protected function reportSkippedCommand(string $class, \Throwable $e): void
+ {
+ fwrite(STDERR, sprintf("Skipped command [%s]: %s\n", $class, $e->getMessage()));
+ }
+
+ /**
+ * Whether a discovered class is a concrete console command. Helpers, traits,
+ * interfaces and abstract base classes that live next to commands are ignored.
+ *
+ * @param string $class
+ * @return bool
+ */
+ protected function isCommandClass(string $class): bool
+ {
+ if (!class_exists($class)) {
+ return false;
+ }
+
+ return is_subclass_of($class, SymfonyCommand::class)
+ && !(new \ReflectionClass($class))->isAbstract();
+ }
}
diff --git a/src/Phaseolies/Console/Commands/ClearCacheCommand.php b/src/Phaseolies/Console/Commands/ClearCacheCommand.php
index 9754e3d8..100249ad 100644
--- a/src/Phaseolies/Console/Commands/ClearCacheCommand.php
+++ b/src/Phaseolies/Console/Commands/ClearCacheCommand.php
@@ -12,7 +12,7 @@ class ClearCacheCommand extends Command
*
* @var string
*/
- protected $name = 'cache:clear';
+ protected $name = 'cache:clear {--store= : Clear only this store from the caching config, leaving the framework cache files alone}';
/**
* The command description shown in the Pool command list.
@@ -28,6 +28,16 @@ class ClearCacheCommand extends Command
*/
public function handle(): int
{
+ $store = $this->option('store');
+
+ if ($store) {
+ return $this->withTiming(function () use ($store) {
+ Cache::store($store)->clear();
+
+ return Command::SUCCESS;
+ }, "Cache store [{$store}] has been cleared.");
+ }
+
return $this->withTiming(function() {
$cacheDir = base_path('storage/framework/cache');
$this->deleteDirectoryContents($cacheDir);
diff --git a/src/Phaseolies/Console/Commands/Cron/CronDaemonCommand.php b/src/Phaseolies/Console/Commands/Cron/CronDaemonCommand.php
index d2656d7f..ba8913c2 100644
--- a/src/Phaseolies/Console/Commands/Cron/CronDaemonCommand.php
+++ b/src/Phaseolies/Console/Commands/Cron/CronDaemonCommand.php
@@ -3,6 +3,7 @@
namespace Phaseolies\Console\Commands\Cron;
use Phaseolies\Console\Schedule\Command;
+use Phaseolies\Console\Schedule\SchedulePool;
class CronDaemonCommand extends Command
{
@@ -267,17 +268,7 @@ protected function isDaemonRunning(): bool
*/
protected function isProcessRunning(int $pid): bool
{
- if ($pid <= 0) {
- return false;
- }
-
- if (function_exists('posix_kill')) {
- return posix_kill($pid, 0);
- }
-
- // Fallback
- $output = shell_exec(sprintf("ps -p %d -o pid=", $pid));
- return !empty(trim($output));
+ return SchedulePool::isProcessRunning($pid);
}
/**
diff --git a/src/Phaseolies/Console/Commands/Cron/CronFinishCommand.php b/src/Phaseolies/Console/Commands/Cron/CronFinishCommand.php
index 9cccbb00..4ed561fb 100644
--- a/src/Phaseolies/Console/Commands/Cron/CronFinishCommand.php
+++ b/src/Phaseolies/Console/Commands/Cron/CronFinishCommand.php
@@ -32,21 +32,30 @@ public function handle(): int
$exitCode = (int)$this->argument('exit_code');
// Find and clean up the process
- $pidFiles = glob(sys_get_temp_dir() . "/doppar_cron_lock_*.pid");
+ $directories = array_unique(array_filter([
+ $this->scheduleDirectory(),
+ sys_get_temp_dir(),
+ ]));
- foreach ($pidFiles as $pidFile) {
- $processInfo = json_decode(file_get_contents($pidFile), true);
+ foreach ($directories as $directory) {
+ foreach (glob($directory . '/doppar_cron_lock_*.pid') ?: [] as $pidFile) {
+ $processInfo = json_decode((string) @file_get_contents($pidFile), true);
+
+ if (!is_array($processInfo) || ($processInfo['finish_id'] ?? null) !== $finishId) {
+ continue;
+ }
- if ($processInfo['finish_id'] === $finishId) {
if ($shouldReleaseLock) {
- $lockFile = str_replace('.pid', '', $pidFile);
+ $lockFile = substr($pidFile, 0, -strlen('.pid'));
+
if (file_exists($lockFile)) {
- unlink($lockFile);
+ @unlink($lockFile);
}
}
- unlink($pidFile);
- break;
+ @unlink($pidFile);
+
+ break 2;
}
}
@@ -57,4 +66,18 @@ public function handle(): int
return $exitCode;
}
}
+
+ /**
+ * Get the directory that holds the scheduler's lock files
+ *
+ * @return string|null
+ */
+ protected function scheduleDirectory(): ?string
+ {
+ try {
+ return storage_path('schedule');
+ } catch (\Throwable) {
+ return null;
+ }
+ }
}
diff --git a/src/Phaseolies/Console/Commands/Cron/CronRunCommand.php b/src/Phaseolies/Console/Commands/Cron/CronRunCommand.php
index bda31649..ea7d9b5c 100644
--- a/src/Phaseolies/Console/Commands/Cron/CronRunCommand.php
+++ b/src/Phaseolies/Console/Commands/Cron/CronRunCommand.php
@@ -2,13 +2,22 @@
namespace Phaseolies\Console\Commands\Cron;
-use App\Schedule\Schedule;
use Phaseolies\Console\Schedule\Command;
+use Phaseolies\Console\Schedule\SchedulePool;
+use Symfony\Component\Console\Input\ArgvInput;
+use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Process\Process;
use React\EventLoop\Loop;
class CronRunCommand extends Command
{
+ /**
+ * The application class that registers the scheduled commands
+ *
+ * @var string
+ */
+ protected const SCHEDULE_CLASS = 'App\\Schedule\\Schedule';
+
/**
* The name and signature of the console command.
*
@@ -30,6 +39,20 @@ class CronRunCommand extends Command
*/
protected $lastExecution = [];
+ /**
+ * Number of scheduled commands that failed to start or exited with an error
+ *
+ * @var int
+ */
+ protected int $failedCommands = 0;
+
+ /**
+ * Handle of the lock file the daemon holds for as long as it runs
+ *
+ * @var resource|null
+ */
+ protected $daemonLock = null;
+
/**
* Execute the console command.
*
@@ -54,8 +77,11 @@ public function handle(): int
protected function runStandardMode(): int
{
return $this->executeWithTiming(function () {
- $schedule = new Schedule();
- $schedule->schedule($schedule);
+ $schedule = $this->makeSchedule();
+
+ if ($schedule === null) {
+ return Command::FAILURE;
+ }
$allCommands = $schedule->getCommands();
@@ -96,10 +122,39 @@ protected function runStandardMode(): int
$this->displayInfo('No scheduled commands are ready to run.');
}
+ // A task that could not run must not look like a clean run to cron
+ // monitors, or to anyone reading the exit status.
+ if ($this->failedCommands > 0) {
+ $this->displayError($this->failedCommands . ' scheduled command(s) failed.');
+
+ return Command::FAILURE;
+ }
+
return Command::SUCCESS;
});
}
+ /**
+ * Build the application's schedule
+ *
+ * @return object|null
+ */
+ protected function makeSchedule(): ?object
+ {
+ $class = self::SCHEDULE_CLASS;
+
+ if (!class_exists($class)) {
+ $this->displayError("The schedule class {$class} was not found. Create it, or remove cron:run from your crontab.");
+
+ return null;
+ }
+
+ $schedule = new $class();
+ $schedule->schedule($schedule);
+
+ return $schedule;
+ }
+
/**
* Run in daemon mode for second-based schedules using React PHP Event Loop
*
@@ -107,6 +162,16 @@ protected function runStandardMode(): int
*/
protected function runDaemonMode(): int
{
+ // Only one daemon may run. The lock is held for the daemon's whole life
+ // and is released by the OS if it dies, so it can never go stale, and
+ // two starts at the same instant cannot both win. This makes it safe to
+ // keep `cron:run --daemon` in the crontab.
+ if (!$this->acquireDaemonLock()) {
+ $this->displayInfo('The cron daemon is already running; nothing to do.');
+
+ return Command::SUCCESS;
+ }
+
$this->displayInfo('Starting doppar cron daemon...');
$this->displayInfo('Monitoring for second-based schedules...');
$this->displayInfo('Press Ctrl+C to stop');
@@ -139,8 +204,11 @@ protected function runDaemonMode(): int
// Check every second for due commands
Loop::addPeriodicTimer(1.0, function () {
try {
- $schedule = new Schedule();
- $schedule->schedule($schedule);
+ $schedule = $this->makeSchedule();
+
+ if ($schedule === null) {
+ return;
+ }
$allCommands = $schedule->getCommands();
@@ -259,7 +327,7 @@ protected function getCommandKey($command): string
protected function executeCommand($command, bool $isSecondBased = false): void
{
try {
- $env = array_merge(getenv(), [
+ $env = SchedulePool::buildEnv([
'APP_RUNNING_IN_CONSOLE' => true,
'APP_SCHEDULE_RUNNING' => true,
'APP_SECOND_SCHEDULE' => $isSecondBased ? 'true' : 'false'
@@ -274,39 +342,61 @@ protected function executeCommand($command, bool $isSecondBased = false): void
} else {
$this->runInForeground($command, $env);
}
- } catch (\Exception $e) {
+ } catch (\Throwable $e) {
+ $this->failedCommands++;
$this->displayError('Error executing command: ' . $e->getMessage());
}
}
/**
- * Check if daemon is currently running
+ * Take the exclusive daemon lock without waiting
*
* @return bool
*/
- protected function isDaemonRunning(): bool
+ protected function acquireDaemonLock(): bool
{
- $pidFile = $this->getDaemonPidFile();
+ $file = dirname($this->getDaemonPidFile()) . '/cron_daemon.lock';
+ $dir = dirname($file);
- if (!file_exists($pidFile)) {
- return false;
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0755, true);
+ }
+
+ $handle = @fopen($file, 'c');
+
+ if ($handle === false) {
+ // Storage is not writable; the PID file check below is the best we can do.
+ return !$this->isDaemonRunning();
}
- $data = @json_decode(file_get_contents($pidFile), true);
- $pid = $data['pid'] ?? 0;
+ if (!flock($handle, LOCK_EX | LOCK_NB)) {
+ fclose($handle);
- if ($pid <= 0) {
return false;
}
- // Check if process is actually running
- if (function_exists('posix_kill')) {
- return posix_kill($pid, 0);
+ $this->daemonLock = $handle;
+
+ return true;
+ }
+
+ /**
+ * Check if daemon is currently running
+ *
+ * @return bool
+ */
+ protected function isDaemonRunning(): bool
+ {
+ $pidFile = $this->getDaemonPidFile();
+
+ if (!file_exists($pidFile)) {
+ return false;
}
- // Fallback for systems without posix_kill
- $output = shell_exec(sprintf("ps -p %d -o pid=", $pid));
- return !empty(trim($output));
+ $data = @json_decode((string) @file_get_contents($pidFile), true);
+ $pid = (int) ($data['pid'] ?? 0);
+
+ return SchedulePool::isProcessRunning($pid);
}
/**
@@ -343,7 +433,13 @@ protected function cleanupDaemonPid(): void
$pidFile = $this->getDaemonPidFile();
if (file_exists($pidFile)) {
- unlink($pidFile);
+ @unlink($pidFile);
+ }
+
+ if ($this->daemonLock !== null) {
+ flock($this->daemonLock, LOCK_UN);
+ fclose($this->daemonLock);
+ $this->daemonLock = null;
}
}
@@ -357,10 +453,78 @@ protected function getDaemonPidFile(): string
return storage_path('schedule/cron_daemon.pid');
}
+ /**
+ * Build the shell command that runs a task detached from this process
+ *
+ * @param string $command
+ * @param string $logFile
+ * @param string $finishId
+ * @param bool $releaseLock
+ * @param array $env
+ * @return string
+ */
+ protected function buildBackgroundCommand(
+ string $command,
+ string $logFile,
+ string $finishId,
+ bool $releaseLock,
+ array $env = []
+ ): string {
+ $php = escapeshellarg(SchedulePool::phpBinary());
+ $pool = escapeshellarg(SchedulePool::poolScript());
+ $log = escapeshellarg($logFile);
+ $arguments = implode(' ', array_map('escapeshellarg', SchedulePool::splitCommand($command)));
+
+ $assignments = '';
+
+ foreach ($env as $name => $value) {
+ if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', (string) $name)) {
+ $assignments .= $name . '=' . escapeshellarg((string) $value) . ' ';
+ }
+ }
+
+ return sprintf(
+ '(cd %s && %s%s %s %s >> %s 2>&1 ; %s %s cron:finish %s %d $? >> %s 2>&1) > /dev/null 2>&1 < /dev/null & echo $!',
+ escapeshellarg(base_path()),
+ $assignments,
+ $php,
+ $pool,
+ $arguments,
+ $log,
+ $php,
+ $pool,
+ escapeshellarg($finishId),
+ $releaseLock ? 1 : 0,
+ $log
+ );
+ }
+
+ /**
+ * Start a shell command that detaches itself, and return its PID
+ *
+ * @param string $shellCommand
+ * @return int|null
+ */
+ protected function startDetached(string $shellCommand): ?int
+ {
+ return SchedulePool::startDetached($shellCommand);
+ }
+
+ /**
+ * Start a command in the background and return immediately
+ *
+ * @param mixed $command
+ * @param array $env
+ * @return void
+ */
protected function runInBackground($command, $env): void
{
- $phpBinary = PHP_BINARY;
- $poolScript = 'pool';
+ if (SchedulePool::isWindows()) {
+ $this->displayWarning('Background execution is not supported on Windows; running in the foreground: ' . $command->getCommand());
+ $this->runInForeground($command, $env);
+
+ return;
+ }
$finishId = uniqid('cron_finish_', true);
@@ -384,25 +548,34 @@ protected function runInBackground($command, $env): void
mkdir($lockDir, 0755, true);
}
- $commandParts = implode(' ', array_map('escapeshellarg', preg_split('/\s+/', trim($command->getCommand()))));
+ $flags = array_intersect_key($env, array_flip([
+ 'APP_RUNNING_IN_CONSOLE',
+ 'APP_SCHEDULE_RUNNING',
+ 'APP_SECOND_SCHEDULE',
+ ]));
- $commandString = sprintf(
- '(%s %s %s >> %s 2>&1 ; %s %s cron:finish %s %d $? >> %s 2>&1) & echo $!',
- escapeshellarg($phpBinary),
- escapeshellarg($poolScript),
- $commandParts,
- escapeshellarg($logFile),
- escapeshellarg($phpBinary),
- escapeshellarg($poolScript),
- escapeshellarg($finishId),
- $command->withoutOverlapping ? 1 : 0,
- escapeshellarg($logFile)
+ $commandString = $this->buildBackgroundCommand(
+ $command->getCommand(),
+ $logFile,
+ $finishId,
+ (bool) $command->withoutOverlapping,
+ $flags
);
- $pid = (int) shell_exec($commandString);
+ $pid = $this->startDetached($commandString);
- if (empty($pid)) {
- throw new \RuntimeException('Failed to start background process');
+ if ($pid === null) {
+ // shell_exec, exec and proc_open are all unavailable (or the shell
+ // returned no PID). Do not lose the task: run it here instead.
+ $this->displayWarning('Could not start a background process; running in the foreground: ' . $command->getCommand());
+
+ if ($command->withoutOverlapping) {
+ $command->releaseLock();
+ }
+
+ $this->runInForeground($command, $env);
+
+ return;
}
$processInfo = [
@@ -442,7 +615,9 @@ protected function runInBackground($command, $env): void
}
}
- file_put_contents(
+ // Best effort: the log path can be unwritable, and that must not turn a
+ // started job into a failed run.
+ @file_put_contents(
$logFile,
sprintf(
"[%s] Process started (PID: %d)\nCommand: %s\nProcess Info: %s\n\n",
@@ -455,28 +630,93 @@ protected function runInBackground($command, $env): void
);
}
+ /**
+ * Run a command to completion and record whether it succeeded
+ *
+ * @param mixed $command
+ * @param array $env
+ * @return void
+ */
protected function runInForeground($command, $env): void
{
if ($command->withoutOverlapping) {
$command->lock();
}
- $process = new Process(array_merge(['php', 'pool'], preg_split('/\s+/', trim($command->getCommand()))), base_path(), $env);
- $process->setTimeout(null);
- $process->run();
+ try {
+ $result = $this->runToCompletion($command->getCommand(), $env);
- if ($process->isSuccessful()) {
- // Only show success for non-second-based
- if (!$command->isSecondSchedule()) {
- $this->info('Success: ' . $command->getCommand());
+ if ($result['code'] === 0) {
+ // Only show success for non-second-based
+ if (!$command->isSecondSchedule()) {
+ $this->info('Success: ' . $command->getCommand());
+ }
+ } else {
+ $this->failedCommands++;
+ $this->displayError('Error: ' . $command->getCommand() . ' (exit code ' . $result['code'] . ')');
+ $this->displayError('Output: ' . ($result['stderr'] !== '' ? $result['stderr'] : $result['stdout']));
+ }
+ } finally {
+ if ($command->withoutOverlapping) {
+ $command->releaseLock();
}
- } else {
- $this->displayError('Error: ' . $command->getCommand());
- $this->displayError('Output: ' . $process->getErrorOutput());
}
+ }
- if ($command->withoutOverlapping) {
- $command->releaseLock();
+ /**
+ * Run a pool command and wait for it.
+ *
+ * @param string $command
+ * @param array $env
+ * @return array{code: int, stdout: string, stderr: string}
+ */
+ protected function runToCompletion(string $command, array $env): array
+ {
+ if (SchedulePool::isFunctionEnabled('proc_open')) {
+ $process = new Process(SchedulePool::buildProcessArguments($command), base_path(), $env);
+ $process->setTimeout(null);
+ $process->run();
+
+ return [
+ 'code' => (int) $process->getExitCode(),
+ 'stdout' => $process->getOutput(),
+ 'stderr' => $process->getErrorOutput(),
+ ];
}
+
+ if (SchedulePool::isFunctionEnabled('exec')) {
+ $arguments = implode(' ', array_map('escapeshellarg', SchedulePool::buildProcessArguments($command)));
+ $lines = [];
+ $code = 0;
+
+ exec(sprintf('cd %s && %s 2>&1', escapeshellarg(base_path()), $arguments), $lines, $code);
+
+ return ['code' => $code, 'stdout' => implode("\n", $lines), 'stderr' => ''];
+ }
+
+ return $this->runInProcess($command);
+ }
+
+ /**
+ * Run a pool command through the console application in this process
+ *
+ * @param string $command
+ * @return array{code: int, stdout: string, stderr: string}
+ */
+ protected function runInProcess(string $command): array
+ {
+ $application = $this->getApplication();
+ $tokens = SchedulePool::splitCommand($command);
+
+ if ($application === null || $tokens === []) {
+ throw new \RuntimeException(
+ 'Cannot run scheduled commands: proc_open, exec and shell_exec are disabled on this host.'
+ );
+ }
+
+ $output = new BufferedOutput();
+ $code = $application->find($tokens[0])->run(new ArgvInput(array_merge(['pool'], $tokens)), $output);
+
+ return ['code' => $code, 'stdout' => $output->fetch(), 'stderr' => ''];
}
}
diff --git a/src/Phaseolies/Console/Commands/FrontendInstallCommand.php b/src/Phaseolies/Console/Commands/FrontendInstallCommand.php
index 25195733..f8a7e32d 100644
--- a/src/Phaseolies/Console/Commands/FrontendInstallCommand.php
+++ b/src/Phaseolies/Console/Commands/FrontendInstallCommand.php
@@ -129,7 +129,7 @@ protected function ensureClientDirectories(): void
client_path(),
client_path('css'),
client_path('js'),
- resource_path('views/layouts'),
+ template_path('views/layouts'),
storage_path('framework'),
] as $directory
) {
@@ -159,8 +159,8 @@ protected function writeFrontendFiles(array $options, array &$state): void
client_path('css/app.css') => $this->clientCss($cssStack),
client_path('js/' . $this->bootstrapFilename($typescript)) => $this->bootstrapFile($cssStack, $typescript, $htmx),
client_path('js/' . $this->entryFilename($framework, $typescript)) => $this->entryFile($framework, $cssStack, $typescript),
- base_path('resources/views/layouts/app.odo.php') => $this->appLayoutView($framework, $typescript),
- base_path('resources/views/welcome.odo.php') => $this->welcomeView($framework, $typescript),
+ base_path('templates/views/layouts/app.odo.php') => $this->appLayoutView($framework, $typescript),
+ base_path('templates/views/welcome.odo.php') => $this->welcomeView($framework, $typescript),
];
if ($cssStack === 'tailwind') {
@@ -278,9 +278,9 @@ protected function trackFrontendDirectories(array &$state): void
protected function trackedFrontendDirectories(): array
{
return [
- $this->projectPath('resources/client'),
- $this->projectPath('resources/client/css'),
- $this->projectPath('resources/client/js'),
+ $this->projectPath('templates/client'),
+ $this->projectPath('templates/client/css'),
+ $this->projectPath('templates/client/js'),
$this->projectPath('public/build'),
];
}
@@ -293,9 +293,7 @@ protected function trackedFrontendDirectories(): array
*/
protected function projectPath(string $path = ''): string
{
- $basePath = defined('BASE_PATH')
- ? rtrim(BASE_PATH, DIRECTORY_SEPARATOR)
- : rtrim(getcwd() ?: '', DIRECTORY_SEPARATOR);
+ $basePath = rtrim(getcwd() ?: '', DIRECTORY_SEPARATOR);
$normalizedPath = trim(str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path), DIRECTORY_SEPARATOR);
@@ -313,7 +311,7 @@ protected function projectPath(string $path = ''): string
*/
protected function entryFilePath(string $framework, bool $typescript): string
{
- return 'resources/client/js/' . $this->entryFilename($framework, $typescript);
+ return 'templates/client/js/' . $this->entryFilename($framework, $typescript);
}
/**
diff --git a/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php b/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php
index a51f98b8..cb34aa49 100644
--- a/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php
+++ b/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php
@@ -135,11 +135,11 @@ protected function performLegacyFrontendCleanup(bool $removeNodeModules = false)
client_path('js/App.tsx'),
client_path('js/App.vue'),
client_path('js/App.svelte'),
- base_path('client/css/app.css'),
- base_path('client/js/app.js'),
- base_path('client/js/app.ts'),
- base_path('client/js/bootstrap.js'),
- base_path('client/js/bootstrap.ts'),
+ base_path('templates/client/css/app.css'),
+ base_path('templates/client/js/app.js'),
+ base_path('templates/client/js/app.ts'),
+ base_path('templates/client/js/bootstrap.js'),
+ base_path('templates/client/js/bootstrap.ts'),
base_path('client/js/main.js'),
base_path('client/js/main.ts'),
base_path('client/js/main.jsx'),
@@ -195,7 +195,7 @@ protected function performLegacyFrontendCleanup(bool $removeNodeModules = false)
protected function legacyFrontendDirectories(): array
{
return [
- $this->projectPath('resources/client'),
+ $this->projectPath('templates/client'),
$this->projectPath('client'),
];
}
@@ -208,9 +208,7 @@ protected function legacyFrontendDirectories(): array
*/
protected function projectPath(string $path = ''): string
{
- $basePath = defined('BASE_PATH')
- ? rtrim(BASE_PATH, DIRECTORY_SEPARATOR)
- : rtrim(getcwd() ?: '', DIRECTORY_SEPARATOR);
+ $basePath = rtrim(getcwd() ?: '', DIRECTORY_SEPARATOR);
$normalizedPath = trim(str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path), DIRECTORY_SEPARATOR);
@@ -226,7 +224,7 @@ protected function projectPath(string $path = ''): string
*/
protected function removeLegacyViteMarkersFromLayouts(): int
{
- $viewsPath = base_path('resources/views');
+ $viewsPath = base_path('templates/views');
if (!is_dir($viewsPath)) {
return 0;
@@ -357,7 +355,7 @@ protected function isGeneratedViteConfig(string $contents): bool
&& str_contains($contents, "storage/framework/vite.hot")
&& str_contains($contents, "outDir: 'public/build'")
&& (
- str_contains($contents, "path.resolve(__dirname, 'resources/client')")
+ str_contains($contents, "path.resolve(__dirname, 'templates/client')")
|| str_contains($contents, "path.resolve(__dirname, 'client')")
);
}
@@ -378,13 +376,13 @@ protected function isGeneratedTsConfig(string $contents): bool
return ($decoded['compilerOptions']['moduleResolution'] ?? null) === 'Bundler'
&& ($decoded['compilerOptions']['baseUrl'] ?? null) === '.'
- && in_array(($decoded['compilerOptions']['paths']['@/*'][0] ?? null), ['resources/client/js/*', 'client/js/*'], true)
+ && in_array(($decoded['compilerOptions']['paths']['@/*'][0] ?? null), ['templates/client/js/*', 'client/js/*'], true)
&& (
- in_array('resources/client/**/*.ts', $decoded['include'] ?? [], true)
+ in_array('templates/client/**/*.ts', $decoded['include'] ?? [], true)
|| in_array('client/**/*.ts', $decoded['include'] ?? [], true)
)
&& (
- in_array('resources/client/**/*.tsx', $decoded['include'] ?? [], true)
+ in_array('templates/client/**/*.tsx', $decoded['include'] ?? [], true)
|| in_array('client/**/*.tsx', $decoded['include'] ?? [], true)
);
}
diff --git a/src/Phaseolies/Console/Commands/KeyGenerateCommand.php b/src/Phaseolies/Console/Commands/KeyGenerateCommand.php
index 08e47119..92f6aea5 100644
--- a/src/Phaseolies/Console/Commands/KeyGenerateCommand.php
+++ b/src/Phaseolies/Console/Commands/KeyGenerateCommand.php
@@ -19,7 +19,7 @@ class KeyGenerateCommand extends Command
*
* @var string
*/
- protected $description = 'Generate a new application key and set it in the .env file';
+ protected $description = 'Generate a new application key and set it in the env.toml file';
/**
* Execute the console command.
@@ -30,21 +30,21 @@ public function handle(): int
{
return $this->executeWithTiming(function() {
$randomKey = base64_encode(random_bytes(32));
- $envPath = base_path() . '/.env';
+ $envPath = base_path() . '/env.toml';
if (!file_exists($envPath)) {
- throw new RuntimeException('.env file not found!');
+ throw new RuntimeException('env.toml file not found!');
}
$envContent = file_get_contents($envPath);
$newEnvContent = preg_replace(
- '/^APP_KEY=.*$/m',
- "APP_KEY=base64:$randomKey",
+ '/^APP_KEY\s*=.*$/m',
+ "APP_KEY = \"base64:$randomKey\"",
$envContent
);
if (file_put_contents($envPath, $newEnvContent) === false) {
- throw new RuntimeException('Failed to update .env file.');
+ throw new RuntimeException('Failed to update env.toml file.');
}
$this->displaySuccess('Application key set successfully');
diff --git a/src/Phaseolies/Console/Commands/MakeAuthCommand.php b/src/Phaseolies/Console/Commands/MakeAuthCommand.php
index d6525170..930fa5d7 100644
--- a/src/Phaseolies/Console/Commands/MakeAuthCommand.php
+++ b/src/Phaseolies/Console/Commands/MakeAuthCommand.php
@@ -57,9 +57,9 @@ public function handle(): int
protected function createDirectories(): void
{
$paths = [
- base_path('app/Http/Controllers/Auth/'),
- base_path('resources/views/auth/'),
- base_path('resources/views/layouts/'),
+ base_path('src/Http/Controllers/Auth/'),
+ base_path('templates/views/auth/'),
+ base_path('templates/views/layouts/'),
];
foreach ($paths as $path) {
@@ -75,27 +75,27 @@ protected function createDirectories(): void
protected function generateControllers(): void
{
$this->createFile(
- base_path('app/Http/Controllers/Auth/LoginController.php'),
+ base_path('src/Http/Controllers/Auth/LoginController.php'),
$this->getStubContent('LoginController.stub')
);
$this->createFile(
- base_path('app/Http/Controllers/Auth/RegisterController.php'),
+ base_path('src/Http/Controllers/Auth/RegisterController.php'),
$this->getStubContent('RegisterController.stub')
);
$this->createFile(
- base_path('app/Http/Controllers/Auth/TwoFactorAuthController.php'),
+ base_path('src/Http/Controllers/Auth/TwoFactorAuthController.php'),
$this->getStubContent('TwoFactorAuthController.stub')
);
$this->createFile(
- base_path('app/Http/Controllers/HomeController.php'),
+ base_path('src/Http/Controllers/HomeController.php'),
$this->getStubContent('HomeController.stub')
);
$this->createFile(
- base_path('app/Http/Controllers/ProfileController.php'),
+ base_path('src/Http/Controllers/ProfileController.php'),
$this->getStubContent('ProfileController.stub')
);
}
@@ -115,7 +115,7 @@ protected function generateViews(): void
];
foreach ($views as $destination => $stubFile) {
- $destinationPath = base_path('resources/views/' . $destination);
+ $destinationPath = base_path('templates/views/' . $destination);
$content = $this->getStubContent($stubFile);
$this->createFile($destinationPath, $content);
}
@@ -126,7 +126,7 @@ protected function generateViews(): void
*/
protected function appendRoutes(): void
{
- $routesPath = base_path('routes/web.php');
+ $routesPath = base_path('runtime/routes/web.php');
if (!file_exists($routesPath)) {
throw new RuntimeException('Routes file not found: ' . $routesPath);
@@ -167,17 +167,17 @@ protected function getStubContent(string $stubName): string
protected function authFilesExist(): bool
{
$filesToCheck = [
- base_path('app/Http/Controllers/Auth/LoginController.php'),
- base_path('app/Http/Controllers/Auth/RegisterController.php'),
- base_path('app/Http/Controllers/Auth/TwoFactorAuthController.php'),
- base_path('app/Http/Controllers/HomeController.php'),
- base_path('app/Http/Controllers/ProfileController.php'),
- base_path('resources/views/auth/login.odo.php'),
- base_path('resources/views/auth/2fa.odo.php'),
- base_path('resources/views/auth/register.odo.php'),
- base_path('resources/views/layouts/app.odo.php'),
- base_path('resources/views/home.odo.php'),
- base_path('resources/views/profile.odo.php'),
+ base_path('src/Http/Controllers/Auth/LoginController.php'),
+ base_path('src/Http/Controllers/Auth/RegisterController.php'),
+ base_path('src/Http/Controllers/Auth/TwoFactorAuthController.php'),
+ base_path('src/Http/Controllers/HomeController.php'),
+ base_path('src/Http/Controllers/ProfileController.php'),
+ base_path('templates/views/auth/login.odo.php'),
+ base_path('templates/views/auth/2fa.odo.php'),
+ base_path('templates/views/auth/register.odo.php'),
+ base_path('templates/views/layouts/app.odo.php'),
+ base_path('templates/views/home.odo.php'),
+ base_path('templates/views/profile.odo.php'),
];
foreach ($filesToCheck as $file) {
@@ -196,19 +196,19 @@ protected function listExistingAuthFiles(): void
{
$files = [
'Controllers' => [
- base_path('app/Http/Controllers/Auth/LoginController.php'),
- base_path('app/Http/Controllers/Auth/RegisterController.php'),
- base_path('app/Http/Controllers/Auth/TwoFactorAuthController.php'),
- base_path('app/Http/Controllers/HomeController.php'),
- base_path('app/Http/Controllers/ProfileController.php'),
+ base_path('src/Http/Controllers/Auth/LoginController.php'),
+ base_path('src/Http/Controllers/Auth/RegisterController.php'),
+ base_path('src/Http/Controllers/Auth/TwoFactorAuthController.php'),
+ base_path('src/Http/Controllers/HomeController.php'),
+ base_path('src/Http/Controllers/ProfileController.php'),
],
'Views' => [
- base_path('resources/views/auth/login.odo.php'),
- base_path('resources/views/auth/register.odo.php'),
- base_path('resources/views/auth/2fa.odo.php'),
- base_path('resources/views/layouts/app.odo.php'),
- base_path('resources/views/home.odo.php'),
- base_path('resources/views/profile.odo.php'),
+ base_path('templates/views/auth/login.odo.php'),
+ base_path('templates/views/auth/register.odo.php'),
+ base_path('templates/views/auth/2fa.odo.php'),
+ base_path('templates/views/layouts/app.odo.php'),
+ base_path('templates/views/home.odo.php'),
+ base_path('templates/views/profile.odo.php'),
]
];
diff --git a/src/Phaseolies/Console/Commands/MakeAuthorizerCommand.php b/src/Phaseolies/Console/Commands/MakeAuthorizerCommand.php
index add18ecf..8c5dd5c4 100644
--- a/src/Phaseolies/Console/Commands/MakeAuthorizerCommand.php
+++ b/src/Phaseolies/Console/Commands/MakeAuthorizerCommand.php
@@ -32,7 +32,7 @@ public function handle(): int
$model = $this->option('model') ?? $this->option('m');
$namespace = 'App\\Authorizers' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Authorizers', $name);
+ $filePath = $this->generatedFilePath('src/Authorizers', $name);
if (file_exists($filePath)) {
$this->displayError('Authorizer already exists at:');
diff --git a/src/Phaseolies/Console/Commands/MakeConsoleCommand.php b/src/Phaseolies/Console/Commands/MakeConsoleCommand.php
index 01266a37..95210e01 100644
--- a/src/Phaseolies/Console/Commands/MakeConsoleCommand.php
+++ b/src/Phaseolies/Console/Commands/MakeConsoleCommand.php
@@ -30,7 +30,7 @@ public function handle(): int
return $this->executeWithTiming(function() {
[$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name'));
$namespace = 'App\\Schedule\\Commands' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Schedule/Commands', $name);
+ $filePath = $this->generatedFilePath('src/Schedule/Commands', $name);
if (file_exists($filePath)) {
$this->displayError('Command already exists at:');
diff --git a/src/Phaseolies/Console/Commands/MakeControllerCommand.php b/src/Phaseolies/Console/Commands/MakeControllerCommand.php
index f8b47720..2178e6b8 100644
--- a/src/Phaseolies/Console/Commands/MakeControllerCommand.php
+++ b/src/Phaseolies/Console/Commands/MakeControllerCommand.php
@@ -129,7 +129,7 @@ protected function replacePlaceholders(
string $routeName
): string {
$baseNamespace = 'App\\Http\\Controllers';
- $basePath = 'app/Http/Controllers/';
+ $basePath = 'src/Http/Controllers/';
// Normalize route view name
$routeView = str_replace(['\\', '/'], '.', $routeName);
@@ -201,7 +201,7 @@ protected function resolveNamespacesAndPaths(string $name, bool $isApi): array
$namespace = $baseNamespace . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
$filePath = $this->generatedFilePath(
- 'app/Http/Controllers' . ($isApi ? '/API' : ''),
+ 'src/Http/Controllers' . ($isApi ? '/API' : ''),
$name
);
@@ -236,7 +236,7 @@ protected function generateLayout(string $namespace, string $className, string $
$layoutDir = base_path(str_replace(
['/', '\\'],
DIRECTORY_SEPARATOR,
- 'resources/views/' . $this->normalizeGeneratedName($routeName)
+ 'templates/views/' . $this->normalizeGeneratedName($routeName)
));
$this->createDirIfMissing($layoutDir);
$layoutPath = $layoutDir . '/default.odo.php';
diff --git a/src/Phaseolies/Console/Commands/MakeHookCommand.php b/src/Phaseolies/Console/Commands/MakeHookCommand.php
index 72bb8d5d..a8eb35cb 100644
--- a/src/Phaseolies/Console/Commands/MakeHookCommand.php
+++ b/src/Phaseolies/Console/Commands/MakeHookCommand.php
@@ -30,7 +30,7 @@ public function handle(): int
return $this->executeWithTiming(function() {
[$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name'));
$namespace = 'App\\Hooks' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Hooks', $name);
+ $filePath = $this->generatedFilePath('src/Hooks', $name);
// Check if hook already exists
if (file_exists($filePath)) {
diff --git a/src/Phaseolies/Console/Commands/MakeLauncherCommand.php b/src/Phaseolies/Console/Commands/MakeLauncherCommand.php
new file mode 100644
index 00000000..669cdaa3
--- /dev/null
+++ b/src/Phaseolies/Console/Commands/MakeLauncherCommand.php
@@ -0,0 +1,99 @@
+executeWithTiming(function () {
+ [$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name'));
+ $namespace = 'App\\Launchers' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
+ $filePath = $this->generatedFilePath('src/Launchers', $name);
+
+ // Check if launcher already exists
+ if (file_exists($filePath)) {
+ $this->displayError('Launcher already exists at:');
+ $this->line('' . $this->relativePath($filePath) . '>');
+ return Command::FAILURE;
+ }
+
+ // Create directory if needed
+ $directoryPath = dirname($filePath);
+ if (!is_dir($directoryPath)) {
+ mkdir($directoryPath, 0755, true);
+ }
+
+ // Generate and save Launcher class
+ $content = $this->generateProviderContent($namespace, $className);
+ file_put_contents($filePath, $content);
+
+ $this->displaySuccess('Launcher created successfully');
+ $this->line('📦 File:> ' . $this->relativePath($filePath) . '>');
+ $this->newLine();
+ $this->line('📌 Class:> ' . $className . '>');
+
+ return Command::SUCCESS;
+ });
+ }
+
+ /**
+ * Generate provider class content.
+ */
+ protected function generateProviderContent(string $namespace, string $className): string
+ {
+ return << 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Mail', $name);
+ $filePath = $this->generatedFilePath('src/Mail', $name);
// Check if Mailable already exists
if (file_exists($filePath)) {
diff --git a/src/Phaseolies/Console/Commands/MakeMiddlewareCommand.php b/src/Phaseolies/Console/Commands/MakeMiddlewareCommand.php
index 86f2ca58..89d46153 100644
--- a/src/Phaseolies/Console/Commands/MakeMiddlewareCommand.php
+++ b/src/Phaseolies/Console/Commands/MakeMiddlewareCommand.php
@@ -30,7 +30,7 @@ public function handle(): int
return $this->executeWithTiming(function() {
[$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name'));
$namespace = 'App\\Http\\Middleware' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Http/Middleware', $name);
+ $filePath = $this->generatedFilePath('src/Http/Middleware', $name);
// Check if middleware already exists
if (file_exists($filePath)) {
diff --git a/src/Phaseolies/Console/Commands/MakeModelCommand.php b/src/Phaseolies/Console/Commands/MakeModelCommand.php
index 8ee5bf63..f97d47d5 100644
--- a/src/Phaseolies/Console/Commands/MakeModelCommand.php
+++ b/src/Phaseolies/Console/Commands/MakeModelCommand.php
@@ -51,7 +51,7 @@ public function handle(): int
[$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name'));
$withMigration = $this->option('m');
$namespace = 'App\\Models' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Models', $name);
+ $filePath = $this->generatedFilePath('src/Models', $name);
// Check if model already exists
if (file_exists($filePath)) {
@@ -129,6 +129,6 @@ protected function createMigration(string $name, string $table): string
*/
protected function getMigrationPath(): string
{
- return base_path('database/migrations');
+ return base_path('schema/migrations');
}
}
diff --git a/src/Phaseolies/Console/Commands/MakeProviderCommand.php b/src/Phaseolies/Console/Commands/MakeProviderCommand.php
index 42d3d2e4..ddfd5799 100644
--- a/src/Phaseolies/Console/Commands/MakeProviderCommand.php
+++ b/src/Phaseolies/Console/Commands/MakeProviderCommand.php
@@ -2,98 +2,10 @@
namespace Phaseolies\Console\Commands;
-use Phaseolies\Console\Schedule\Command;
-
-class MakeProviderCommand extends Command
+/**
+ * @deprecated since Doppar 4.x
+ * Use MakeLauncherCommand.
+ */
+class MakeProviderCommand extends MakeLauncherCommand
{
- /**
- * The name and signature of the console command.
- *
- * @var string
- */
- protected $name = 'make:provider {name}';
-
- /**
- * The description of the console command.
- *
- * @var string
- */
- protected $description = 'Create a new service provider class';
-
- /**
- * Execute the console command.
- *
- * @return int
- */
- public function handle(): int
- {
- return $this->executeWithTiming(function () {
- [$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name'));
- $namespace = 'App\\Providers' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Providers', $name);
-
- // Check if provider already exists
- if (file_exists($filePath)) {
- $this->displayError('Provider already exists at:');
- $this->line('' . $this->relativePath($filePath) . '>');
- return Command::FAILURE;
- }
-
- // Create directory if needed
- $directoryPath = dirname($filePath);
- if (!is_dir($directoryPath)) {
- mkdir($directoryPath, 0755, true);
- }
-
- // Generate and save provider class
- $content = $this->generateProviderContent($namespace, $className);
- file_put_contents($filePath, $content);
-
- $this->displaySuccess('Provider created successfully');
- $this->line('📦 File:> ' . $this->relativePath($filePath) . '>');
- $this->newLine();
- $this->line('📌 Class:> ' . $className . '>');
-
- return Command::SUCCESS;
- });
- }
-
- /**
- * Generate provider class content.
- */
- protected function generateProviderContent(string $namespace, string $className): string
- {
- return << 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Http/Validations', $name);
+ $filePath = $this->generatedFilePath('src/Http/Validations', $name);
// Check if request already exists
if (file_exists($filePath)) {
diff --git a/src/Phaseolies/Console/Commands/MakeRuleCommand.php b/src/Phaseolies/Console/Commands/MakeRuleCommand.php
index a498329f..34711bc2 100644
--- a/src/Phaseolies/Console/Commands/MakeRuleCommand.php
+++ b/src/Phaseolies/Console/Commands/MakeRuleCommand.php
@@ -30,7 +30,7 @@ public function handle(): int
return $this->executeWithTiming(function () {
[$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name'));
$namespace = 'App\\Http\\Validations\\Rules' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Http/Validations/Rules', $name);
+ $filePath = $this->generatedFilePath('src/Http/Validations/Rules', $name);
// Check if rule already exists
if (file_exists($filePath)) {
diff --git a/src/Phaseolies/Console/Commands/MakeSignalCommand.php b/src/Phaseolies/Console/Commands/MakeSignalCommand.php
new file mode 100644
index 00000000..65a3c3d8
--- /dev/null
+++ b/src/Phaseolies/Console/Commands/MakeSignalCommand.php
@@ -0,0 +1,128 @@
+executeWithTiming(function () {
+ [$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name'));
+ $connection = $this->option('connection');
+ $connection = is_string($connection) && $connection !== '' ? $connection : null;
+
+ if ($className === '' || $this->generatedNameError($name) !== null) {
+ $this->displayError('The signal name must be a valid class name.');
+ return Command::FAILURE;
+ }
+
+ if ($connection !== null && !preg_match('/^[A-Za-z0-9_.\-]+$/', $connection)) {
+ $this->displayError('The connection name may only contain letters, numbers, dots, dashes and underscores.');
+ return Command::FAILURE;
+ }
+
+ $namespace = 'App\\Signals' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
+ $filePath = $this->generatedFilePath('src/Signals', $name);
+
+ // Check if signal already exists
+ if (file_exists($filePath)) {
+ $this->displayError('Signal already exists at:');
+ $this->line('' . $this->relativePath($filePath) . '>');
+ return Command::FAILURE;
+ }
+
+ // Create directory if needed
+ $directoryPath = dirname($filePath);
+ if (!is_dir($directoryPath)) {
+ mkdir($directoryPath, 0755, true);
+ }
+
+ // A connection only makes sense for a signal that waits for a transaction
+ $settle = (bool) $this->option('settle') || $connection !== null;
+
+ // Generate and save signal class
+ $content = $this->generateSignalContent($namespace, $className, $settle, $connection);
+ file_put_contents($filePath, $content);
+
+ $this->displaySuccess('Signal created successfully');
+ $this->line('📁 File:> ' . $this->relativePath($filePath) . '>');
+ $this->newLine();
+ $this->line('📌 Signal Class:> ' . $className . '>');
+
+ if ($settle) {
+ $this->newLine();
+ $this->line('⏳ Delivery:> after the transaction commits'
+ . ($connection !== null ? " (connection: {$connection})" : '') . '>');
+ }
+
+ return Command::SUCCESS;
+ });
+ }
+
+ /**
+ * Generate signal class content.
+ *
+ * @param string $namespace
+ * @param string $className
+ * @param bool $settle
+ * @param string|null $connection
+ * @return string
+ */
+ protected function generateSignalContent(
+ string $namespace,
+ string $className,
+ bool $settle = false,
+ ?string $connection = null
+ ): string {
+ $import = '';
+ $attribute = '';
+
+ if ($settle) {
+ $import = "\nuse Phaseolies\\Signals\\Attributes\\DeliverOnSettle;\n";
+ $attribute = $connection !== null
+ ? "#[DeliverOnSettle(connection: '{$connection}')]\n"
+ : "#[DeliverOnSettle]\n";
+ }
+
+ return <<executeWithTiming(function () {
[$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name'));
$namespace = 'App\\Watchers' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Watchers', $name);
+ $filePath = $this->generatedFilePath('src/Watchers', $name);
if (file_exists($filePath)) {
$this->displayError('Watcher already exists at:');
diff --git a/src/Phaseolies/Console/Commands/Migrations/AddColumnMigrationCommand.php b/src/Phaseolies/Console/Commands/Migrations/AddColumnMigrationCommand.php
index 6f144e9c..154be94f 100644
--- a/src/Phaseolies/Console/Commands/Migrations/AddColumnMigrationCommand.php
+++ b/src/Phaseolies/Console/Commands/Migrations/AddColumnMigrationCommand.php
@@ -76,6 +76,6 @@ public function handle(): int
*/
protected function getMigrationPath(): string
{
- return base_path('database/migrations');
+ return base_path('schema/migrations');
}
}
diff --git a/src/Phaseolies/Console/Commands/Migrations/CreateMigrationCommand.php b/src/Phaseolies/Console/Commands/Migrations/CreateMigrationCommand.php
index 947b8727..2b8b6b68 100644
--- a/src/Phaseolies/Console/Commands/Migrations/CreateMigrationCommand.php
+++ b/src/Phaseolies/Console/Commands/Migrations/CreateMigrationCommand.php
@@ -78,6 +78,6 @@ public function handle(): int
*/
protected function getMigrationPath(): string
{
- return base_path('database/migrations');
+ return base_path('schema/migrations');
}
}
diff --git a/src/Phaseolies/Console/Commands/Migrations/CreateSeedCommand.php b/src/Phaseolies/Console/Commands/Migrations/CreateSeedCommand.php
index 66a04001..3e161cec 100644
--- a/src/Phaseolies/Console/Commands/Migrations/CreateSeedCommand.php
+++ b/src/Phaseolies/Console/Commands/Migrations/CreateSeedCommand.php
@@ -29,7 +29,7 @@ public function handle(): int
{
return $this->executeWithTiming(function() {
$name = $this->argument('name');
- $filePath = base_path('database/seeders/' . $name . '.php');
+ $filePath = base_path('schema/seeders/' . $name . '.php');
if (file_exists($filePath)) {
$this->displayError('Seed file already exists!');
diff --git a/src/Phaseolies/Console/Commands/Migrations/DbWipeCommand.php b/src/Phaseolies/Console/Commands/Migrations/DbWipeCommand.php
new file mode 100644
index 00000000..4e203d41
--- /dev/null
+++ b/src/Phaseolies/Console/Commands/Migrations/DbWipeCommand.php
@@ -0,0 +1,62 @@
+executeWithTiming(function () {
+ $connection = $this->resolveConnection();
+
+ if (!$this->confirmToProceed("This will drop all tables from database connection: {$connection}", true)) {
+ return Command::FAILURE;
+ }
+
+ try {
+ $database = DB::connection($connection);
+
+ $database->disableForeignKeyConstraints();
+
+ try {
+ $dropped = $database->dropAllTables();
+ } finally {
+ $database->enableForeignKeyConstraints();
+ }
+ } catch (\Throwable $e) {
+ $this->displayError("Failed to wipe database [{$connection}]: {$e->getMessage()}");
+
+ return Command::FAILURE;
+ }
+
+ $this->displaySuccess("Dropped {$dropped} " . ($dropped === 1 ? 'table' : 'tables') . " from {$connection}");
+
+ return Command::SUCCESS;
+ });
+ }
+}
diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateCommand.php
index 7c9e243b..ee67f47e 100644
--- a/src/Phaseolies/Console/Commands/Migrations/MigrateCommand.php
+++ b/src/Phaseolies/Console/Commands/Migrations/MigrateCommand.php
@@ -3,16 +3,19 @@
namespace Phaseolies\Console\Commands\Migrations;
use Phaseolies\Console\Schedule\Command;
+use Phaseolies\Console\Support\InteractsWithMigrations;
use Phaseolies\Database\Migration\Migrator;
class MigrateCommand extends Command
{
+ use InteractsWithMigrations;
+
/**
* The name and signature of the console command.
*
* @var string
*/
- protected $name = 'migrate {--path=} {--connection=}';
+ protected $name = 'migrate {--path=} {--connection=} {--step} {--pretend} {--force} {--seed}';
/**
* The description of the console command.
@@ -45,28 +48,38 @@ public function __construct()
public function handle(): int
{
return $this->executeWithTiming(function () {
- $connection = $this->option('connection') ?: config('database.default');
+ $connection = $this->resolveConnection();
+ $pretend = (bool) $this->option('pretend');
- $path = $this->option('path');
+ if (!$this->confirmToProceed('Running migrations in production')) {
+ return Command::FAILURE;
+ }
- $this->line("🔁 Running migrations on connection: {$connection}>");
+ $this->line("🔁 " . ($pretend ? 'Previewing' : 'Running') . " migrations on connection: {$connection}>");
$this->newLine();
- if ($path) {
- $migrations = $this->migrator->run($connection, $path);
- } else {
- $migrations = $this->migrator->run($connection);
+ try {
+ $migrations = $this->migrator->run($connection, $this->option('path') ?: null, [
+ 'step' => (bool) $this->option('step'),
+ 'pretend' => $pretend,
+ 'progress' => $this->progressReporter(),
+ ]);
+ } catch (\Throwable $e) {
+ $this->newLine();
+ $this->displayError($e->getMessage());
+
+ return Command::FAILURE;
}
if (empty($migrations)) {
- $this->newLine();
$this->displayInfo('Nothing to migrate');
- } else {
- $this->displaySuccess('Database migrated successfully');
- $this->line('📊 Migrations Executed:>');
- foreach ($migrations as $migration) {
- $this->line('- ' . $migration . '>');
- }
+ } elseif (!$pretend) {
+ $this->newLine();
+ $this->displaySuccess(count($migrations) . ' ' . (count($migrations) === 1 ? 'migration' : 'migrations') . ' executed');
+ }
+
+ if ($this->option('seed') && !$pretend) {
+ return $this->runSeeders();
}
return Command::SUCCESS;
diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateFreshCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateFreshCommand.php
new file mode 100644
index 00000000..539b4ea9
--- /dev/null
+++ b/src/Phaseolies/Console/Commands/Migrations/MigrateFreshCommand.php
@@ -0,0 +1,101 @@
+migrator = app('migrator');
+ }
+
+ /**
+ * Execute the console command.
+ *
+ * @return int
+ */
+ public function handle(): int
+ {
+ return $this->executeWithTiming(function () {
+ $connection = $this->resolveConnection();
+
+ if (!$this->confirmToProceed("This will drop all tables from database connection: {$connection}", true)) {
+ return Command::FAILURE;
+ }
+
+ $this->line("♻️ Refreshing database on connection: {$connection}>");
+
+ try {
+ $database = DB::connection($connection);
+
+ $database->disableForeignKeyConstraints();
+
+ try {
+ $tablesDropped = $database->dropAllTables();
+ } finally {
+ $database->enableForeignKeyConstraints();
+ }
+
+ $this->newLine();
+ $this->line("✔ Dropped {$tablesDropped} tables from {$connection}>");
+ } catch (\Throwable $e) {
+ $this->displayError("Failed to refresh database [{$connection}]: {$e->getMessage()}");
+ return Command::FAILURE;
+ }
+
+ $this->newLine();
+ $this->line('🔁 Running migrations>');
+ $this->newLine();
+
+ try {
+ $migrations = $this->migrator->run($connection, null, ['progress' => $this->progressReporter()]);
+ } catch (\Throwable $e) {
+ $this->newLine();
+ $this->displayError($e->getMessage());
+ return Command::FAILURE;
+ }
+
+ $this->newLine();
+ $this->displaySuccess('Database refresh completed (' . count($migrations) . ' executed)');
+
+ if ($this->option('seed')) {
+ return $this->runSeeders();
+ }
+
+ return Command::SUCCESS;
+ });
+ }
+}
diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateRefreshCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateRefreshCommand.php
index c8080b76..a8c8f402 100644
--- a/src/Phaseolies/Console/Commands/Migrations/MigrateRefreshCommand.php
+++ b/src/Phaseolies/Console/Commands/Migrations/MigrateRefreshCommand.php
@@ -3,25 +3,26 @@
namespace Phaseolies\Console\Commands\Migrations;
use Phaseolies\Console\Schedule\Command;
-use Phaseolies\Support\Facades\Schema;
-use Phaseolies\Support\Facades\DB;
+use Phaseolies\Console\Support\InteractsWithMigrations;
use Phaseolies\Database\Migration\Migrator;
class MigrateRefreshCommand extends Command
{
+ use InteractsWithMigrations;
+
/**
* The name and signature of the console command.
*
* @var string
*/
- protected $name = 'migrate:fresh {--connection=}';
+ protected $name = 'migrate:refresh {--connection=} {--step=} {--seed} {--force}';
/**
* The description of the console command.
*
* @var string
*/
- protected $description = 'Drop all tables and re-run all migrations for the specified or default connection';
+ protected $description = 'Roll back all migrations (or the last --step=N) and run them again';
/**
* The migrator instance.
@@ -36,7 +37,6 @@ class MigrateRefreshCommand extends Command
public function __construct()
{
parent::__construct();
-
$this->migrator = app('migrator');
}
@@ -48,43 +48,49 @@ public function __construct()
public function handle(): int
{
return $this->executeWithTiming(function () {
- $connection = $this->option('connection') ?: config('database.default');
+ $connection = $this->resolveConnection();
- $this->displayWarning("This will drop all tables from database connection: {$connection}>");
- $this->line('Are you sure you want to proceed? (yes/no) [no]');
- $response = trim(fgets(STDIN));
+ try {
+ $step = $this->integerOption('step');
+ } catch (\RuntimeException $e) {
+ $this->displayError($e->getMessage());
- if (strtolower($response) !== 'yes') {
- $this->displayInfo('Command cancelled');
- return Command::SUCCESS;
+ return Command::FAILURE;
}
- $this->newLine();
- $this->line("♻️ Refreshing database on connection: {$connection}>");
+ $scope = $step === null ? 'every migration' : "the last {$step} migration(s)";
- try {
- Schema::connection($connection)->disableForeignKeyConstraints();
+ if (!$this->confirmToProceed("This will roll back {$scope} on connection: {$connection} and run them again", true)) {
+ return Command::FAILURE;
+ }
- $tablesDropped = DB::connection($connection)->dropAllTables();
+ $progress = $this->progressReporter();
- Schema::connection($connection)->enableForeignKeyConstraints();
+ try {
+ $this->line("⏪ Rolling back on connection: {$connection}>");
+ $this->newLine();
+ $rolledBack = $step === null
+ ? $this->migrator->reset($connection, ['progress' => $progress])
+ : $this->migrator->rollback($connection, ['step' => $step, 'progress' => $progress]);
+
+ $this->newLine();
+ $this->line('🔁 Running migrations>');
$this->newLine();
- $this->line("✔ Dropped {$tablesDropped} tables from {$connection}>");
+
+ $migrated = $this->migrator->run($connection, null, ['progress' => $progress]);
} catch (\Throwable $e) {
- $this->displayError("Failed to refresh database [{$connection}]: {$e->getMessage()}");
+ $this->newLine();
+ $this->displayError($e->getMessage());
+
return Command::FAILURE;
}
$this->newLine();
- $this->line('🔁 Running migrations>');
- $this->newLine();
- $migrations = $this->migrator->run($connection);
+ $this->displaySuccess(sprintf('Refreshed (%d rolled back, %d executed)', count($rolledBack), count($migrated)));
- $this->displaySuccess('Database refresh completed');
- $this->line('📊 Migrations Executed:>');
- foreach ($migrations as $migration) {
- $this->line('- ' . $migration . '>');
+ if ($this->option('seed')) {
+ return $this->runSeeders();
}
return Command::SUCCESS;
diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateResetCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateResetCommand.php
new file mode 100644
index 00000000..01f7f47a
--- /dev/null
+++ b/src/Phaseolies/Console/Commands/Migrations/MigrateResetCommand.php
@@ -0,0 +1,83 @@
+migrator = app('migrator');
+ }
+
+ /**
+ * Execute the console command.
+ *
+ * @return int
+ */
+ public function handle(): int
+ {
+ return $this->executeWithTiming(function () {
+ $connection = $this->resolveConnection();
+ $pretend = (bool) $this->option('pretend');
+
+ if (!$this->confirmToProceed("This will roll back every migration on connection: {$connection}", true)) {
+ return Command::FAILURE;
+ }
+
+ $this->line("⏪ " . ($pretend ? 'Previewing reset' : 'Resetting migrations') . " on connection: {$connection}>");
+ $this->newLine();
+
+ try {
+ $rolledBack = $this->migrator->reset($connection, [
+ 'pretend' => $pretend,
+ 'progress' => $this->progressReporter(),
+ ]);
+ } catch (\Throwable $e) {
+ $this->newLine();
+ $this->displayError($e->getMessage());
+
+ return Command::FAILURE;
+ }
+
+ if (empty($rolledBack)) {
+ $this->displayInfo('Nothing to roll back');
+ } elseif (!$pretend) {
+ $this->newLine();
+ $this->displaySuccess(count($rolledBack) . ' ' . (count($rolledBack) === 1 ? 'migration' : 'migrations') . ' rolled back');
+ }
+
+ return Command::SUCCESS;
+ });
+ }
+}
diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateRollbackCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateRollbackCommand.php
new file mode 100644
index 00000000..155f8c6e
--- /dev/null
+++ b/src/Phaseolies/Console/Commands/Migrations/MigrateRollbackCommand.php
@@ -0,0 +1,105 @@
+migrator = app('migrator');
+ }
+
+ /**
+ * Execute the console command.
+ *
+ * @return int
+ */
+ public function handle(): int
+ {
+ return $this->executeWithTiming(function () {
+ $connection = $this->resolveConnection();
+ $pretend = (bool) $this->option('pretend');
+
+ try {
+ $step = $this->integerOption('step');
+ $batch = $this->integerOption('batch');
+ } catch (\RuntimeException $e) {
+ $this->displayError($e->getMessage());
+
+ return Command::FAILURE;
+ }
+
+ if ($step !== null && $batch !== null) {
+ $this->displayError('Use either --step or --batch, not both.');
+
+ return Command::FAILURE;
+ }
+
+ if (!$this->confirmToProceed('Rolling back migrations in production')) {
+ return Command::FAILURE;
+ }
+
+ $this->line("⏪ " . ($pretend ? 'Previewing rollback' : 'Rolling back migrations') . " on connection: {$connection}>");
+ $this->newLine();
+
+ try {
+ $options = ['pretend' => $pretend, 'progress' => $this->progressReporter()];
+
+ if ($step !== null) {
+ $options['step'] = $step;
+ }
+
+ if ($batch !== null) {
+ $options['batch'] = $batch;
+ }
+
+ $rolledBack = $this->migrator->rollback($connection, $options);
+ } catch (\Throwable $e) {
+ $this->newLine();
+ $this->displayError($e->getMessage());
+
+ return Command::FAILURE;
+ }
+
+ if (empty($rolledBack)) {
+ $this->displayInfo('Nothing to roll back');
+ } elseif (!$pretend) {
+ $this->newLine();
+ $this->displaySuccess(count($rolledBack) . ' ' . (count($rolledBack) === 1 ? 'migration' : 'migrations') . ' rolled back');
+ }
+
+ return Command::SUCCESS;
+ });
+ }
+}
diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateStatusCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateStatusCommand.php
new file mode 100644
index 00000000..d90c4dd9
--- /dev/null
+++ b/src/Phaseolies/Console/Commands/Migrations/MigrateStatusCommand.php
@@ -0,0 +1,131 @@
+migrator = app('migrator');
+ }
+
+ /**
+ * Execute the console command.
+ *
+ * @return int
+ */
+ public function handle(): int
+ {
+ $connection = $this->resolveConnection();
+
+ try {
+ $rows = $this->migrator->status($connection);
+ } catch (\Throwable $e) {
+ $this->displayError($e->getMessage());
+
+ return Command::FAILURE;
+ }
+
+ $pending = array_values(array_filter($rows, fn($row) => !$row['ran']));
+ $problems = array_values(array_filter($rows, fn($row) => $row['modified'] || $row['missing']));
+
+ // Both flags are meant for CI and deploy scripts, so the exit code carries the answer.
+ $exit = $this->option('pending') && $pending !== [] ? Command::FAILURE : Command::SUCCESS;
+
+ if ($this->option('json')) {
+ $this->output->writeln(json_encode([
+ 'connection' => $connection,
+ 'migrations' => $rows,
+ 'pending' => count($pending),
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+
+ return $exit;
+ }
+
+ $this->newLine();
+ $this->line("📊 Migration status on connection: {$connection}>");
+ $this->newLine();
+
+ if ($rows === []) {
+ $this->displayInfo('No migrations found');
+
+ return $exit;
+ }
+
+ $table = $this->createTable();
+ $table->setHeaders(['Migration', 'Status', 'Batch', 'Ran at', 'Time']);
+
+ foreach ($this->option('pending') ? $pending : $rows as $row) {
+ $table->addRow([
+ preg_replace('/\.php$/', '', $row['migration']),
+ $this->statusLabel($row),
+ $row['batch'] ?? '-',
+ $row['ran_at'] ?? '-',
+ $row['execution_time'] === null ? '-' : $row['execution_time'] . 'ms',
+ ]);
+ }
+
+ $table->render();
+ $this->newLine();
+
+ $this->line(sprintf(
+ ' %d ran>, %d pending>%s',
+ count($rows) - count($pending),
+ count($pending),
+ $problems === [] ? '' : sprintf(', %d need attention>', count($problems))
+ ));
+
+ if ($problems !== []) {
+ $this->line(' Modified = the file changed after it ran. Missing = it ran, but the file is gone.>');
+ }
+
+ $this->newLine();
+
+ return $exit;
+ }
+
+ /**
+ * @param array $row
+ * @return string
+ */
+ private function statusLabel(array $row): string
+ {
+ return match (true) {
+ $row['missing'] => 'Ran (file missing)>',
+ $row['modified'] => 'Ran (modified)>',
+ $row['ran'] => 'Ran>',
+ default => 'Pending>',
+ };
+ }
+}
diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php
index 4dc9eb68..c2325175 100644
--- a/src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php
+++ b/src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php
@@ -37,11 +37,12 @@ public function handle(): int
return $this->executeWithTiming(function () {
$connection = $this->option('connection') ?: null;
$dryRun = (bool) $this->option('show');
- $modelsPath = $this->option('path') ?: base_path('app/Models');
+ $modelsPath = $this->option('path') ?: base_path('src/Models');
$connectionLabel = $connection ?: 'per-model';
- $this->line("🕐 Temporal Migration — connection: {$connectionLabel}>");
+ $this->newLine();
+ $this->line(" Temporal migration> connection: {$connectionLabel}" . ($dryRun ? ' · dry run' : '') . '>');
$this->newLine();
$classes = $this->discoverTemporalModels($modelsPath);
@@ -51,15 +52,32 @@ public function handle(): int
return Command::SUCCESS;
}
+ $entries = [];
+
+ foreach ($classes as $class) {
+ $model = new $class();
+ $baseTable = $model->getTable();
+
+ $entries[] = [
+ 'class' => $class,
+ 'base' => $baseTable,
+ 'history' => TemporalManager::historyTable($baseTable, $class),
+ 'connection' => $this->resolveConnectionForModel($model, $connection),
+ ];
+ }
+
+ $classWidth = max(array_map(fn ($e) => strlen($e['class']), $entries));
+ $connectionWidth = max(array_map(fn ($e) => strlen($e['connection']), $entries));
+ $tableWidth = max(array_map(fn ($e) => strlen($e['base'] . ' → ' . $e['history']), $entries));
+
$created = 0;
$skipped = 0;
- foreach ($classes as $class) {
- $attr = TemporalManager::getAttribute($class);
- $model = new $class();
- $baseTable = $model->getTable();
- $historyTable = TemporalManager::historyTable($baseTable, $class);
- $modelConnection = $this->resolveConnectionForModel($model, $connection);
+ foreach ($entries as $entry) {
+ $class = $entry['class'];
+ $historyTable = $entry['history'];
+ $modelConnection = $entry['connection'];
+ $attr = TemporalManager::getAttribute($class);
try {
$pdo = Database::getPdoInstance($modelConnection);
@@ -71,47 +89,74 @@ public function handle(): int
return Command::FAILURE;
}
- $this->line(" Model> : {$class}>");
- $this->line(" Connection> : {$modelConnection}>");
- $this->line(" Base table> : {$baseTable}>");
- $this->line(" History table> : {$historyTable}>");
+ $row = fn (string $icon, string $color, string $status) => $this->line(
+ " {$icon}> " . str_pad($class, $classWidth) . '> '
+ . '' . str_pad($entry['base'] . ' → ' . $historyTable, $tableWidth) . '> '
+ . '' . str_pad($modelConnection, $connectionWidth) . "> {$status}>"
+ );
if (!$dryRun && $this->tableExists($pdo, $driver, $historyTable)) {
- // Table exists — check if we need to add the actor column
+ // Table exists — check if we need to add the actor and/or branch columns
+ $addedColumns = [];
+ $addedIndexes = [];
+
if ($attr?->trackActor && !$this->columnExists($pdo, $driver, $historyTable, 'actor')) {
- $sql = $this->addActorColumnSql($driver, $historyTable);
- $pdo->exec($sql);
- $this->line(' → Added missing `actor` column.>');
+ $pdo->exec($this->addActorColumnSql($driver, $historyTable));
+ $addedColumns[] = 'actor';
+ }
+
+ if ($attr !== null && $attr->branches && !$this->columnExists($pdo, $driver, $historyTable, 'branch')) {
+ $pdo->exec($this->addBranchColumnSql($driver, $historyTable));
+ $addedColumns[] = 'branch';
+ }
+
+ $branchIndex = $driver === 'mysql' ? 'idx_branch' : 'idx_' . $historyTable . '_branch';
+ if ($attr !== null && $attr->branches && !$this->indexExists($pdo, $driver, $historyTable, $branchIndex)) {
+ $pdo->exec($this->createBranchIndexSql($driver, $historyTable));
+ $addedIndexes[] = 'branch';
+ }
+
+ if (!empty($addedColumns) || !empty($addedIndexes)) {
+ TemporalManager::resetCache();
+ $status = [];
+ if (!empty($addedColumns)) {
+ $status[] = implode(', ', $addedColumns) . ' column' . (count($addedColumns) > 1 ? 's' : '') . ' added';
+ }
+ if (!empty($addedIndexes)) {
+ $status[] = implode(', ', $addedIndexes) . ' index added';
+ }
+ $row('✓', 'green', implode('; ', $status));
$created++;
} else {
- $this->line(' → Already exists, skipped.>');
+ $row('•', 'yellow', 'already exists');
$skipped++;
}
- $this->newLine();
continue;
}
$statements = TemporalManager::createHistoryTableSql(
- historyTable: $historyTable,
- driver: $driver,
- trackActor: $attr?->trackActor ?? false,
+ historyTable: $historyTable,
+ driver: $driver,
+ trackActor: $attr?->trackActor ?? false,
+ trackBranches: $attr !== null && $attr->branches,
);
if ($dryRun) {
+ $row('›', 'yellow', 'would create');
foreach ($statements as $sql) {
- $this->line(' SQL:> ' . preg_replace('/\s+/', ' ', trim($sql)));
+ $this->line(' ' . preg_replace('/\s+/', ' ', trim($sql)) . '>');
}
} else {
foreach ($statements as $sql) {
$pdo->exec($sql);
}
- $this->line(' → Created successfully.>');
+ $row('✓', 'green', 'created');
$created++;
}
-
- $this->newLine();
}
+ $this->newLine();
+
if (!$dryRun) {
$this->displaySuccess(
"Done. Created: {$created}" . ($skipped ? ", Skipped (already exist): {$skipped}" : '')
@@ -250,7 +295,7 @@ private function columnExists(PDO $pdo, string $driver, string $table, string $c
if ($driver === 'pgsql') {
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM information_schema.columns
- WHERE table_name = ? AND column_name = ?"
+ WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?"
);
$stmt->execute([$table, $column]);
return (bool) $stmt->fetchColumn();
@@ -290,4 +335,67 @@ private function addActorColumnSql(string $driver, string $table): string
default => "ALTER TABLE `{$table}` ADD COLUMN `actor` VARCHAR(255) NULL",
};
}
+
+ /**
+ * Generate the ALTER TABLE SQL to add the branch column.
+ *
+ * @param string $driver
+ * @param string $table
+ * @return string
+ */
+ private function addBranchColumnSql(string $driver, string $table): string
+ {
+ return match ($driver) {
+ 'pgsql' => "ALTER TABLE \"{$table}\" ADD COLUMN branch VARCHAR(50) NOT NULL DEFAULT 'main'",
+ 'sqlite' => "ALTER TABLE \"{$table}\" ADD COLUMN branch TEXT NOT NULL DEFAULT 'main'",
+ default => "ALTER TABLE `{$table}` ADD COLUMN `branch` VARCHAR(50) NOT NULL DEFAULT 'main'",
+ };
+ }
+
+ /**
+ * Check whether the given index exists on the given table.
+ *
+ * @param PDO $pdo
+ * @param string $driver
+ * @param string $table
+ * @param string $index
+ * @return bool
+ */
+ private function indexExists(PDO $pdo, string $driver, string $table, string $index): bool
+ {
+ try {
+ if ($driver === 'sqlite') {
+ foreach ($pdo->query("PRAGMA index_list(\"{$table}\")")->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ if ($row['name'] === $index) return true;
+ }
+ return false;
+ }
+ if ($driver === 'pgsql') {
+ $stmt = $pdo->prepare('SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND tablename = ? AND indexname = ?');
+ $stmt->execute([$table, $index]);
+ return $stmt->fetchColumn() !== false;
+ }
+ $stmt = $pdo->prepare("SHOW INDEX FROM `{$table}` WHERE Key_name = ?");
+ $stmt->execute([$index]);
+ return $stmt->fetch() !== false;
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * Generate the CREATE INDEX SQL for the branch lookup index.
+ *
+ * @param string $driver
+ * @param string $table
+ * @return string
+ */
+ private function createBranchIndexSql(string $driver, string $table): string
+ {
+ $name = $driver === 'mysql' ? 'idx_branch' : 'idx_' . $table . '_branch';
+ return match ($driver) {
+ 'pgsql', 'sqlite' => "CREATE INDEX \"{$name}\" ON \"{$table}\" (record_id, branch, valid_from)",
+ default => "CREATE INDEX `{$name}` ON `{$table}` (record_id, branch, valid_from)",
+ };
+ }
}
diff --git a/src/Phaseolies/Console/Commands/PaginationPublishCommand.php b/src/Phaseolies/Console/Commands/PaginationPublishCommand.php
index b4f64c88..29e8f57c 100644
--- a/src/Phaseolies/Console/Commands/PaginationPublishCommand.php
+++ b/src/Phaseolies/Console/Commands/PaginationPublishCommand.php
@@ -18,7 +18,7 @@ class PaginationPublishCommand extends Command
*
* @var string
*/
- protected $description = 'Publish pagination views to resources/views/vendor/pagination';
+ protected $description = 'Publish pagination views to templates/views/vendor/pagination';
/**
* Execute the console command.
@@ -27,8 +27,8 @@ class PaginationPublishCommand extends Command
*/
public function handle(): int
{
- return $this->executeWithTiming(function() {
- $paginationPath = resource_path('views/vendor/pagination');
+ return $this->executeWithTiming(function () {
+ $paginationPath = template_path('views/vendor/pagination');
if (!is_dir($paginationPath)) {
if (!mkdir($paginationPath, 0755, true)) {
diff --git a/src/Phaseolies/Console/Commands/Presenter/BundleCommand.php b/src/Phaseolies/Console/Commands/Presenter/BundleCommand.php
index 51812a46..8dbe73ae 100644
--- a/src/Phaseolies/Console/Commands/Presenter/BundleCommand.php
+++ b/src/Phaseolies/Console/Commands/Presenter/BundleCommand.php
@@ -30,7 +30,7 @@ public function handle(): int
return $this->executeWithTiming(function () {
[$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name'));
$namespace = 'App\\Http\\Presenters' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Http/Presenters', $name);
+ $filePath = $this->generatedFilePath('src/Http/Presenters', $name);
if (file_exists($filePath)) {
$this->displayError('Bundle class already exists at:');
diff --git a/src/Phaseolies/Console/Commands/Presenter/PresenterCommand.php b/src/Phaseolies/Console/Commands/Presenter/PresenterCommand.php
index 873e874b..5fbfeb8a 100644
--- a/src/Phaseolies/Console/Commands/Presenter/PresenterCommand.php
+++ b/src/Phaseolies/Console/Commands/Presenter/PresenterCommand.php
@@ -30,7 +30,7 @@ public function handle(): int
return $this->executeWithTiming(function () {
[$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name'));
$namespace = 'App\\Http\\Presenters' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
- $filePath = $this->generatedFilePath('app/Http/Presenters', $name);
+ $filePath = $this->generatedFilePath('src/Http/Presenters', $name);
if (file_exists($filePath)) {
$this->displayError('Presenters already exists at:');
diff --git a/src/Phaseolies/Console/Commands/Server/ServerStartCommand.php b/src/Phaseolies/Console/Commands/Server/ServerStartCommand.php
index 2c515d15..2504d52a 100644
--- a/src/Phaseolies/Console/Commands/Server/ServerStartCommand.php
+++ b/src/Phaseolies/Console/Commands/Server/ServerStartCommand.php
@@ -3,6 +3,9 @@
namespace Phaseolies\Console\Commands\Server;
use Symfony\Component\Process\Process;
+use Symfony\Component\Console\Terminal;
+use Symfony\Component\Console\Formatter\OutputFormatter;
+use Phaseolies\Application;
use Phaseolies\Console\Schedule\Command;
class ServerStartCommand extends Command
@@ -25,6 +28,25 @@ class ServerStartCommand extends Command
protected const MAX_PORT_ATTEMPTS = 10;
+ /**
+ * Time each open connection was accepted (or last served), keyed by client address.
+ *
+ * @var array
+ */
+ private array $connections = [];
+
+ /**
+ * Partial output line waiting for its newline.
+ */
+ private string $pendingOutput = '';
+
+ /**
+ * Clients whose current request was already reported by the request logger.
+ *
+ * @var array
+ */
+ private array $reported = [];
+
/**
* Execute the console command.
*
@@ -43,7 +65,17 @@ public function handle(): int
$port = self::determineAvailablePort($port);
+ if (!$background) {
+ $this->displayBanner();
+ }
+
$this->displaySuccess("Server started on http://localhost:$port>");
+
+ if (!$background) {
+ $this->line(' Press Ctrl+C to stop the server>');
+ $this->newLine();
+ }
+
self::startServer($port, $background);
return Command::SUCCESS;
@@ -118,6 +150,8 @@ private function startServer(int $port, bool $background): void
// Foreground mode
$process = new Process([
'php',
+ '-d',
+ 'auto_prepend_file=' . __DIR__ . '/request_logger.stub',
'-S',
"localhost:$port",
'-t',
@@ -128,7 +162,138 @@ private function startServer(int $port, bool $background): void
$process->start();
$process->wait(function ($type, $buffer) {
- $this->displayInfo($buffer);
+ $this->handleServerOutput($buffer);
});
+
+ $this->handleServerOutput("\n");
+ }
+
+ /**
+ * Print the Doppar logo with the framework version.
+ *
+ * @return void
+ */
+ private function displayBanner(): void
+ {
+ $logo = [
+ ' ____ ',
+ ' / __ \\____ ____ ____ ____ ______',
+ ' / / / / __ \\/ __ \\/ __ \\/ __ `/ ___/',
+ ' / /_/ / /_/ / /_/ / /_/ / /_/ / / ',
+ '/_____/\\____/ .___/ .___/\\__,_/_/ ',
+ ' /_/ /_/ ',
+ ];
+
+ foreach ($logo as $row) {
+ $this->line('' . OutputFormatter::escape($row) . '>');
+ }
+
+ $this->line(str_repeat(' ', 26) . 'v' . Application::VERSION . '>');
+ $this->line('' . str_repeat('-', 48) . '>');
+ $this->newLine();
+ }
+
+ /**
+ * Split raw server output into lines and render each one.
+ *
+ * @param string $buffer
+ * @return void
+ */
+ private function handleServerOutput(string $buffer): void
+ {
+ $this->pendingOutput .= $buffer;
+
+ while (($pos = strpos($this->pendingOutput, "\n")) !== false) {
+ $line = trim(substr($this->pendingOutput, 0, $pos));
+ $this->pendingOutput = substr($this->pendingOutput, $pos + 1);
+
+ if ($line !== '') {
+ $this->renderServerLine($line);
+ }
+ }
+ }
+
+ /**
+ * Render a single PHP built-in server log line.
+ *
+ * @param string $line
+ * @return void
+ */
+ private function renderServerLine(string $line): void
+ {
+ $now = microtime(true);
+
+ if (str_starts_with($line, "DOPPAR_REQUEST\t")) {
+ [, $client, $status, $method, $uri, $ms] = explode("\t", $line) + array_fill(0, 6, '');
+ $this->reported[$client] = true;
+ $this->printRequest((int) $status, $method, $uri, (float) $ms);
+ return;
+ }
+
+ if (!preg_match('/^\[[^\]]+\]\s+(\S+)\s+(Accepted|Closing|\[(\d{3})\]:\s+(\S+)\s+(.*))$/', $line, $m)) {
+ $this->line(' ' . OutputFormatter::escape($line) . '>');
+ return;
+ }
+
+ [, $client, $event] = $m;
+
+ if ($event === 'Accepted') {
+ $this->connections[$client] = $now;
+ return;
+ }
+
+ if ($event === 'Closing') {
+ unset($this->connections[$client], $this->reported[$client]);
+ return;
+ }
+
+ if (isset($this->reported[$client])) {
+ return;
+ }
+
+ $status = (int) $m[3];
+ $method = $m[4];
+ $uri = $m[5];
+ $elapsed = $now - ($this->connections[$client] ?? $now);
+ $this->connections[$client] = $now;
+
+ $this->printRequest($status, $method, $uri, $elapsed * 1000);
+ }
+
+ /**
+ * Print one request line: status and path on the left, duration on the right.
+ *
+ * @param int $status
+ * @param string $method
+ * @param string $uri
+ * @param float $milliseconds
+ * @return void
+ */
+ private function printRequest(int $status, string $method, string $uri, float $milliseconds): void
+ {
+ $color = match (true) {
+ $status >= 500 => 'red',
+ $status >= 400 => 'yellow',
+ $status >= 300 => 'cyan',
+ default => 'green',
+ };
+
+ $duration = $milliseconds >= 1000
+ ? sprintf('%.2fs', $milliseconds / 1000)
+ : sprintf('%.2fms', $milliseconds);
+
+ $left = sprintf('%s %s', $method, $uri);
+ $plainLeft = sprintf(' %s %s', $status, $left);
+ $dots = max(1, (new Terminal())->getWidth() - mb_strlen($plainLeft) - mb_strlen($duration) - 4);
+
+ $this->line(sprintf(
+ ' %s> %s> %s> %s>',
+ $color,
+ $status,
+ OutputFormatter::escape($left),
+ str_repeat('.', $dots),
+ $color,
+ $duration
+ ));
}
}
diff --git a/src/Phaseolies/Console/Commands/Server/request_logger.stub b/src/Phaseolies/Console/Commands/Server/request_logger.stub
new file mode 100644
index 00000000..fc359fc3
--- /dev/null
+++ b/src/Phaseolies/Console/Commands/Server/request_logger.stub
@@ -0,0 +1,33 @@
+executeWithTiming(function () {
- $provider = $this->option('provider');
- $tag = $this->option('tag');
- $force = $this->option('force');
+ $this->published = $this->skipped = $this->failed = 0;
- if ($provider) {
- $this->publishProvider($provider, $force);
- return Command::SUCCESS;
+ $launcher = $this->option('launcher');
+ $tags = $this->parseTags($this->option('tag'));
+ $force = (bool) $this->option('force');
+
+ if ($launcher || !empty($tags)) {
+ return $this->publishSelection($launcher ?: null, $tags, $force);
}
- if ($tag) {
- $this->publishTag($tag, $force);
- return Command::SUCCESS;
+ return $this->publishInteractively($force);
+ });
+ }
+
+ /**
+ * Split the --tag value into a list of unique tag names.
+ *
+ * Accepts a single tag or a comma separated list: --tag=a,b
+ *
+ * @param mixed $value
+ * @return array
+ */
+ protected function parseTags(mixed $value): array
+ {
+ $tags = [];
+
+ foreach ((array) $value as $item) {
+ foreach (explode(',', (string) $item) as $tag) {
+ $tag = trim($tag);
+
+ if ($tag !== '') {
+ $tags[$tag] = $tag;
+ }
}
+ }
+
+ return array_values($tags);
+ }
- $this->publishAll($force);
+ /**
+ * Prompt the user to choose which registered launcher to publish,
+ * falling back to publishing everything in non-interactive environments.
+ *
+ * @param bool $force
+ * @return int
+ */
+ protected function publishInteractively(bool $force): int
+ {
+ $launchers = $this->publishableLaunchers();
+ if (empty($launchers)) {
+ $this->displayWarning('No publishable launchers found.');
return Command::SUCCESS;
- });
+ }
+
+ if (!$this->isInteractive()) {
+ return $this->publishAll($force);
+ }
+
+ $choices = array_merge(['All'], $launchers);
+
+ $choice = $this->choice('Which launcher would you like to publish?', $choices, 'All');
+
+ if ($choice === 'All') {
+ return $this->publishAll($force);
+ }
+
+ return $this->publishSelection($choice, [], $force);
}
- protected function publishProvider(string $provider, bool $force = false)
+ /**
+ * Get the class names of registered launchers that have publishable paths.
+ *
+ * @return array
+ */
+ protected function publishableLaunchers(): array
{
- $providerClass = $this->app->getProvider($provider);
+ $launchers = [];
- if (!$providerClass) {
- $this->displayError("Unable to locate provider: {$provider}");
- return;
+ foreach ($this->app->getLaunchers() as $launcherInstance) {
+ if (!empty($launcherInstance->pathsToPublish())) {
+ $launchers[] = get_class($launcherInstance);
+ }
}
- $paths = $providerClass->pathsToPublish($provider);
- $this->publishPaths($paths, $force);
-
- $this->newLine();
- $this->displaySuccess("Published assets for provider: {$provider}");
+ return $launchers;
}
- protected function publishTag(string $tag, bool $force = false)
+ /**
+ * Publish what the --launcher and/or --tag options select.
+ *
+ * @param string|null $launcher
+ * @param array $tags
+ * @param bool $force
+ * @return int
+ */
+ protected function publishSelection(?string $launcher, array $tags, bool $force = false): int
{
- $paths = [];
+ $launchers = $this->app->getLaunchers();
- foreach ($this->app->getProviders() as $provider) {
- $providerPaths = $provider->pathsToPublish(null, $tag);
- if (!empty($providerPaths)) {
- $paths = array_merge($paths, $providerPaths);
+ if ($launcher !== null) {
+ $instance = $this->app->getLauncher($launcher);
+
+ if (!$instance) {
+ $this->displayError("Unable to locate launcher: {$launcher}");
+ $this->listPublishableLaunchers();
+
+ return Command::FAILURE;
}
+
+ $launchers = [$instance];
}
- if (empty($paths)) {
- $this->displayError("Unable to locate tag: {$tag}");
- return;
+ $batches = [];
+
+ if (empty($tags)) {
+ $batches[] = ["launcher [{$launcher}]", $launchers[0]->pathsToPublish()];
+ } else {
+ $missing = [];
+
+ foreach ($tags as $tag) {
+ $paths = [];
+
+ foreach ($launchers as $candidate) {
+ $paths = array_merge($paths, $candidate->pathsToPublish(null, $tag));
+ }
+
+ if (empty($paths)) {
+ $missing[] = $tag;
+ continue;
+ }
+
+ $batches[] = ["tag [{$tag}]", $paths];
+ }
+
+ if (!empty($missing)) {
+ $this->reportUnknownTags($missing, $launchers, $launcher);
+
+ return Command::FAILURE;
+ }
}
- $this->publishPaths($paths, $force);
+ $batches = array_values(array_filter($batches, fn(array $batch) => !empty($batch[1])));
- $this->newLine();
- $this->displaySuccess("Published assets for tag: {$tag}");
+ if (empty($batches)) {
+ $this->displayWarning("Launcher [{$launcher}] has nothing to publish.");
+
+ return Command::SUCCESS;
+ }
+
+ $this->publishBatches($batches, $force);
+
+ $scope = count($batches) === 1
+ ? 'for ' . $batches[0][0]
+ : 'for ' . count($batches) . ' tags';
+
+ return $this->summarize($scope);
}
- protected function publishAll(bool $force = false)
+ /**
+ * Publish every launcher's registered paths.
+ *
+ * @param bool $force
+ * @return int
+ */
+ protected function publishAll(bool $force = false): int
{
- $publishedCount = 0;
- foreach ($this->app->getProviders() as $provider) {
- $paths = $provider->pathsToPublish();
+ $batches = [];
+
+ foreach ($this->app->getLaunchers() as $launcher) {
+ $paths = $launcher->pathsToPublish();
+
if (!empty($paths)) {
- $this->publishPaths($paths, $force);
- $publishedCount++;
+ $batches[] = ['launcher [' . get_class($launcher) . ']', $paths];
+ }
+ }
+
+ $this->publishBatches($batches, $force);
+
+ $count = count($batches);
+
+ return $this->summarize('from ' . $count . ' ' . ($count === 1 ? 'launcher' : 'launchers'));
+ }
+
+ /**
+ * Publish each labelled group of paths under its own heading.
+ *
+ * @param array $batches
+ * @param bool $force
+ * @return void
+ */
+ protected function publishBatches(array $batches, bool $force = false): void
+ {
+ foreach ($batches as [$label, $paths]) {
+ $this->line('Publishing ' . OutputFormatter::escape($label) . '>');
+ $this->publishPaths($paths, $force);
+ $this->newLine();
+ }
+ }
+
+ /**
+ * Print the final result and return the matching exit code.
+ *
+ * @param string $scope
+ * @return int
+ */
+ protected function summarize(string $scope): int
+ {
+ if ($this->failed > 0) {
+ $this->displayError($this->countOf($this->failed) . " could not be published {$scope}.");
+
+ return Command::FAILURE;
+ }
+
+ if ($this->published > 0) {
+ $message = 'Published ' . $this->countOf($this->published) . " {$scope}.";
+
+ if ($this->skipped > 0) {
+ $message .= " Skipped {$this->skipped} existing (use --force to overwrite).";
+ }
+
+ $this->displaySuccess($message);
+
+ return Command::SUCCESS;
+ }
+
+ if ($this->skipped > 0) {
+ $this->displayInfo(
+ "Nothing published {$scope}: {$this->skipped} existing "
+ . ($this->skipped === 1 ? 'file was' : 'files were')
+ . ' left as is (use --force to overwrite).'
+ );
+
+ return Command::SUCCESS;
+ }
+
+ $this->displayInfo("Nothing to publish {$scope}.");
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * Explain that a tag is unknown and point at the tags that do exist.
+ *
+ * @param array $missing
+ * @param array $launchers
+ * @param string|null $launcher
+ * @return void
+ */
+ protected function reportUnknownTags(array $missing, array $launchers, ?string $launcher): void
+ {
+ $available = [];
+
+ foreach ($launchers as $candidate) {
+ $available = array_merge($available, $candidate->publishTags());
+ }
+
+ $available = array_values(array_unique($available));
+ sort($available);
+
+ foreach ($missing as $tag) {
+ $where = $launcher !== null ? " in launcher [{$launcher}]" : '';
+
+ $this->displayError("No publishable resources found for tag [{$tag}]{$where}.");
+
+ $similar = array_values(array_filter(
+ $available,
+ fn(string $name) => str_contains($name, $tag) || levenshtein($name, $tag) <= 3
+ ));
+
+ if (!empty($similar)) {
+ $this->line('Did you mean: ' . implode('>, ', array_map(
+ [OutputFormatter::class, 'escape'],
+ $similar
+ )) . '>?');
+ $this->newLine();
}
}
+ if (!empty($available)) {
+ $this->line('Available tags:>');
+
+ foreach ($available as $name) {
+ $this->line(' ' . OutputFormatter::escape($name));
+ }
+
+ $this->newLine();
+ }
+ }
+
+ /**
+ * List the launchers that have something to publish.
+ *
+ * @return void
+ */
+ protected function listPublishableLaunchers(): void
+ {
+ $launchers = $this->publishableLaunchers();
+
+ if (empty($launchers)) {
+ return;
+ }
+
+ $this->line('Publishable launchers:>');
+
+ foreach ($launchers as $name) {
+ $this->line(' ' . OutputFormatter::escape($name));
+ }
+
$this->newLine();
- $this->displaySuccess("Published assets from {$publishedCount} providers");
}
protected function publishPaths(array $paths, bool $force = false)
@@ -127,8 +388,11 @@ protected function publishPaths(array $paths, bool $force = false)
protected function publishDirectory(string $from, string $to, bool $force = false)
{
- if (!is_dir($to)) {
- mkdir($to, 0755, true);
+ if (!is_dir($to) && !@mkdir($to, 0755, true) && !is_dir($to)) {
+ $this->report('FAILED', 'red', "cannot create directory {$this->displayPath($to)}");
+ $this->failed++;
+
+ return;
}
$iterator = new RecursiveIteratorIterator(
@@ -140,29 +404,96 @@ protected function publishDirectory(string $from, string $to, bool $force = fals
$target = $to . DIRECTORY_SEPARATOR . $iterator->getSubPathName();
if ($item->isDir()) {
- if (!is_dir($target)) {
- mkdir($target, 0755);
+ if (!is_dir($target) && !@mkdir($target, 0755, true) && !is_dir($target)) {
+ $this->report('FAILED', 'red', "cannot create directory {$this->displayPath($target)}");
+ $this->failed++;
}
} else {
$this->publishFile($item->getPathname(), $target, $force);
}
}
- $this->newLine();
}
protected function publishFile(string $from, string $to, bool $force = false)
{
- if (file_exists($to) && !$force) {
- $this->displayWarning("Skipping: File already exists at {$to}");
+ if (!is_file($from)) {
+ $this->report('MISSING', 'red', "source file not found: {$this->displayPath($from)}");
+ $this->failed++;
+
+ return;
+ }
+
+ $exists = file_exists($to);
+
+ if ($exists && !$force) {
+ $this->report('SKIPPED', 'yellow', $this->displayPath($to) . ' already exists>');
+ $this->skipped++;
+
return;
}
$directory = dirname($to);
- if (!is_dir($directory)) {
- mkdir($directory, 0755, true);
+
+ if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) {
+ $this->report('FAILED', 'red', "cannot create directory {$this->displayPath($directory)}");
+ $this->failed++;
+
+ return;
+ }
+
+ if (!@copy($from, $to)) {
+ $this->report('FAILED', 'red', "cannot write {$this->displayPath($to)}");
+ $this->failed++;
+
+ return;
}
- copy($from, $to);
- $this->line("→ {$to}");
+ $this->report(
+ $exists ? 'OVERWRITTEN' : 'COPIED',
+ 'green',
+ $this->displayPath($from) . ' →> ' . $this->displayPath($to)
+ );
+ $this->published++;
+ }
+
+ /**
+ * Write one aligned status line for a file.
+ *
+ * @param string $label
+ * @param string $color
+ * @param string $message
+ * @return void
+ */
+ protected function report(string $label, string $color, string $message): void
+ {
+ $this->line(sprintf(' %s> %s', $color, str_pad($label, 11), $message));
+ }
+
+ /**
+ * Show a path relative to the application root and safe for console markup.
+ *
+ * @param string $path
+ * @return string
+ */
+ protected function displayPath(string $path): string
+ {
+ $base = rtrim(base_path(), '/\\') . DIRECTORY_SEPARATOR;
+
+ if (str_starts_with($path, $base)) {
+ $path = substr($path, strlen($base));
+ }
+
+ return OutputFormatter::escape($path);
+ }
+
+ /**
+ * Format a file count, e.g. "1 file" or "3 files".
+ *
+ * @param int $count
+ * @return string
+ */
+ protected function countOf(int $count): string
+ {
+ return $count . ' ' . ($count === 1 ? 'file' : 'files');
}
}
diff --git a/src/Phaseolies/Console/Commands/ViewCacheCommand.php b/src/Phaseolies/Console/Commands/ViewCacheCommand.php
index 376970ee..d1f5a0b6 100644
--- a/src/Phaseolies/Console/Commands/ViewCacheCommand.php
+++ b/src/Phaseolies/Console/Commands/ViewCacheCommand.php
@@ -37,7 +37,7 @@ class ViewCacheCommand extends Command
public function handle(): int
{
return $this->executeWithTiming(function() {
- $viewPath = base_path('resources/views');
+ $viewPath = base_path('templates/views');
$cachePath = base_path('storage/framework/views');
// Validate directories
@@ -77,7 +77,7 @@ protected function compileViews(array $viewFiles): array
$results = ['success' => [], 'failed' => []];
foreach ($viewFiles as $file) {
- $relativePath = $this->relativePath($file, base_path('resources/views'));
+ $relativePath = $this->relativePath($file, base_path('templates/views'));
$viewName = str_replace(['/', '.odo.php'], ['.', ''], $relativePath);
try {
diff --git a/src/Phaseolies/Console/Commands/stubs/auth/HomeController.stub b/src/Phaseolies/Console/Commands/stubs/auth/HomeController.stub
index d54dd5f3..15c9daf3 100644
--- a/src/Phaseolies/Console/Commands/stubs/auth/HomeController.stub
+++ b/src/Phaseolies/Console/Commands/stubs/auth/HomeController.stub
@@ -3,8 +3,8 @@
namespace App\Http\Controllers;
use Phaseolies\Http\Response;
-use Phaseolies\Utilities\Attributes\Route;
-use Phaseolies\Utilities\Attributes\Mapper;
+use Phaseolies\Support\Router\Attributes\Route;
+use Phaseolies\Support\Router\Attributes\Mapper;
use App\Http\Controllers\Controller;
#[Mapper(prefix: 'home', middleware: ['auth'])]
diff --git a/src/Phaseolies/Console/Commands/stubs/auth/LoginController.stub b/src/Phaseolies/Console/Commands/stubs/auth/LoginController.stub
index 9a248037..7c3926cb 100644
--- a/src/Phaseolies/Console/Commands/stubs/auth/LoginController.stub
+++ b/src/Phaseolies/Console/Commands/stubs/auth/LoginController.stub
@@ -5,8 +5,8 @@ namespace App\Http\Controllers\Auth;
use Phaseolies\Http\Request;
use Phaseolies\Http\Response;
use Phaseolies\Support\Facades\Auth;
-use Phaseolies\Utilities\Attributes\Route;
-use Phaseolies\Utilities\Attributes\Mapper;
+use Phaseolies\Support\Router\Attributes\Route;
+use Phaseolies\Support\Router\Attributes\Mapper;
use Phaseolies\Http\Response\RedirectResponse;
use App\Models\User;
use App\Http\Controllers\Controller;
diff --git a/src/Phaseolies/Console/Commands/stubs/auth/ProfileController.stub b/src/Phaseolies/Console/Commands/stubs/auth/ProfileController.stub
index cf0239e6..bf59e8f4 100644
--- a/src/Phaseolies/Console/Commands/stubs/auth/ProfileController.stub
+++ b/src/Phaseolies/Console/Commands/stubs/auth/ProfileController.stub
@@ -4,9 +4,9 @@ namespace App\Http\Controllers;
use Phaseolies\Support\Facades\Hash;
use Phaseolies\Support\Facades\Auth;
-use Phaseolies\Utilities\Attributes\Middleware;
+use Phaseolies\Middleware\Attributes\Middleware;
use Phaseolies\Http\Response\RedirectResponse;
-use Phaseolies\Utilities\Attributes\Route;
+use Phaseolies\Support\Router\Attributes\Route;
use Phaseolies\Http\Response;
use Phaseolies\Http\Request;
use App\Http\Middleware\Authenticate;
diff --git a/src/Phaseolies/Console/Commands/stubs/auth/RegisterController.stub b/src/Phaseolies/Console/Commands/stubs/auth/RegisterController.stub
index 201b1f43..1192f5e3 100644
--- a/src/Phaseolies/Console/Commands/stubs/auth/RegisterController.stub
+++ b/src/Phaseolies/Console/Commands/stubs/auth/RegisterController.stub
@@ -5,8 +5,8 @@ namespace App\Http\Controllers\Auth;
use Phaseolies\Http\Request;
use Phaseolies\Http\Response;
use Phaseolies\Http\Response\RedirectResponse;
-use Phaseolies\Utilities\Attributes\Route;
-use Phaseolies\Utilities\Attributes\Mapper;
+use Phaseolies\Support\Router\Attributes\Route;
+use Phaseolies\Support\Router\Attributes\Mapper;
use App\Models\User;
use App\Http\Controllers\Controller;
@@ -51,4 +51,4 @@ class RegisterController extends Controller
return redirect('/login')->withSuccess('User created successfully');
}
-}
\ No newline at end of file
+}
diff --git a/src/Phaseolies/Console/Commands/stubs/auth/TwoFactorAuthController.stub b/src/Phaseolies/Console/Commands/stubs/auth/TwoFactorAuthController.stub
index 8b7f4948..817bd0dc 100644
--- a/src/Phaseolies/Console/Commands/stubs/auth/TwoFactorAuthController.stub
+++ b/src/Phaseolies/Console/Commands/stubs/auth/TwoFactorAuthController.stub
@@ -2,11 +2,11 @@
namespace App\Http\Controllers\Auth;
-use Phaseolies\Utilities\Attributes\Middleware;
+use Phaseolies\Middleware\Attributes\Middleware;
use Phaseolies\Support\Facades\Auth;
use Phaseolies\Http\Response;
use Phaseolies\Http\Request;
-use Phaseolies\Utilities\Attributes\Route;
+use Phaseolies\Support\Router\Attributes\Route;
use App\Http\Middleware\GuestMiddleware;
use App\Http\Middleware\VerifyTwoFactorUser;
use App\Http\Middleware\Authenticate;
diff --git a/src/Phaseolies/Console/Commands/stubs/controller/complete.stub b/src/Phaseolies/Console/Commands/stubs/controller/complete.stub
index ab9a0c23..19d4da33 100644
--- a/src/Phaseolies/Console/Commands/stubs/controller/complete.stub
+++ b/src/Phaseolies/Console/Commands/stubs/controller/complete.stub
@@ -3,7 +3,7 @@
namespace {{ namespace }};
use App\Http\Controllers\Controller;
-use Phaseolies\Utilities\Attributes\Route;
+use Phaseolies\Support\Router\Attributes\Route;
class {{ class }} extends Controller
{
@@ -16,4 +16,4 @@ class {{ class }} extends Controller
'routeName' => '{{ routeName }}'
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Phaseolies/Console/Commands/stubs/controller/layouts/complete.stub b/src/Phaseolies/Console/Commands/stubs/controller/layouts/complete.stub
index d37e125e..f7561a5b 100644
--- a/src/Phaseolies/Console/Commands/stubs/controller/layouts/complete.stub
+++ b/src/Phaseolies/Console/Commands/stubs/controller/layouts/complete.stub
@@ -20,7 +20,7 @@