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 @@

-

-Build Status -Total Downloads -Latest Stable Version -License -PHP Version -GitHub Stars -GitHub Forks -GitHub Issues -Contributors -Last Commit -Repo Size -

+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 @@

The following files were created for you:

  • Controller: [[ $controllerPath ]]
  • -
  • View: resources/views/[[ $routeName ]]/default.odo.php
  • +
  • View: templates/views/[[ $routeName ]]/default.odo.php
diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/components/react.jsx.stub b/src/Phaseolies/Console/Commands/stubs/frontend/components/react.jsx.stub index 640c2047..3de6a75a 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/components/react.jsx.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/components/react.jsx.stub @@ -2,38 +2,88 @@ const runtime = window.__DOPPAR_FRONTEND__ ?? {}; export default function App() { return ( -
-
- Logo -

Welcome to Doppar

-
-
-

{runtime.version ?? 'Doppar'}

-

Craft Fast-Loading PHP Application

-
-
- -

Starter kits

-

To give you a head start building your new Doppar application, we are happy to offer application starter kits.

-
- -

System Architecture

-

Doppar follows the Model-View-Controller (MVC) architectural pattern, a widely accepted standard in web application development.

-
- -

Routing

-

With support for route prefix grouping, named routes, throttle route, middleware assignment, and RESTful resource routing, Doppar gives developers full control over how requests are handled.

-
- -

Authentication

-

Doppar simplifies this process by providing built-in tools and scaffolding to help you implement user authentication quickly and securely.

-
-
-
- GitHub - Website +
+
+
+
Application ready
+ +

Let's build something.

+ +
+
+ +
01 · Learn
+ Read the documentation ↗ +
+
+ +
02 · Explore
+ See what's new in 4.x ↗ +
+
+ +
03 · Connect
+ Star the project on GitHub ↗ +
+
+ + Learn + +
+ Doppar + {runtime.version ?? 'Doppar'} · {runtime.phpVersion ?? ''} · + {' '} + Doppar News ↗ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + 4 + + + + + + + + + + + + + + + + + + + DOPPAR/4.X + + +
-
{runtime.phpVersion ?? ''}
-
+ ); } diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/components/react.tsx.stub b/src/Phaseolies/Console/Commands/stubs/frontend/components/react.tsx.stub index 9d08ac49..cc911a53 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/components/react.tsx.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/components/react.tsx.stub @@ -8,38 +8,88 @@ const runtime = (window as Window & { __DOPPAR_FRONTEND__?: DopparRuntime }).__D export default function App() { return ( -
-
- Logo -

Welcome to Doppar

-
-
-

{runtime.version ?? 'Doppar'}

-

Craft Fast-Loading PHP Application

-
-
- -

Starter kits

-

To give you a head start building your new Doppar application, we are happy to offer application starter kits.

-
- -

System Architecture

-

Doppar follows the Model-View-Controller (MVC) architectural pattern, a widely accepted standard in web application development.

-
- -

Routing

-

With support for route prefix grouping, named routes, throttle route, middleware assignment, and RESTful resource routing, Doppar gives developers full control over how requests are handled.

-
- -

Authentication

-

Doppar simplifies this process by providing built-in tools and scaffolding to help you implement user authentication quickly and securely.

-
-
-
- GitHub - Website +
+
+
+
Application ready
+ +

Let's build something.

+ +
+
+ +
01 · Learn
+ Read the documentation ↗ +
+
+ +
02 · Explore
+ See what's new in 4.x ↗ +
+
+ +
03 · Connect
+ Star the project on GitHub ↗ +
+
+ + Learn + +
+ Doppar + {runtime.version ?? 'Doppar'} · {runtime.phpVersion ?? ''} · + {' '} + Doppar News ↗ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + 4 + + + + + + + + + + + + + + + + + + + DOPPAR/4.X + + +
-
{runtime.phpVersion ?? ''}
-
+ ); } diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/components/svelte.stub b/src/Phaseolies/Console/Commands/stubs/frontend/components/svelte.stub index 99e9151f..d76bf03a 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/components/svelte.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/components/svelte.stub @@ -2,36 +2,85 @@ const runtime = window.__DOPPAR_FRONTEND__ ?? {}; -
-
- -

Welcome to Doppar

-
-
-

{runtime.version ?? 'Doppar'}

-

Craft Fast-Loading PHP Application

-
-
- -

Starter kits

-

To give you a head start building your new Doppar application, we are happy to offer application starter kits.

-
- -

System Architecture

-

Doppar follows the Model-View-Controller (MVC) architectural pattern, a widely accepted standard in web application development.

-
- -

Routing

-

With support for route prefix grouping, named routes, throttle route, middleware assignment, and RESTful resource routing, Doppar gives developers full control over how requests are handled.

-
- -

Authentication

-

Doppar simplifies this process by providing built-in tools and scaffolding to help you implement user authentication quickly and securely.

-
-
-
- GitHub - Website +
+
+
+
Application ready
+ +

Let's build something.

+ +
+
+ +
01 · Learn
+ Read the documentation ↗ +
+
+ +
02 · Explore
+ See what's new in 4.x ↗ +
+
+ +
03 · Connect
+ Star the project on GitHub ↗ +
+
+ + Learn + +
+ Doppar + {runtime.version ?? 'Doppar'} · {runtime.phpVersion ?? ''} · + Doppar News ↗ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + 4 + + + + + + + + + + + + + + + + + + + DOPPAR/4.X + + +
- -
+ diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/components/vue.stub b/src/Phaseolies/Console/Commands/stubs/frontend/components/vue.stub index b627cb77..468e399c 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/components/vue.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/components/vue.stub @@ -3,37 +3,86 @@ const runtime = window.__DOPPAR_FRONTEND__ ?? {}; diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/configs/tsconfig.stub b/src/Phaseolies/Console/Commands/stubs/frontend/configs/tsconfig.stub index 7491ad18..13fb1b13 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/configs/tsconfig.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/configs/tsconfig.stub @@ -12,8 +12,8 @@ {{ jsxOption }} "baseUrl": ".", "paths": { - "@/*": ["resources/client/js/*"] + "@/*": ["templates/client/js/*"] } }, - "include": ["resources/client/**/*.ts", "resources/client/**/*.tsx", "resources/client/**/*.vue", "resources/client/**/*.svelte"] + "include": ["templates/client/**/*.ts", "templates/client/**/*.tsx", "templates/client/**/*.vue", "templates/client/**/*.svelte"] } diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/configs/vite.stub b/src/Phaseolies/Console/Commands/stubs/frontend/configs/vite.stub index 40072f84..2cc73c21 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/configs/vite.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/configs/vite.stub @@ -46,8 +46,8 @@ export default defineConfig({ publicDir: false, resolve: { alias: { - '~client': path.resolve(__dirname, 'resources/client'), - '@': path.resolve(__dirname, 'resources/client/js'), + '~client': path.resolve(__dirname, 'templates/client'), + '@': path.resolve(__dirname, 'templates/client/js'), }, }, server: { diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/css/bootstrap.stub b/src/Phaseolies/Console/Commands/stubs/frontend/css/bootstrap.stub index 4e16b9cc..1e7290dc 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/css/bootstrap.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/css/bootstrap.stub @@ -1,11 +1,18 @@ @import "bootstrap/dist/css/bootstrap.min.css"; :root { - --primary: #10b981; - --background: #f9fafb; + --bg: #fbfbf9; --card-bg: #ffffff; - --text-color: #111827; - --border: #d1d5db; + --card-border: #ececea; + --text-color: #18181b; + --muted: #6b7280; + --line: #e7e5e4; + --emerald: #10b981; + --emerald-soft: #34d399; + --orange: #fb923c; + --violet: #807dfc; + --yellow: #facc15; + --panel-bg: #0a0a0a; color-scheme: light; } @@ -17,17 +24,16 @@ body { margin: 0; padding: 40px 20px; min-height: 100vh; - font-family: Poppins, "Segoe UI", sans-serif; - background: - radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 30%), - radial-gradient(circle at bottom right, rgba(14, 165, 233, 0.12), transparent 35%), - var(--background); + display: flex; + align-items: center; + justify-content: center; + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); color: var(--text-color); } a { color: inherit; - text-decoration: none; } code { @@ -37,103 +43,203 @@ code { font-family: "JetBrains Mono", monospace; } -.doppar-welcome-shell { - max-width: 960px; - margin: 0 auto; - text-align: center; +.doppar-welcome { + width: 100%; + max-width: 980px; } -.doppar-welcome-header { - margin-bottom: 20px; +.doppar-card { + display: grid; + grid-template-columns: 1.05fr 0.95fr; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 28px; + overflow: hidden; + box-shadow: 0 50px 100px -40px rgba(15, 23, 42, 0.18); } -.doppar-welcome-logo { - width: 48px; - height: auto; - margin-bottom: 10px; +.doppar-panel { + padding: 56px 52px; } -.doppar-welcome-title { - margin: 0; - font-size: 2rem; - font-weight: 700; +.doppar-eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: "JetBrains Mono", monospace; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--emerald); + margin-bottom: 18px; } -.doppar-cta-box { - margin: 0 auto 40px; - max-width: 600px; - width: 100%; - border: 2px solid var(--primary); - border-radius: 12px; - padding: 20px; - background: #ecfdf5; +.doppar-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--emerald); + box-shadow: 0 0 8px rgba(16, 185, 129, 0.7); } -.doppar-cta-box h3 { - margin: 0 0 10px; - font-size: 1.2rem; +.doppar-heading { + font-family: "Space Grotesk", sans-serif; + font-weight: 700; + font-size: 1.85rem; + line-height: 1.2; + margin: 0 0 30px; } -.doppar-cta-box p { - margin: 0; +.doppar-steps { + margin-bottom: 34px; } -.doppar-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 20px; - max-width: 800px; - margin: 0 auto 40px; +.doppar-step { + position: relative; + padding-left: 30px; + padding-bottom: 22px; } -.doppar-card { - background: var(--card-bg); - padding: 20px; - border: 1px solid var(--border); - border-radius: 12px; - text-align: left; - transition: box-shadow .2s; +.doppar-step:last-child { + padding-bottom: 0; } -.doppar-card:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, .05); +.doppar-step::before { + content: ""; + position: absolute; + left: 5px; + top: 20px; + bottom: -2px; + width: 1px; + background: var(--line); } -.doppar-card h3 { - margin: 0 0 8px; - font-size: 1rem; +.doppar-step:last-child::before { + display: none; } -.doppar-card p { - margin: 0; - font-size: 14px; - color: #4b5563; +.doppar-step-marker { + position: absolute; + left: 0; + top: 2px; + width: 11px; + height: 11px; + border-radius: 50%; + border: 1.5px solid var(--emerald-soft); + background: #fff; } -.doppar-btn-group { - margin-top: 20px; +.doppar-step-marker::after { + content: ""; + position: absolute; + inset: 2.5px; + border-radius: 50%; + background: var(--emerald-soft); } -.doppar-btn { - display: inline-block; - margin: 0 10px; - padding: 10px 18px; - background-color: #fff; - color: #111827; +.doppar-step-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #9ca3af; + margin-bottom: 4px; +} + +.doppar-step a { + font-size: 14.5px; + font-weight: 600; + color: var(--text-color); text-decoration: none; - border: 1px solid var(--border); - border-radius: 6px; - font-weight: 500; - transition: background .2s; + display: inline-flex; + align-items: center; + gap: 6px; } -.doppar-btn:hover { - background-color: #c4c9c8; +.doppar-step a:hover { + color: var(--emerald); } -.doppar-footer { - margin-top: 50px; +.doppar-arrow { + font-size: 12px; + color: var(--orange); +} + +.doppar-cta { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 13px 26px; + border-radius: 999px; + background: var(--yellow); + color: #171717; + text-decoration: none; font-size: 13px; + font-weight: 700; + letter-spacing: 0.01em; + box-shadow: 0 14px 30px -12px rgba(250, 204, 21, 0.65); + transition: transform .15s ease, box-shadow .15s ease; +} + +.doppar-cta:hover { + transform: translateY(-1px); + box-shadow: 0 18px 34px -12px rgba(250, 204, 21, 0.8); +} + +.doppar-meta { + margin-top: 30px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + font-family: "JetBrains Mono", monospace; + font-size: 12px; color: #9ca3af; - text-align: center; +} + +.doppar-meta img { + height: 14px; + width: auto; + margin-right: 6px; +} + +.doppar-meta a { + color: var(--muted); + text-decoration: none; + border-bottom: 1px solid var(--line); +} + +.doppar-meta a:hover { + color: var(--orange); + border-color: var(--orange); +} + +.doppar-art { + position: relative; + background: var(--panel-bg); + min-height: 460px; +} + +.doppar-art svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +@media (max-width: 860px) { + .doppar-card { + grid-template-columns: 1fr; + } + + .doppar-panel { + padding: 40px 28px; + order: 2; + } + + .doppar-art { + min-height: 200px; + order: 1; + } } diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/css/none.stub b/src/Phaseolies/Console/Commands/stubs/frontend/css/none.stub index c9827968..6bd0a9b3 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/css/none.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/css/none.stub @@ -1,9 +1,16 @@ :root { - --primary: #10b981; - --background: #f9fafb; + --bg: #fbfbf9; --card-bg: #ffffff; - --text-color: #111827; - --border: #d1d5db; + --card-border: #ececea; + --text-color: #18181b; + --muted: #6b7280; + --line: #e7e5e4; + --emerald: #10b981; + --emerald-soft: #34d399; + --orange: #fb923c; + --violet: #807dfc; + --yellow: #facc15; + --panel-bg: #0a0a0a; color-scheme: light; } @@ -15,17 +22,16 @@ body { margin: 0; padding: 40px 20px; min-height: 100vh; - font-family: Poppins, "Segoe UI", sans-serif; - background: - radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 30%), - radial-gradient(circle at bottom right, rgba(14, 165, 233, 0.12), transparent 35%), - var(--background); + display: flex; + align-items: center; + justify-content: center; + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); color: var(--text-color); } a { color: inherit; - text-decoration: none; } code { @@ -35,103 +41,203 @@ code { font-family: "JetBrains Mono", monospace; } -.doppar-welcome-shell { - max-width: 960px; - margin: 0 auto; - text-align: center; +.doppar-welcome { + width: 100%; + max-width: 980px; } -.doppar-welcome-header { - margin-bottom: 20px; +.doppar-card { + display: grid; + grid-template-columns: 1.05fr 0.95fr; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 28px; + overflow: hidden; + box-shadow: 0 50px 100px -40px rgba(15, 23, 42, 0.18); } -.doppar-welcome-logo { - width: 48px; - height: auto; - margin-bottom: 10px; +.doppar-panel { + padding: 56px 52px; } -.doppar-welcome-title { - margin: 0; - font-size: 2rem; - font-weight: 700; +.doppar-eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: "JetBrains Mono", monospace; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--emerald); + margin-bottom: 18px; } -.doppar-cta-box { - margin: 0 auto 40px; - max-width: 600px; - width: 100%; - border: 2px solid var(--primary); - border-radius: 12px; - padding: 20px; - background: #ecfdf5; +.doppar-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--emerald); + box-shadow: 0 0 8px rgba(16, 185, 129, 0.7); } -.doppar-cta-box h3 { - margin: 0 0 10px; - font-size: 1.2rem; +.doppar-heading { + font-family: "Space Grotesk", sans-serif; + font-weight: 700; + font-size: 1.85rem; + line-height: 1.2; + margin: 0 0 30px; } -.doppar-cta-box p { - margin: 0; +.doppar-steps { + margin-bottom: 34px; } -.doppar-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 20px; - max-width: 800px; - margin: 0 auto 40px; +.doppar-step { + position: relative; + padding-left: 30px; + padding-bottom: 22px; } -.doppar-card { - background: var(--card-bg); - padding: 20px; - border: 1px solid var(--border); - border-radius: 12px; - text-align: left; - transition: box-shadow .2s; +.doppar-step:last-child { + padding-bottom: 0; } -.doppar-card:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, .05); +.doppar-step::before { + content: ""; + position: absolute; + left: 5px; + top: 20px; + bottom: -2px; + width: 1px; + background: var(--line); } -.doppar-card h3 { - margin: 0 0 8px; - font-size: 1rem; +.doppar-step:last-child::before { + display: none; } -.doppar-card p { - margin: 0; - font-size: 14px; - color: #4b5563; +.doppar-step-marker { + position: absolute; + left: 0; + top: 2px; + width: 11px; + height: 11px; + border-radius: 50%; + border: 1.5px solid var(--emerald-soft); + background: #fff; } -.doppar-btn-group { - margin-top: 20px; +.doppar-step-marker::after { + content: ""; + position: absolute; + inset: 2.5px; + border-radius: 50%; + background: var(--emerald-soft); } -.doppar-btn { - display: inline-block; - margin: 0 10px; - padding: 10px 18px; - background-color: #fff; - color: #111827; +.doppar-step-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #9ca3af; + margin-bottom: 4px; +} + +.doppar-step a { + font-size: 14.5px; + font-weight: 600; + color: var(--text-color); text-decoration: none; - border: 1px solid var(--border); - border-radius: 6px; - font-weight: 500; - transition: background .2s; + display: inline-flex; + align-items: center; + gap: 6px; } -.doppar-btn:hover { - background-color: #c4c9c8; +.doppar-step a:hover { + color: var(--emerald); } -.doppar-footer { - margin-top: 50px; +.doppar-arrow { + font-size: 12px; + color: var(--orange); +} + +.doppar-cta { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 13px 26px; + border-radius: 999px; + background: var(--yellow); + color: #171717; + text-decoration: none; font-size: 13px; + font-weight: 700; + letter-spacing: 0.01em; + box-shadow: 0 14px 30px -12px rgba(250, 204, 21, 0.65); + transition: transform .15s ease, box-shadow .15s ease; +} + +.doppar-cta:hover { + transform: translateY(-1px); + box-shadow: 0 18px 34px -12px rgba(250, 204, 21, 0.8); +} + +.doppar-meta { + margin-top: 30px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + font-family: "JetBrains Mono", monospace; + font-size: 12px; color: #9ca3af; - text-align: center; +} + +.doppar-meta img { + height: 14px; + width: auto; + margin-right: 6px; +} + +.doppar-meta a { + color: var(--muted); + text-decoration: none; + border-bottom: 1px solid var(--line); +} + +.doppar-meta a:hover { + color: var(--orange); + border-color: var(--orange); +} + +.doppar-art { + position: relative; + background: var(--panel-bg); + min-height: 460px; +} + +.doppar-art svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +@media (max-width: 860px) { + .doppar-card { + grid-template-columns: 1fr; + } + + .doppar-panel { + padding: 40px 28px; + order: 2; + } + + .doppar-art { + min-height: 200px; + order: 1; + } } diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/css/tailwind.stub b/src/Phaseolies/Console/Commands/stubs/frontend/css/tailwind.stub index 6fee4dc9..7af62337 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/css/tailwind.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/css/tailwind.stub @@ -3,11 +3,18 @@ @source "../../views"; :root { - --primary: #10b981; - --background: #f9fafb; + --bg: #fbfbf9; --card-bg: #ffffff; - --text-color: #111827; - --border: #d1d5db; + --card-border: #ececea; + --text-color: #18181b; + --muted: #6b7280; + --line: #e7e5e4; + --emerald: #10b981; + --emerald-soft: #34d399; + --orange: #fb923c; + --violet: #807dfc; + --yellow: #facc15; + --panel-bg: #0a0a0a; color-scheme: light; } @@ -19,17 +26,16 @@ body { margin: 0; padding: 40px 20px; min-height: 100vh; - font-family: Poppins, "Segoe UI", sans-serif; - background: - radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 30%), - radial-gradient(circle at bottom right, rgba(14, 165, 233, 0.12), transparent 35%), - var(--background); + display: flex; + align-items: center; + justify-content: center; + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); color: var(--text-color); } a { color: inherit; - text-decoration: none; } code { @@ -39,103 +45,203 @@ code { font-family: "JetBrains Mono", monospace; } -.doppar-welcome-shell { - max-width: 960px; - margin: 0 auto; - text-align: center; +.doppar-welcome { + width: 100%; + max-width: 980px; } -.doppar-welcome-header { - margin-bottom: 20px; +.doppar-card { + display: grid; + grid-template-columns: 1.05fr 0.95fr; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 28px; + overflow: hidden; + box-shadow: 0 50px 100px -40px rgba(15, 23, 42, 0.18); } -.doppar-welcome-logo { - width: 48px; - height: auto; - margin-bottom: 10px; +.doppar-panel { + padding: 56px 52px; } -.doppar-welcome-title { - margin: 0; - font-size: 2rem; - font-weight: 700; +.doppar-eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: "JetBrains Mono", monospace; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--emerald); + margin-bottom: 18px; } -.doppar-cta-box { - margin: 0 auto 40px; - max-width: 600px; - width: 100%; - border: 2px solid var(--primary); - border-radius: 12px; - padding: 20px; - background: #ecfdf5; +.doppar-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--emerald); + box-shadow: 0 0 8px rgba(16, 185, 129, 0.7); } -.doppar-cta-box h3 { - margin: 0 0 10px; - font-size: 1.2rem; +.doppar-heading { + font-family: "Space Grotesk", sans-serif; + font-weight: 700; + font-size: 1.85rem; + line-height: 1.2; + margin: 0 0 30px; } -.doppar-cta-box p { - margin: 0; +.doppar-steps { + margin-bottom: 34px; } -.doppar-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 20px; - max-width: 800px; - margin: 0 auto 40px; +.doppar-step { + position: relative; + padding-left: 30px; + padding-bottom: 22px; } -.doppar-card { - background: var(--card-bg); - padding: 20px; - border: 1px solid var(--border); - border-radius: 12px; - text-align: left; - transition: box-shadow .2s; +.doppar-step:last-child { + padding-bottom: 0; } -.doppar-card:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, .05); +.doppar-step::before { + content: ""; + position: absolute; + left: 5px; + top: 20px; + bottom: -2px; + width: 1px; + background: var(--line); } -.doppar-card h3 { - margin: 0 0 8px; - font-size: 1rem; +.doppar-step:last-child::before { + display: none; } -.doppar-card p { - margin: 0; - font-size: 14px; - color: #4b5563; +.doppar-step-marker { + position: absolute; + left: 0; + top: 2px; + width: 11px; + height: 11px; + border-radius: 50%; + border: 1.5px solid var(--emerald-soft); + background: #fff; } -.doppar-btn-group { - margin-top: 20px; +.doppar-step-marker::after { + content: ""; + position: absolute; + inset: 2.5px; + border-radius: 50%; + background: var(--emerald-soft); } -.doppar-btn { - display: inline-block; - margin: 0 10px; - padding: 10px 18px; - background-color: #fff; - color: #111827; +.doppar-step-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #9ca3af; + margin-bottom: 4px; +} + +.doppar-step a { + font-size: 14.5px; + font-weight: 600; + color: var(--text-color); text-decoration: none; - border: 1px solid var(--border); - border-radius: 6px; - font-weight: 500; - transition: background .2s; + display: inline-flex; + align-items: center; + gap: 6px; } -.doppar-btn:hover { - background-color: #c4c9c8; +.doppar-step a:hover { + color: var(--emerald); } -.doppar-footer { - margin-top: 50px; +.doppar-arrow { + font-size: 12px; + color: var(--orange); +} + +.doppar-cta { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 13px 26px; + border-radius: 999px; + background: var(--yellow); + color: #171717; + text-decoration: none; font-size: 13px; + font-weight: 700; + letter-spacing: 0.01em; + box-shadow: 0 14px 30px -12px rgba(250, 204, 21, 0.65); + transition: transform .15s ease, box-shadow .15s ease; +} + +.doppar-cta:hover { + transform: translateY(-1px); + box-shadow: 0 18px 34px -12px rgba(250, 204, 21, 0.8); +} + +.doppar-meta { + margin-top: 30px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + font-family: "JetBrains Mono", monospace; + font-size: 12px; color: #9ca3af; - text-align: center; +} + +.doppar-meta img { + height: 14px; + width: auto; + margin-right: 6px; +} + +.doppar-meta a { + color: var(--muted); + text-decoration: none; + border-bottom: 1px solid var(--line); +} + +.doppar-meta a:hover { + color: var(--orange); + border-color: var(--orange); +} + +.doppar-art { + position: relative; + background: var(--panel-bg); + min-height: 460px; +} + +.doppar-art svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +@media (max-width: 860px) { + .doppar-card { + grid-template-columns: 1fr; + } + + .doppar-panel { + padding: 40px 28px; + order: 2; + } + + .doppar-art { + min-height: 200px; + order: 1; + } } diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/views/layouts/app.stub b/src/Phaseolies/Console/Commands/stubs/frontend/views/layouts/app.stub index 7710ccdc..48b1338a 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/views/layouts/app.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/views/layouts/app.stub @@ -5,7 +5,8 @@ #yield('title') - + + + [[ $status_code ]] · [[ $error_message ]] - + - - - + .frame-toggle { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + text-align: left; + background: none; + border: none; + padding: 13px 4px 13px 0; + min-height: 56px; + } - -
-
-
+ .frame-toggle:hover { + background: color-mix(in srgb, var(--ink) 3%, transparent); + } -
+ .frame-main { + flex: 1; + min-width: 0; + } -
-
- - [[ $exception_class ]] -
+ .frame-sig { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12.5px; + font-weight: 600; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } -
- - - -
-
- Doppar - [[ $doppar_version ]] -
-
- PHP - [[ $php_version ]] -
-
-
-
+ .frame-file { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + color: var(--muted); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } -

- [[ $error_message ]] -

-
-
+ .vendor-tag { + font-size: 9.5px; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 4px; + padding: 1px 5px; + flex: none; + } - -
-
-
- [[ $request_method ]] - [[ $request_url ]] - -
-
-
+ .chev { + width: 14px; + height: 14px; + color: var(--muted); + flex: none; + transition: transform .18s ease; + } - -
-
-
- -
- [[ $timestamp ]] -
-
- -
- - - [[ $status_code ]] - -
-
- -
- - [[ $request_method ]] - -
-
-
+ .frame-toggle[aria-expanded="true"] .chev, + .kv-toggle[aria-expanded="true"] .chev { + transform: rotate(180deg); + } - -
+ .frame-body { + display: none; + border-top: 1px solid var(--line); + background: var(--surface-2); + } - -
-
-
-
-
-
-
- [[ $error_file ]] - Line [[ $error_line ]] -
-
-
[[! $contents !]]
-
-
+ .frame-body .code { + background: transparent; + } - -
-
-
- - - -

Stack Trace

- -
- -
+ /* ============ DUAL COLUMN ============ */ + .dual-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + margin-bottom: 18px; + } -
- #include('trace-frames', ['traces' => $traces]) -
-
+ .dual-col .panel { + margin-bottom: 0; + } - -
- #include('template-headers', ['headers' => $headers]) -
+ @media (max-width: 760px) { + .dual-col { + grid-template-columns: 1fr; + } + } - -
-
-
- - - -
- Request Body - #if (!empty($request_body)) - - - - #endif -
- #if (!empty($request_body)) - - #else -
- - - -

Empty Request Body

-
- #endif -
+ .kv-toggle { + width: 100%; + background: none; + border: none; + text-align: left; + cursor: pointer; + padding: 0; + } - -
+ .kv-panel { + display: none; + } -
-
-
- - - -
-
-

Routing

-
-
-
+ .kv-list { + padding: 6px 16px 14px; + } -
+ .kv-row { + display: flex; + align-items: baseline; + gap: 8px; + padding: 6px 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + } - -
- -
- - Name - - - - [[ $current_route_name ?? 'unnamed_route' ]] - -
-
+ .kv-key { + color: var(--muted); + text-transform: uppercase; + letter-spacing: .03em; + font-size: 10.5px; + flex: none; + } - -
- + .kv-dots { + flex: 1; + border-bottom: 1px dashed var(--line); + margin-bottom: 4px; + min-width: 12px; + } -
- - Action - - + .kv-val { + color: var(--ink-soft); + text-align: right; + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } - #if(!empty($current_route_action)) - - [[ $current_route_action ]] - - #else - - Closure / No Action - - #endif -
-
+ .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 34px 16px; + color: var(--muted); + gap: 8px; + } - -
- + .empty-state svg { + width: 28px; + height: 28px; + opacity: .55; + } - #if (!empty($current_middleware)) - #foreach(($current_middleware ?? []) as $index => $mw) -
- - [[ $index + 1 ]] - - - - [[ $mw ]] - -
- #endforeach - #else -
-
- No middleware -
- #endif -
+ .empty-state span { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10.5px; + letter-spacing: .1em; + text-transform: uppercase; + } - -
- + .json-body { + margin: 0; + padding: 14px 16px; + overflow-x: auto; + font-size: 12px; + line-height: 1.6; + color: var(--ink-soft); + background: var(--surface-2); + } - #if (!empty($current_route_params)) - #foreach ($current_route_params as $key => $val) -
- - [[ $key ]] - - - - [[ $val ]] - -
- #endforeach - #else -
-
- No parameters -
- #endif -
+ /* ============ ROUTING ============ */ + .routing-grid { + padding: 18px 16px 4px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 18px 28px; + } + .field-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .09em; + color: var(--muted); + margin-bottom: 10px; + } + + .route-row { + display: flex; + align-items: baseline; + gap: 8px; + padding: 5px 0; + } + + .route-key { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10.5px; + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + flex: none; + } + + .route-dots { + flex: 1; + border-bottom: 1px dashed var(--line); + margin-bottom: 4px; + } + + .route-val { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + font-weight: 600; + color: var(--ink-soft); + word-break: break-all; + } + + .route-none { + font-size: 12px; + font-style: italic; + color: var(--muted); + padding-bottom: 14px; + } + + /* ============ INFO GRID ============ */ + .info-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; + margin-bottom: 18px; + } + + @media (max-width: 760px) { + .info-grid { + grid-template-columns: 1fr; + } + } + + .info-card { + padding: 16px; + } + + .info-card .field-label { + margin-bottom: 12px; + } + + .info-row { + margin-bottom: 12px; + } + + .info-row:last-child { + margin-bottom: 0; + } + + .info-row .k { + font-size: 10.5px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .04em; + margin-bottom: 2px; + } + + .info-row .v { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .foot { + text-align: center; + padding: 18px 0 36px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; + letter-spacing: .18em; + text-transform: uppercase; + color: var(--muted); + } + + + + +
+ +
+ [[ $status_code ]] +
+
+ DOPPAR [[ $doppar_version ]] + PHP [[ $php_version ]] +
+ + +
+
+ +
+

Uncaught — [[ $exception_class ]]

+

[[ $error_message ]]

+
+ [[ count($traces) ]] frames + [[ $timestamp ]]
+
+ +
+ [[ $request_method ]] + [[ $request_url ]] +
- -
- -
-
-
- - - -
- System +
+ +
+
+ + [[ $error_file ]] + Line [[ $error_line ]]
-
-
- -
[[ $server_software ]]
-
-
- -
[[ $platform ]]
+
[[! $contents !]]
+
+ + +
+
+

Stack Trace

+ [[ count($traces) ]] frames +
+ + +
-
- - -
-
-
- - - -
- Memory +
+ #include('trace-frames', ['traces' => $traces])
-
-
- -
[[ number_format($memory_usage / 1024 / 1024, 2) ]] MB
+ + + +
+
+ #include('template-headers', ['headers' => $headers]) +
+ +
+
+

Request Body

+ + #if (!empty($request_body)) + payload + #else + empty + #endif +
-
- -
[[ number_format($peack_memory_usage / 1024 / 1024, 2) ]] MB
+ #if (!empty($request_body)) +
[[ json_encode($request_body, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ]]
+ #else +
+
+ + No payload on this request +
-
+ #endif +
- -
-
-
- - - -
- User + +
+
+

Routing

- #if ($user_info) -
+
- -
[[ $user_info['id'] ]]
+
Route
+
Name[[ $current_route_name ?? 'unnamed_route' ]]
+
+ Action + #if (!empty($current_route_action)) + [[ $current_route_action ]] + #else + Closure + #endif +
- -
[[ $user_info['email'] ]]
+
Middleware ([[ count($current_middleware ?? []) ]])
+ #if (!empty($current_middleware)) + #foreach (($current_middleware ?? []) as $index => $mw) +
[[ $index + 1 ]][[ $mw ]]
+ #endforeach + #else +
No middleware
+ #endif +
+
+
Route Parameters
+ #if (!empty($current_route_params)) + #foreach ($current_route_params as $key => $val) +
[[ $key ]][[ $val ]]
+ #endforeach + #else +
No parameters
+ #endif
- #else -
- - - -

No User

-
- #endif +
+ + +
+
+
System
+
Server
[[ $server_software ]]
+
Platform
[[ $platform ]]
+
+
+
Memory
+
Current Usage
[[ number_format($memory_usage / 1024 / 1024, 2) ]] MB
+
Peak Usage
[[ number_format($peack_memory_usage / 1024 / 1024, 2) ]] MB
+
+
+
User
+ #if ($user_info) +
ID
[[ $user_info['id'] ]]
+
Email
[[ $user_info['email'] ]]
+ #else +
+ + No user +
+ #endif +
-
- - -
- Doppar Framework -
-
+
Doppar Framework · Request Diagnostic
+ +
- + - \ No newline at end of file + diff --git a/src/Phaseolies/Error/views/trace-frames.odo.php b/src/Phaseolies/Error/views/trace-frames.odo.php index bde91fe0..906386db 100644 --- a/src/Phaseolies/Error/views/trace-frames.odo.php +++ b/src/Phaseolies/Error/views/trace-frames.odo.php @@ -1,27 +1,24 @@ #if (empty($traces)) -
-

No stack trace available

+
+ No stack trace available
#else #foreach ($traces as $index => $trace) -
-