diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml index f23b92ea45b6..b21686c826dd 100644 --- a/.github/workflows/databases.yml +++ b/.github/workflows/databases.yml @@ -1,10 +1,15 @@ name: databases -on: [push, pull_request] +on: + push: + branches: + - master + - '*.x' + pull_request: jobs: mysql_57: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: mysql: @@ -23,7 +28,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -34,7 +39,7 @@ jobs: coverage: none - name: Install dependencies - uses: nick-invision/retry@v1 + uses: nick-fields/retry@v2 with: timeout_minutes: 5 max_attempts: 5 @@ -47,7 +52,7 @@ jobs: DB_USERNAME: root mysql_8: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: mysql: @@ -66,7 +71,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -77,7 +82,7 @@ jobs: coverage: none - name: Install dependencies - uses: nick-invision/retry@v1 + uses: nick-fields/retry@v2 with: timeout_minutes: 5 max_attempts: 5 @@ -90,7 +95,7 @@ jobs: DB_USERNAME: root mariadb: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: mysql: @@ -109,7 +114,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -120,7 +125,7 @@ jobs: coverage: none - name: Install dependencies - uses: nick-invision/retry@v1 + uses: nick-fields/retry@v2 with: timeout_minutes: 5 max_attempts: 5 @@ -133,7 +138,7 @@ jobs: DB_USERNAME: root pgsql: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: postgresql: @@ -153,7 +158,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -164,7 +169,7 @@ jobs: coverage: none - name: Install dependencies - uses: nick-invision/retry@v1 + uses: nick-fields/retry@v2 with: timeout_minutes: 5 max_attempts: 5 @@ -195,25 +200,25 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 8.1 - extensions: dom, curl, libxml, mbstring, zip, pcntl, sqlsrv, pdo, pdo_sqlsrv + extensions: dom, curl, libxml, mbstring, zip, pcntl, sqlsrv, pdo, pdo_sqlsrv, odbc, pdo_odbc tools: composer:v2 coverage: none - name: Install dependencies - uses: nick-invision/retry@v1 + uses: nick-fields/retry@v2 with: timeout_minutes: 5 max_attempts: 5 command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress - name: Execute tests - run: vendor/bin/phpunit tests/Integration/Database --verbose --exclude-group SkipMSSQL + run: vendor/bin/phpunit tests/Integration/Database --verbose env: DB_CONNECTION: sqlsrv DB_DATABASE: master diff --git a/.github/workflows/facades.yml b/.github/workflows/facades.yml new file mode 100644 index 000000000000..d77e2b509624 --- /dev/null +++ b/.github/workflows/facades.yml @@ -0,0 +1,43 @@ +name: facades + +on: + push: + branches: + - master + - '*.x' + +jobs: + update: + runs-on: ubuntu-22.04 + + strategy: + fail-fast: true + + name: Facade DocBlocks + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + tools: composer:v2 + coverage: none + + - name: Install dependencies + uses: nick-fields/retry@v2 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress + + - name: Update facade docblocks + run: php -f bin/facades.php + + - name: Commit facade docblocks + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Update facade docblocks + file_pattern: src/ diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml new file mode 100644 index 000000000000..9634a0edb3e0 --- /dev/null +++ b/.github/workflows/issues.yml @@ -0,0 +1,12 @@ +name: issues + +on: + issues: + types: [labeled] + +permissions: + issues: write + +jobs: + help-wanted: + uses: laravel/.github/.github/workflows/issues.yml@main diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index d7ff60d75312..52378f6b12ba 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -2,13 +2,14 @@ name: static analysis on: push: + branches: + - master + - '*.x' pull_request: - schedule: - - cron: '0 0 * * *' jobs: - src: - runs-on: ubuntu-20.04 + types: + runs-on: ubuntu-22.04 strategy: fail-fast: true @@ -19,17 +20,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.0 + php-version: 8.1 tools: composer:v2 coverage: none - name: Install dependencies - uses: nick-invision/retry@v1 + uses: nick-fields/retry@v2 with: timeout_minutes: 5 max_attempts: 5 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 175b476b6b90..fe643d9611a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,13 +2,16 @@ name: tests on: push: + branches: + - master + - '*.x' pull_request: schedule: - cron: '0 0 * * *' jobs: linux_tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: memcached: @@ -29,27 +32,27 @@ jobs: - 6379:6379 options: --entrypoint redis-server dynamodb: - image: amazon/dynamodb-local:latest + image: amazon/dynamodb-local:1.22.0 ports: - 8888:8000 strategy: fail-fast: true matrix: - php: ['8.0', '8.1'] + php: ['8.0', 8.1, 8.2] stability: [prefer-lowest, prefer-stable] name: PHP ${{ matrix.php }} - ${{ matrix.stability }} steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd, redis-phpredis/phpredis@5.3.5, igbinary, msgpack, lzf, zstd, lz4, memcached + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd, redis-phpredis/phpredis@5.3.7, igbinary, msgpack, lzf, zstd, lz4, memcached, gmp ini-values: error_reporting=E_ALL tools: composer:v2 coverage: none @@ -58,7 +61,7 @@ jobs: REDIS_LIBS: liblz4-dev, liblzf-dev, libzstd-dev - name: Set Minimum PHP 8.0 Versions - uses: nick-invision/retry@v1 + uses: nick-fields/retry@v2 with: timeout_minutes: 5 max_attempts: 5 @@ -66,15 +69,23 @@ jobs: if: matrix.php >= 8 - name: Set Minimum PHP 8.1 Versions - uses: nick-invision/retry@v1 + uses: nick-fields/retry@v2 with: timeout_minutes: 5 max_attempts: 5 - command: composer require ramsey/collection:^1.2 brick/math:^0.9.3 --no-interaction --no-update + command: composer require symfony/css-selector:^6.0 --no-interaction --no-update if: matrix.php >= 8.1 + - name: Set Minimum PHP 8.2 Versions + uses: nick-fields/retry@v2 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer require guzzlehttp/guzzle:^7.5 guzzlehttp/psr7:^2.4 predis/predis:^2.0.2 --no-interaction --no-update + if: matrix.php >= 8.2 + - name: Install dependencies - uses: nick-invision/retry@v1 + uses: nick-fields/retry@v2 with: timeout_minutes: 5 max_attempts: 5 @@ -91,7 +102,7 @@ jobs: AWS_SECRET_ACCESS_KEY: random_secret - name: Store artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: logs path: | @@ -104,7 +115,7 @@ jobs: strategy: fail-fast: true matrix: - php: ['8.0', '8.1'] + php: ['8.0', 8.1, 8.2] stability: [prefer-lowest, prefer-stable] name: PHP ${{ matrix.php }} - ${{ matrix.stability }} - Windows @@ -116,26 +127,42 @@ jobs: git config --global core.eol lf - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, gd, pdo_mysql, fileinfo, ftp, redis, memcached + extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, gd, pdo_mysql, fileinfo, ftp, redis, memcached, gmp tools: composer:v2 coverage: none - name: Set Minimum PHP 8.0 Versions - uses: nick-invision/retry@v1 + uses: nick-fields/retry@v2 with: timeout_minutes: 5 max_attempts: 5 - command: composer require guzzlehttp/guzzle:^7.2 --no-interaction --no-update + command: composer require guzzlehttp/guzzle:~7.2 --no-interaction --no-update if: matrix.php >= 8 + - name: Set Minimum PHP 8.1 Versions + uses: nick-fields/retry@v2 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer require symfony/css-selector:~6.0 --no-interaction --no-update + if: matrix.php >= 8.1 + + - name: Set Minimum PHP 8.2 Versions + uses: nick-fields/retry@v2 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer require guzzlehttp/guzzle:~7.5 guzzlehttp/psr7:~2.4 predis/predis:~2.0.2 --no-interaction --no-update + if: matrix.php >= 8.2 + - name: Install dependencies - uses: nick-invision/retry@v1 + uses: nick-fields/retry@v2 with: timeout_minutes: 5 max_attempts: 5 @@ -148,7 +175,7 @@ jobs: AWS_SECRET_ACCESS_KEY: random_secret - name: Store artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: logs path: | diff --git a/.gitignore b/.gitignore index a46201ab0b4b..39397245b7ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.phpunit.cache /vendor composer.phar composer.lock @@ -5,5 +6,6 @@ composer.lock Thumbs.db /phpunit.xml /.idea +/.fleet /.vscode .phpunit.result.cache diff --git a/.styleci.yml b/.styleci.yml index 9cd91cf68fdc..44f7cb91093b 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -1,6 +1,9 @@ php: preset: laravel version: 8.1 + finder: + not-name: + - bad-syntax-strategy.php js: finder: not-name: diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ef0fa0a717..60aa9c9fbc8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,1066 @@ # Release Notes for 9.x -## [Unreleased](https://github.com/laravel/framework/compare/v9.18.0...9.x) +## [Unreleased](https://github.com/laravel/framework/compare/v9.52.10...9.x) + +## [v9.52.10](https://github.com/laravel/framework/compare/v9.52.9...v9.52.10) - 2023-06-27 + +* Fix SES V2 Transport "reply to" addresses by @jacobmllr95 in https://github.com/laravel/framework/pull/47522 +* Fixes unable to use `trans()->has()` on JSON language files by @crynobone in https://github.com/laravel/framework/pull/47582 + +## [v9.52.9](https://github.com/laravel/framework/compare/v9.52.8...v9.52.9) - 2023-06-08 + +* Fixes usage of `Redis::many()` with empty array by @nunomaduro in https://github.com/laravel/framework/pull/47307 +* Fix PHPStan description of Closure returning a union type by @ondrejmirtes in https://github.com/laravel/framework/pull/47352 + +## [v9.52.8](https://github.com/laravel/framework/compare/v9.52.7...v9.52.8) - 2023-05-30 + +### Fixed +- Fixed escaped String for JSON_CONTAINS ([#47244](https://github.com/laravel/framework/pull/47244)) + +### Changed +- Send along value to InvalidPayloadException ([#47223](https://github.com/laravel/framework/pull/47223)) + + +## [v9.52.7](https://github.com/laravel/framework/compare/v9.52.6...v9.52.7) - 2023-04-25 + +### Changed +- Make rules method in FormRequest optional ([#46846](https://github.com/laravel/framework/pull/46846)) + + +## [v9.52.6](https://github.com/laravel/framework/compare/v9.52.5...v9.52.6) - 2023-04-18 + +### Fixed +- Fixed Cache::spy incompatibility with Cache::get ([#46689](https://github.com/laravel/framework/pull/46689)) + +### Changed +- Remove unnecessary parameters in creatable() and destroyable() methods in Illuminate/Routing/PendingSingletonResourceRegistration class ([#46677](https://github.com/laravel/framework/pull/46677)) +- Allow Event::assertListening to check for invokable event listeners ([#46683](https://github.com/laravel/framework/pull/46683)) +- Return non-zero exit code for uncaught exceptions ([#46541](https://github.com/laravel/framework/pull/46541)) +- Release lock for job implementing ShouldBeUnique that is dispatched afterResponse() ([#46806](https://github.com/laravel/framework/pull/46806)) + + +## [v9.52.5](https://github.com/laravel/framework/compare/v9.52.4...v9.52.5) - 2023-02-25 + +### Fixed +- Fixed `Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase::expectsDatabaseQueryCount()` $connection parameter ([#46228](https://github.com/laravel/framework/pull/46228)) +- PHP 8.0 fix for Closure jobs ([#46505](https://github.com/laravel/framework/pull/46505)) +- Fix preg_split error when there is a slash in the attribute ([#46549](https://github.com/laravel/framework/pull/46549)) + +### Changed +- Allow WithFaker to be used when app is not bound ([#46529](https://github.com/laravel/framework/pull/46529)) + + +## [v9.52.4](https://github.com/laravel/framework/compare/v9.52.3...v9.52.4) - 2023-02-22 + +### Fixed +- Fixes constructable migrations ([#46223](https://github.com/laravel/framework/pull/46223)) + + +## [v9.52.3](https://github.com/laravel/framework/compare/v9.52.2...v9.52.3) - 2023-02-22 + +### Reverted +- Revert changes from `Arr::random()` ([cf3eb90](https://github.com/laravel/framework/commit/cf3eb90a6473444bb7a78d1a3af1e9312a62020d)) + + +## [v9.52.2](https://github.com/laravel/framework/compare/v9.52.1...v9.52.2) - 2023-02-21 + +### Fixed +- Fixed `Illuminate/Collections/Arr::shuffle()` with empty array ([0c6cae0](https://github.com/laravel/framework/commit/0c6cae0ef647158b9554cad05ff39db7e7ad0d33)) + + +## [v9.52.1](https://github.com/laravel/framework/compare/v9.52.0...v9.52.1) - 2023-02-21 + +### Changed +- Use secure randomness in Arr:random and Arr:shuffle ([#46105](https://github.com/laravel/framework/pull/46105)) + + +## [v9.52.0](https://github.com/laravel/framework/compare/v9.51.0...v9.52.0) - 2023-02-14 + +### Added +- Added methods to Enumerable contract ([#46021](https://github.com/laravel/framework/pull/46021)) +- Added new mailer transport for AWS SES V2 API ([#45977](https://github.com/laravel/framework/pull/45977)) +- Add S3 temporaryUploadUrl method to AwsS3V3Adapter ([#45753](https://github.com/laravel/framework/pull/45753)) +- Add index hinting support to query builder ([#46063](https://github.com/laravel/framework/pull/46063)) +- Add mailer name to data for SentMessage and MessageSending events ([#46079](https://github.com/laravel/framework/pull/46079)) +- Added --pending option to migrate:status ([#46089](https://github.com/laravel/framework/pull/46089)) + +### Fixed +- Fixed pdo exception when rollbacking without active transaction ([#46017](https://github.com/laravel/framework/pull/46017)) +- Fix duplicated columns on select ([#46049](https://github.com/laravel/framework/pull/46049)) +- Fixes memory leak on anonymous migrations ([№46073](https://github.com/laravel/framework/pull/46073)) +- Fixed race condition in locks issued by the file cache driver ([#46011](https://github.com/laravel/framework/pull/46011)) + +### Changed +- Allow choosing tables to truncate in `Illuminate/Foundation/Testing/DatabaseTruncation::truncateTablesForConnection()` ([#46025](https://github.com/laravel/framework/pull/46025)) +- Update afterPromptingForMissingArguments method ([#46052](https://github.com/laravel/framework/pull/46052)) +- Accept closure in bus assertion helpers ([#46075](https://github.com/laravel/framework/pull/46075)) +- Avoid mutating the $expectedLitener between loops on Event::assertListening ([#46095](https://github.com/laravel/framework/pull/46095)) + + +## [v9.51.0](https://github.com/laravel/framework/compare/v9.50.2...v9.51.0) - 2023-02-07 + +### Added +- Added `Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase::expectsDatabaseQueryCount()` ([#45932](https://github.com/laravel/framework/pull/45932)) +- Added pending has-many-through and has-one-through builder ([#45894](https://github.com/laravel/framework/pull/45894)) +- Added `Illuminate/Http/Client/PendingRequest::withUrlParameters()` ([#45982](https://github.com/laravel/framework/pull/45982)) + +### Fixed +- Fix: prevent duplicated content-type on HTTP client ([#45960](https://github.com/laravel/framework/pull/45960)) +- Add missing php extensions in composer ([#45941](https://github.com/laravel/framework/pull/45941)) + +### Changed +- Command schedule:work minor features: schedule:run output file & environment specific verbosity ([#45949](https://github.com/laravel/framework/pull/45949)) +- Added missing self reserved word to reservedNames array in `Illuminate/Console/GeneratorCommand.php` ([#46001](https://github.com/laravel/framework/pull/46001)) +- pass value along to ttl callback in `Illuminate/Cache/Repository::remember()` ([#46006](https://github.com/laravel/framework/pull/46006)) +- Make sure the lock_connection is used for schedule's withoutOverlapping() ([#45963](https://github.com/laravel/framework/pull/45963)) + + +## [v9.50.2](https://github.com/laravel/framework/compare/v9.50.1...v9.50.2) - 2023-02-02 + +### Fixed +- Fixed missing_with and missing_with_all validation ([#45913](https://github.com/laravel/framework/pull/45913)) +- Fixes blade escaped tags issue ([#45928](https://github.com/laravel/framework/pull/45928)) + +### Changed +- Trims numeric validation values / parameters ([#45912](https://github.com/laravel/framework/pull/45912)) +- Random function doesn't generate evenly distributed random chars ([#45916](https://github.com/laravel/framework/pull/45916)) + + +## [v9.50.1](https://github.com/laravel/framework/compare/v9.50.0...v9.50.1) - 2023-02-01 + +### Reverted +- Reverted ["Optimize destroy method"](https://github.com/laravel/framework/pull/45709) ([#45903](https://github.com/laravel/framework/pull/45903)) + +### Changed +- Allow scheme to be specified in `Illuminate/Mail/MailManager::createSmtpTransport()` ([68a8bfc](https://github.com/laravel/framework/commit/68a8bfc3ab758962c8f050160ec32833dc12e467)) +- Accept optional mode in `Illuminate/Filesystem/Filesystem::replace()` ([2664e7f](https://github.com/laravel/framework/commit/2664e7fcdfe3a290462ae8e326ba79a17c747c1e)) + + +## [v9.50.0](https://github.com/laravel/framework/compare/v9.49.0...v9.50.0) - 2023-02-01 + +### Added +- Added `Illuminate/Translation/Translator::stringable()` ([#45874](https://github.com/laravel/framework/pull/45874)) +- Added `Illuminate/Foundation/Testing/DatabaseTruncation` ([#45726](https://github.com/laravel/framework/pull/45726)) +- Added @style Blade directive ([#45887](https://github.com/laravel/framework/pull/45887)) + +### Reverted +- Reverted: ["Fix Illuminate Filesystem replace() leaves file executable"](https://github.com/laravel/framework/pull/45856) ([5ea388d](https://github.com/laravel/framework/commit/5ea388d7fe6f786b6dbcb34e0b52341c0b38ad7e)) + +### Fixed +- Fixed LazyCollection::makeIterator() to accept non Generator Function ([#45881](https://github.com/laravel/framework/pull/45881)) + +### Changed +- Solve data to be dumped for separate schemes ([#45805](https://github.com/laravel/framework/pull/45805)) + + +## [v9.49.0](https://github.com/laravel/framework/compare/v9.48.0...v9.49.0) - 2023-01-31 + +### Added +- Added `Illuminate/Database/Schema/ForeignKeyDefinition::noActionOnDelete()` ([#45712](https://github.com/laravel/framework/pull/45712)) +- Added new throw helper methods to the HTTP Client ([#45704](https://github.com/laravel/framework/pull/45704)) +- Added configurable timezone support for WorkCommand output timestamps ([#45722](https://github.com/laravel/framework/pull/45722)) +- Added support for casting arrays containing enums ([#45621](https://github.com/laravel/framework/pull/45621)) +- Added "missing" validation rules ([#45717](https://github.com/laravel/framework/pull/45717)) +- Added `/Illuminate/Database/Eloquent/SoftDeletes::forceDeleteQuietly()` ([#45737](https://github.com/laravel/framework/pull/45737)) +- Added `Illuminate/Collections/Arr::sortDesc()` ([#45761](https://github.com/laravel/framework/pull/45761)) +- Added CLI Prompts ([#45629](https://github.com/laravel/framework/pull/45629), [#45864](https://github.com/laravel/framework/pull/45864)) +- Adds assertJsonIsArray and assertJsonIsObject for TestResponse ([#45731](https://github.com/laravel/framework/pull/45731)) +- Added `Illuminate/Database/Eloquent/Relations/HasOneOrMany::createQuietly()` ([#45783](https://github.com/laravel/framework/pull/45783)) +- Add validation rules: ascii_alpha, ascii_alpha_num, ascii_alpha_dash ([#45769](https://github.com/laravel/framework/pull/45769)) +- Extract status methods to traits ([#45789](https://github.com/laravel/framework/pull/45789)) +- Add "addRestoreOrCreate" extension to SoftDeletingScope ([#45754](https://github.com/laravel/framework/pull/45754)) +- Added connection established event ([f850d99](https://github.com/laravel/framework/commit/f850d99c50d173189ece2bb37b6c7ddcb456f1f9)) +- Add forceDeleting event to models ([#45836](https://github.com/laravel/framework/pull/45836)) +- Add title tag in mail template ([#45859](https://github.com/laravel/framework/pull/45859)) +- Added new methods to Collection ([#45839](https://github.com/laravel/framework/pull/45839)) +- Add skip cancelled middleware ([#45869](https://github.com/laravel/framework/pull/45869)) + +### Fixed +- Fix flushdb on cluster for `PredisClusterConnection.php` ([#45544](https://github.com/laravel/framework/pull/45544)) +- Fix blade tag issue with nested calls ([#45764](https://github.com/laravel/framework/pull/45764)) +- Fix infinite loop in blade compiler ([#45780](https://github.com/laravel/framework/pull/45780)) +- Fix ValidationValidator not to accept terminating newline ([#45790](https://github.com/laravel/framework/pull/45790)) +- Fix stubs publish command generating incorrect controller stubs ([#45812](https://github.com/laravel/framework/pull/45812)) +- fix: normalize route pipeline exception ([#45817](https://github.com/laravel/framework/pull/45817)) +- Fix Illuminate Filesystem replace() leaves file executable ([#45856](https://github.com/laravel/framework/pull/45856)) + +### Changed +- Ensures channel name matches from start of string ([#45692](https://github.com/laravel/framework/pull/45692)) +- Replace raw invisible characters in regex expressions with counterpart Unicode regex notations ([#45680](https://github.com/laravel/framework/pull/45680)) +- Optimize destroy method ([#45709](https://github.com/laravel/framework/pull/45709)) +- Unify prohibits behavior around prohibits_if ([#45723](https://github.com/laravel/framework/pull/45723)) +- Removes dependency on bcmath ([#45729](https://github.com/laravel/framework/pull/45729)) +- Allow brick/math 0.11 also ([#45762](https://github.com/laravel/framework/pull/45762)) +- Optimize findMany of BelongsToMany ([#45745](https://github.com/laravel/framework/pull/45745)) +- Ensure decimal rule handles large values ([#45693](https://github.com/laravel/framework/pull/45693)) +- Backed enum support for @js ([#45862](https://github.com/laravel/framework/pull/45862)) +- Restart syscalls for SIGALRM when worker times out a job ([#45871](https://github.com/laravel/framework/pull/45871)) +- Ensure subsiquent calls to Mailable->to() overwrite previous entries ([#45885](https://github.com/laravel/framework/pull/45885)) + + +## [v9.48.0](https://github.com/laravel/framework/compare/v9.47.0...v9.48.0) - 2023-01-17 + +### Added +- Added `Illuminate/Database/Schema/Builder::withoutForeignKeyConstraints()` ([#45601](https://github.com/laravel/framework/pull/45601)) +- Added `fragments()` \ `fragmentIf()` \ `fragmentsIf()` methods to `Illuminate/View/View.php` class ([#45656](https://github.com/laravel/framework/pull/45656), [#45669](https://github.com/laravel/framework/pull/45669)) +- Added `incrementEach()` and `decrementEach()` to `Illuminate/Database/Query/Builder` ([#45577](https://github.com/laravel/framework/pull/45577)) +- Added ability to drop an index when modifying a column ([#45513](https://github.com/laravel/framework/pull/45513)) +- Allow to set HTTP client for mailers ([#45684](https://github.com/laravel/framework/pull/45684)) +- Added 402 exception view ([#45682](https://github.com/laravel/framework/pull/45682)) +- Added `notFound()` helper to Http Client response ([#45681](https://github.com/laravel/framework/pull/45681)) + +### Fixed +- Fixed decimal cast ([#45602](https://github.com/laravel/framework/pull/45602)) + +### Changed +- Ignore whitespaces/newlines when finding relations in model:show command ([#45608](https://github.com/laravel/framework/pull/45608)) +- Fail queued job with a string messag ([#45625](https://github.com/laravel/framework/pull/45625)) +- Allow fake() helper in unit tests ([#45624](https://github.com/laravel/framework/pull/45624)) +- allow egulias/email-validator v4 ([#45649](https://github.com/laravel/framework/pull/45649)) +- Force countBy method in EloquentCollection to return base collection ([#45663](https://github.com/laravel/framework/pull/45663)) +- Allow for the collection of stubs to be published ([#45653](https://github.com/laravel/framework/pull/45653)) + + +## [v9.47.0](https://github.com/laravel/framework/compare/v9.46.0...v9.47.0) - 2023-01-10 + +### Added +- Added Support Lazy Collections in `BatchFake::add()` ([#45507](https://github.com/laravel/framework/pull/45507)) +- Added Decimal to list of Numeric rules ([#45533](https://github.com/laravel/framework/pull/45533)) +- Added `Illuminate/Routing/PendingSingletonResourceRegistration::destroyable()` ([#45549](https://github.com/laravel/framework/pull/45549)) +- Added setVisible and setHidden to Eloquent Collection ([#45558](https://github.com/laravel/framework/pull/45558)) + +### Fixed +- Fix bound method contextual binding ([#45500](https://github.com/laravel/framework/pull/45500)) +- Fixed Method explodeExplicitRule with regex rule ([#45555](https://github.com/laravel/framework/pull/45555)) +- Fixed `Illuminate/Database/Query/Builder::whereIntegerInRaw()` ([#45584](https://github.com/laravel/framework/pull/45584)) +- Fixes blade tags ([#45490](https://github.com/laravel/framework/pull/45490)) + +### Changed +- Return model when casting attribute ([#45539](https://github.com/laravel/framework/pull/45539)) +- always show full path to migration in `Illuminate/Database/Console/Migrations/MigrateMakeCommand.php` ([9f6ff48](https://github.com/laravel/framework/commit/9f6ff487e6964dc407c267d1a40352fa71b2fc44)) +- Remove index name when adding primary key on MySQL ([#45515](https://github.com/laravel/framework/pull/45515)) + + +## [v9.46.0](https://github.com/laravel/framework/compare/v9.45.1...v9.46.0) - 2023-01-03 + +### Added +- Added Passthrough PATH variable to serve command ([#45402](https://github.com/laravel/framework/pull/45402)) +- Added whenHas to JsonResource ([#45376](https://github.com/laravel/framework/pull/45376)) +- Added ./fleet directory to .gitignore ([#45432](https://github.com/laravel/framework/pull/45432)) +- Added unless to JsonResource ([#45419](https://github.com/laravel/framework/pull/45419)) + +### Fixed +- Fixed credentials check ([#45437](https://github.com/laravel/framework/pull/45437)) +- Fixed decimal cast precision issue ([#45456](https://github.com/laravel/framework/pull/45456), [#45492](https://github.com/laravel/framework/pull/45492)) +- Precognitive validation with nested arrays doesn't throw validation error ([#45405](https://github.com/laravel/framework/pull/45405)) +- Fixed issue on which class to check increment and decrement methods for custom cast ([#45444](https://github.com/laravel/framework/pull/45444)) + +### Changed +- Update decimal validation rule to allow validation of signed numbers ([24a48b2](https://github.com/laravel/framework/commit/24a48b2fa6154b2ba2e669999e73a060f9e82080)) +- Output only unique asset / preload tags in Vite ([#45404](https://github.com/laravel/framework/pull/45404)) +- Optimize whereKey method in Query Builder ([#45453](https://github.com/laravel/framework/pull/45453)) +- Remove extra code in Model.php to optimize performance ([#45476](https://github.com/laravel/framework/pull/45476)) +- Exception Handler prepareResponse add previous Exception ([#45499](https://github.com/laravel/framework/pull/45499)) + + +## [v9.45.1](https://github.com/laravel/framework/compare/v9.45.0...v9.45.1) - 2022-12-21 + +### Revert +- Revert "fix single line @php statements to not be parsed as php blocks" ([#45389](https://github.com/laravel/framework/pull/45389)) + +### Changed +- Load schema to in memory database ([#45375](https://github.com/laravel/framework/pull/45375)) + + +## [v9.45.0](https://github.com/laravel/framework/compare/v9.44.0...v9.45.0) - 2022-12-20 + +### Added +- Allows the registration of custom, root-level anonymous component search paths. ([#45338](https://github.com/laravel/framework/pull/45338), [1ff0379](https://github.com/laravel/framework/commit/1ff0379d203ac836c3eeae567cc07b99c352b1e7)) +- Added decimal validation rule ([#45356](https://github.com/laravel/framework/pull/45356), [e89b2b0](https://github.com/laravel/framework/commit/e89b2b0bd0e43b8aecd72a55c546288576bb0370)) +- Added align property to button mail component ([#45362](https://github.com/laravel/framework/pull/45362)) +- Added whereUlid(param) support for routing ([#45372](https://github.com/laravel/framework/pull/45372)) + +### Fixed +- Fixed single line @php statements to not be parsed as php blocks in BladeCompiler ([#45333](https://github.com/laravel/framework/pull/45333)) +- Added missing code to set locale from model preferred locale in Maillable ([#45308](https://github.com/laravel/framework/pull/45308)) + +### Changed +- Vite: ability to prevent preload tag generation from attribute resolver callback ([#45283](https://github.com/laravel/framework/pull/45283)) +- Deprecation Test Improvements ([#45317](https://github.com/laravel/framework/pull/45317)) +- Do not allow nested arrays in whereIn method ([140c3a8](https://github.com/laravel/framework/commit/140c3a81d261669d0785aebe2599aed99991e890)) +- Bump ramsey/uuid ([#45367](https://github.com/laravel/framework/pull/45367)) + + +## [v9.44.0](https://github.com/laravel/framework/compare/v9.43.0...v9.44.0) - 2022-12-15 + +### Added +- Added `Illuminate/Auth/GuardHelpers::forgetUser()` ([#45208](https://github.com/laravel/framework/pull/45208)) +- Added sort option for schedule:list ([#45198](https://github.com/laravel/framework/pull/45198)) +- Added `ascii` and `ulid` validation rules ([#45218](https://github.com/laravel/framework/pull/45218)) +- Http client - allow to provide closure as "throwif" condition ([#45251](https://github.com/laravel/framework/pull/45251)) +- Support '/' as a possible column name in database ([#45268](https://github.com/laravel/framework/pull/45268)) +- Added Granular notifications queue connections ([#45264](https://github.com/laravel/framework/pull/45264)) +- Add support for native rename/drop column commands ([#45258](https://github.com/laravel/framework/pull/45258)) +- Add $encoding parameter to substr method ([#45300](https://github.com/laravel/framework/pull/45300)) +- Use Macroable in Session facade ([#45310](https://github.com/laravel/framework/pull/45310)) + +### Fixed +- Fixed aliasing with cursor pagination ([#45188](https://github.com/laravel/framework/pull/45188)) +- Fixed email verification request ([#45227](https://github.com/laravel/framework/pull/45227)) +- Return 500 http error, instead of 200, when dotenv fails to load ([#45235](https://github.com/laravel/framework/pull/45235)) +- Fixed bug on Job Batchs Table ([#45263](https://github.com/laravel/framework/pull/45263)) +- Fixed schedule:list crash when call() is given class-string ([#45306](https://github.com/laravel/framework/pull/45306)) +- Fixed Lack of Memory when failing a job with wrong variable passed on the method fail() ([#45291](https://github.com/laravel/framework/pull/45291)) +- Fixed errors occurring when encrypted cookies has been tampered with ([#45313](https://github.com/laravel/framework/pull/45313)) +- bug fix, change array_merge to array_replace to prevent reindex ([#45309](https://github.com/laravel/framework/pull/45309)) + +### Changed +- Allow BusFake to use custom BusRepository ([#45202](https://github.com/laravel/framework/pull/45202)) +- Improved error logging for unmatched routes and route not found ([#45206](https://github.com/laravel/framework/pull/45206)) +- Improve assertSeeText and assertDontSeeText test methods ([#45274](https://github.com/laravel/framework/pull/45274)) +- Improved `Illuminate/Auth/SessionGuard::clearUserDataFromStorage()` ([#45305](https://github.com/laravel/framework/pull/45305)) +- Allows shouldIgnoresDeprecationError() to be overriden ([#45299](https://github.com/laravel/framework/pull/45299)) + + +## [v9.43.0](https://github.com/laravel/framework/compare/v9.42.2...v9.43.0) - 2022-12-06 + +### Added +- Add support for eager loading specific columns to withWhereHas ([#45168](https://github.com/laravel/framework/pull/45168)) +- Add Policies to Model Show Command ([#45153](https://github.com/laravel/framework/pull/45153)) +- Added `Illuminate/Support/Stringable::whenIsUlid()` ([#45183](https://github.com/laravel/framework/pull/45183)) + +### Fixed +- Added missing reserved names in GeneratorCommand ([#45149](https://github.com/laravel/framework/pull/45149)) + +### Changed +- Allow to pass base64 key to env:encrypt command ([#45157](https://github.com/laravel/framework/pull/45157)) +- Replace model:show searched value with correct FQCN ([#45160](https://github.com/laravel/framework/pull/45160)) + + +## [v9.42.2](https://github.com/laravel/framework/compare/v9.42.1...v9.42.2) - 2022-11-30 + +### Changed +- Improved stubs and `Illuminate/Routing/ResourceRegistrar::getResourceMethods()` ([6ddf3b0](https://github.com/laravel/framework/commit/6ddf3b017ccb8486c8dc5ff5a09d051a40e094ca)) + + +## [v9.42.1](https://github.com/laravel/framework/compare/v9.42.0...v9.42.1) - 2022-11-30 + +### Revert +- Revert "[9.x] Create new Json ParameterBag Instance when cloning Request" ([#45147](https://github.com/laravel/framework/pull/45147)) + +### Fixed +- Mailable : fixes strict comparison with int value ([#45138](https://github.com/laravel/framework/pull/45138)) +- Address Dynamic Relation Resolver inconsiency issue with extended Models ([#45122](https://github.com/laravel/framework/pull/45122)) + + +## [v9.42.0](https://github.com/laravel/framework/compare/v9.41.0...v9.42.0) - 2022-11-29 + +### Added +- Added --rest option to queue:listen ([00a12e2](https://github.com/laravel/framework/commit/00a12e256f897d215012bddf76b6b6c0d66f7f67), [82fde9e](https://github.com/laravel/framework/commit/82fde9e0dc4f08f4c4db254b9449fd87652c40a6)) +- Added `Illuminate/Support/Stringable::isUlid()` ([#45100](https://github.com/laravel/framework/pull/45100)) +- Add news report_if and report_unless helpers functions ([#45093](https://github.com/laravel/framework/pull/45093)) +- Add callback to resolve custom mutex name of schedule events ([#45126](https://github.com/laravel/framework/pull/45126)) +- Add WorkOptions to WorkerStopping Event ([#45120](https://github.com/laravel/framework/pull/45120)) +- Added `singleton` and `creatable` options to `Illuminate/Routing/Console/ControllerMakeCommand` ([#44872](https://github.com/laravel/framework/pull/44872)) + +### Fixed +- Fix pure enums validation ([#45121](https://github.com/laravel/framework/pull/45121)) +- Prevent test issues with relations with the $touches property ([#45118](https://github.com/laravel/framework/pull/45118)) +- Fix factory breaking when trying to determine whether a relation is empty ([#45135](https://github.com/laravel/framework/pull/45135)) + +### Changed +- Allow set command description via AsCommand attribute ([#45117](https://github.com/laravel/framework/pull/45117)) +- Updated Mailable to prevent duplicated recipients ([#45119](https://github.com/laravel/framework/pull/45119)) + + +## [v9.41.0](https://github.com/laravel/framework/compare/v9.40.1...v9.41.0) - 2022-11-22 + +### Added +- Added `Illuminate/Validation/Rules/DatabaseRule::onlyTrashed()` ([#44989](https://github.com/laravel/framework/pull/44989)) +- Add some class rules in class Rule ([#44998](https://github.com/laravel/framework/pull/44998)) +- Added `Illuminate/View/ComponentAttributeBag::missing()` ([#45016](https://github.com/laravel/framework/pull/45016)) +- Added `Illuminate/Http/Concerns/InteractsWithInput::whenMissing()` ([#45019](https://github.com/laravel/framework/pull/45019)) +- Add isolation levels to SQL Server Connector ([#45023](https://github.com/laravel/framework/pull/45023)) +- Fix php artisan serve with PHP_CLI_SERVER_WORKERS > 1 ([#45041](https://github.com/laravel/framework/pull/45041)) +- Add ability to prune cancelled job batches ([#45034](https://github.com/laravel/framework/pull/45034)) +- Adding option for custom manifest filename on Vite Facade ([#45007](https://github.com/laravel/framework/pull/45007)) + +### Fixed +- Fix deprecation warning when comparing a password against a NULL database password ([#44986](https://github.com/laravel/framework/pull/44986), [206e465](https://github.com/laravel/framework/commit/206e465f9680ef4618009ddfeafa672f8015a511)) +- Outlook web dark mode email layout fix ([#45024](https://github.com/laravel/framework/pull/45024)) + +### Changed +- Improves queue:work command output ([#44971](https://github.com/laravel/framework/pull/44971)) +- Optimize Collection::containsStrict ([#44970](https://github.com/laravel/framework/pull/44970)) +- Make name required in `Illuminate/Testing/TestResponse::assertRedirectToRoute()` ([98a0301](https://github.com/laravel/framework/commit/98a03013ed74925f68040beee0937203b632f57d)) +- Strip key, secret and token from root config options on aws clients ([#44979](https://github.com/laravel/framework/pull/44979)) +- Allow customised implementation of the SendQueuedMailable job ([#45040](https://github.com/laravel/framework/pull/45040)) +- Validate uuid before route binding query ([#44945](https://github.com/laravel/framework/pull/44945)) + + +## [v9.40.1](https://github.com/laravel/framework/compare/v9.40.0...v9.40.1) - 2022-11-15 + +### Added +- `Illuminate/Support/Lottery::fix()` ([7bade4f](https://github.com/laravel/framework/commit/7bade4f486e7b600cc9a5d527fcfd85ead1e17db)) + + +## [v9.40.0](https://github.com/laravel/framework/compare/v9.39.0...v9.40.0) - 2022-11-15 + +### Added +- Include Eloquent Model Observers in model:show command ([#44884](https://github.com/laravel/framework/pull/44884)) +- Added "lowercase" validation rule ([#44883](https://github.com/laravel/framework/pull/44883)) +- Introduce `Lottery` class ([#44894](https://github.com/laravel/framework/pull/44894)) +- Added `/Illuminate/Testing/TestResponse::assertRedirectToRoute()` ([#44926](https://github.com/laravel/framework/pull/44926)) +- Add uppercase validation rule ([#44918](https://github.com/laravel/framework/pull/44918)) +- Added saveManyQuietly to the hasOneOrMany and belongsToMany relations ([#44913](https://github.com/laravel/framework/pull/44913)) + +### Fixed +- Fix HasAttributes::getMutatedAttributes for classes with constructor args ([#44829](https://github.com/laravel/framework/pull/44829)) + +### Changed +- Remove argument assignment for console ([#44888](https://github.com/laravel/framework/pull/44888)) +- Pass $maxExceptions from mailable to underlying job when queuing ([#44903](https://github.com/laravel/framework/pull/44903)) +- Make Vite::isRunningHot public ([#44900](https://github.com/laravel/framework/pull/44900)) +- Add method to be able to override the exception context format ([#44895](https://github.com/laravel/framework/pull/44895)) +- Add zero-width space to trimmed characters in TrimStrings middleware ([#44906](https://github.com/laravel/framework/pull/44906)) +- Show error if key:generate artisan command fails ([#44927](https://github.com/laravel/framework/pull/44927)) +- Update database version check for lock popping for PlanetScale ([#44925](https://github.com/laravel/framework/pull/44925)) +- Move function withoutTrashed into DatabaseRule ([#44938](https://github.com/laravel/framework/pull/44938)) +- Use write connection on Schema::getColumnListing() and Schema::hasTable() for MySQL and PostgreSQL ([#44946](https://github.com/laravel/framework/pull/44946)) + + +## [v9.39.0](https://github.com/laravel/framework/compare/v9.38.0...v9.39.0) - 2022-11-08 + +### Added +- Added template fragments to Blade ([#44774](https://github.com/laravel/framework/pull/44774)) +- Added source file to Collection's dd method output ([#44793](https://github.com/laravel/framework/pull/44793), [d2e0e85](https://github.com/laravel/framework/commit/d2e0e859f00579aeb2600fce2fe9fc3cca933f41)) +- Added `Illuminate/Support/Testing/Fakes/PendingBatchFake::dispatchAfterResponse()` ([#44815](https://github.com/laravel/framework/pull/44815)) +- Added `Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase::assertDatabaseEmpty()` ([#44810](https://github.com/laravel/framework/pull/44810)) + +### Fixed +- Fixed `InteractsWithContainer::withoutMix()` ([#44822](https://github.com/laravel/framework/pull/44822)) + +### Changed +- Update `UpCommand::handle` that must return int ([#44807](https://github.com/laravel/framework/pull/44807)) +- Decouple database component from console component ([#44798](https://github.com/laravel/framework/pull/44798)) +- Improve input argument parsing for commands ([#44662](https://github.com/laravel/framework/pull/44662), [#44826](https://github.com/laravel/framework/pull/44826)) +- Added DatabaseBatchRepository to provides() in BusServiceProvider ([#44833](https://github.com/laravel/framework/pull/44833)) +- Move reusable onNotSuccessfulTest functionality to TestResponse ([#44827](https://github.com/laravel/framework/pull/44827)) +- Add CSP nonce to Vite reactRefresh inline script ([#44816](https://github.com/laravel/framework/pull/44816)) +- Allow route group method to be chained ([#44825](https://github.com/laravel/framework/pull/44825)) +- Remove __sleep() & __wakeup() from SerializesModels trait. ([#44847](https://github.com/laravel/framework/pull/44847)) +- Handle SQLite without ENABLE_DBSTAT_VTAB enabled in `Illuminate/Database/Console/DatabaseInspectionCommand::getSqliteTableSize()` ([#44867](https://github.com/laravel/framework/pull/44867)) +- Apply force flag when necessary in `Illuminate/Queue/Listener` ([#44862](https://github.com/laravel/framework/pull/44862)) +- De-couple Console component from framework ([#44864](https://github.com/laravel/framework/pull/44864)) +- Update Vite mock to return empty array for preloadedAssets ([#44858](https://github.com/laravel/framework/pull/44858)) + + +## [v9.38.0](https://github.com/laravel/framework/compare/v9.37.0...v9.38.0) - 2022-11-01 + +### Added +- Added `Illuminate/Routing/Route::flushController()` ([#44393](https://github.com/laravel/framework/pull/44393)) +- Added `Illuminate/Session/Store::setHandler()` ([#44736](https://github.com/laravel/framework/pull/44736)) +- Added dictionary to slug helper ([#44730](https://github.com/laravel/framework/pull/44730)) +- Added ability to set middleware based on notifiable instance and channel ([#44767](https://github.com/laravel/framework/pull/44767)) +- Added touchQuietly convenience method to Model ([#44722](https://github.com/laravel/framework/pull/44722)) +- Added `Illuminate/Routing/Router::removeMiddlewareFromGroup()` ([#44780](https://github.com/laravel/framework/pull/44780)) +- Allow queueable notifications to set maxExceptions ([#44773](https://github.com/laravel/framework/pull/44773)) +- Make migrate command isolated ([#44743](https://github.com/laravel/framework/pull/44743), [ac3252a](https://github.com/laravel/framework/commit/ac3252a4c2a4c94724cd5aeaf6268427d21f9e97)) + +### Fixed +- Fixed whenPivotLoaded(As) api resource methods when using Eloquent strict mode ([#44792](https://github.com/laravel/framework/pull/44792)) +- Fixed components view error when using $attributes in parent view ([#44778](https://github.com/laravel/framework/pull/44778)) +- Fixed problem with disregarding global scopes when using existOr and doesntExistOr methods on model query ([#44795](https://github.com/laravel/framework/pull/44795)) + +### Changed +- Recompiles views when necessary ([#44737](https://github.com/laravel/framework/pull/44737)) +- Throw meaningful exception when broadcast connection not configured ([#44745](https://github.com/laravel/framework/pull/44745)) +- Prevents booting of providers when running env:encrypt ([#44758](https://github.com/laravel/framework/pull/44758)) +- Added nonce for preloaded assets ([#44747](https://github.com/laravel/framework/pull/44747)) +- Inherit crossorigin attributes while preloading view ([#44800](https://github.com/laravel/framework/pull/44800)) + + +## [v9.37.0](https://github.com/laravel/framework/compare/v9.36.4...v9.37.0) - 2022-10-25 + +### Added +- Added optional verbose output when view caching ([#44673](https://github.com/laravel/framework/pull/44673)) +- Allow passing closure to rescue $report parameter ([#44710](https://github.com/laravel/framework/pull/44710)) +- Support preloading assets with Vite ([#44096](https://github.com/laravel/framework/pull/44096)) +- Added `Illuminate/Mail/Mailables/Content::htmlString()` ([#44703](https://github.com/laravel/framework/pull/44703)) + +### Fixed +- Fixed model:show registering getAttribute() as a null accessor ([#44683](https://github.com/laravel/framework/pull/44683)) +- Fix expectations for output assertions in PendingCommand ([#44723](https://github.com/laravel/framework/pull/44723)) + + +## [v9.36.4](https://github.com/laravel/framework/compare/v9.36.3...v9.36.4) - 2022-10-20 + +### Added +- Added rawValue to Database Query Builder (and Eloquent as wrapper) ([#44631](https://github.com/laravel/framework/pull/44631)) +- Added TransactionCommitting ([#44608](https://github.com/laravel/framework/pull/44608)) +- Added dontIncludeSource to CliDumper and HtmlDumper ([#44623](https://github.com/laravel/framework/pull/44623)) +- Added `Illuminate/Filesystem/FilesystemAdapter::checksum()` ([#44660](https://github.com/laravel/framework/pull/44660)) +- Added handlers for silently discarded and missing attribute violations ([#44664](https://github.com/laravel/framework/pull/44664)) + +### Reverted +- Reverted ["Let MustVerifyEmail to be used on models without id as primary key"](https://github.com/laravel/framework/pull/44613) ([#44672](https://github.com/laravel/framework/pull/44672)) + +### Changed +- Create new Json ParameterBag Instance when cloning Request ([#44671](https://github.com/laravel/framework/pull/44671)) +- Prevents booting providers when running env:decrypt ([#44654](https://github.com/laravel/framework/pull/44654)) + + +## [v9.36.3](https://github.com/laravel/framework/compare/v9.36.2...v9.36.3) - 2022-10-19 + +### Reverted +- Reverts micro-optimization on view events ([#44653](https://github.com/laravel/framework/pull/44653)) + +### Fixed +- Fixes blade not forgetting compiled views on view:clear ([#44643](https://github.com/laravel/framework/pull/44643)) +- Fixed `Illuminate/Database/Eloquent/Model::offsetExists()` ([#44642](https://github.com/laravel/framework/pull/44642)) +- Forget component's cache and factory between tests ([#44648](https://github.com/laravel/framework/pull/44648)) + +### Changed +- Bump Testbench dependencies ([#44651](https://github.com/laravel/framework/pull/44651)) + + +## [v9.36.2](https://github.com/laravel/framework/compare/v9.36.1...v9.36.2) - 2022-10-18 + +### Fixed +- Ensures view creators and composers are called when * is present ([#44636](https://github.com/laravel/framework/pull/44636)) + + +## [v9.36.1](https://github.com/laravel/framework/compare/v9.36.0...v9.36.1) - 2022-10-18 + +### Fixed +- Fixes livewire components that were using createBladeViewFromString ([#pull](https://github.com/laravel/framework/pull)) + + +## [v9.36.0](https://github.com/laravel/framework/compare/v9.35.1...v9.36.0) - 2022-10-18 + +### Added +- Added mailable assertions ([#44563](https://github.com/laravel/framework/pull/44563)) +- Added `Illuminate/Testing/TestResponse::assertContent()` ([#44580](https://github.com/laravel/framework/pull/44580)) +- Added to `Illuminate/Console/Concerns/InteractsWithIO::alert()` `$verbosity` param ([#44614](https://github.com/laravel/framework/pull/44614)) + +### Optimization +- Makes blade components blazing fast ([#44487](https://github.com/laravel/framework/pull/44487)) + +### Fixed +- Fixed `Illuminate/Filesystem/Filesystem::relativeLink()` ([#44519](https://github.com/laravel/framework/pull/44519)) +- Fixed for `model:show` failing with models that have null timestamp columns ([#44576](https://github.com/laravel/framework/pull/44576)) +- Allow Model::shouldBeStrict(false) to disable "strict mode" ([#44627](https://github.com/laravel/framework/pull/44627)) + +### Changed +- Dont require a host for sqlite connections in php artisan db ([#44585](https://github.com/laravel/framework/pull/44585)) +- Let MustVerifyEmail to be used on models without id as primary key ([#44613](https://github.com/laravel/framework/pull/44613)) +- Changed `Illuminate/Routing/Route::controllerMiddleware()` ([#44590](https://github.com/laravel/framework/pull/44590)) + + +## [v9.35.1](https://github.com/laravel/framework/compare/v9.35.0...v9.35.1) - 2022-10-11 + +### Fixed +- Remove check for `$viewFactory->exists($component)` in `Illuminate/View/Compilers/ComponentTagCompiler::componentClass` ([7c6db00](https://github.com/laravel/framework/commit/7c6db000928be240dfc6996537a0fed5b8c68ebb)) + + +## [v9.35.0](https://github.com/laravel/framework/compare/v9.34.0...v9.35.0) - 2022-10-11 + +### Added +- Allow loading trashed models for resource routes ([#44405](https://github.com/laravel/framework/pull/44405)) +- Added `Illuminate/Database/Eloquent/Model::shouldBeStrict()` and other ([#44283](https://github.com/laravel/framework/pull/44283)) +- Controller middleware without resolving controller ([#44516](https://github.com/laravel/framework/pull/44516)) +- Alternative Mailable Syntax ([#44462](https://github.com/laravel/framework/pull/44462)) + +### Fixed +- Fix issue with aggregates (withSum, etc.) for pivot columns on self-referencing many-to-many relations ([#44286](https://github.com/laravel/framework/pull/44286)) +- Fixes issue using static class properties as blade attributes ([#44473](https://github.com/laravel/framework/pull/44473)) +- Traversable should have priority over JsonSerializable in EnumerateValues ([#44456](https://github.com/laravel/framework/pull/44456)) +- Fixed `make:cast --inbound` so it's a boolean option, not value ([#44505](https://github.com/laravel/framework/pull/44505)) + +### Changed +- Testing methods. Making error messages with json_encode more readable ([#44397](https://github.com/laravel/framework/pull/44397)) +- Have 'Model::withoutTimestamps()' return the callback's return value ([#44457](https://github.com/laravel/framework/pull/44457)) +- only load trashed models on relevant routes ([#44478](https://github.com/laravel/framework/pull/44478)) +- Adding additional PHP extensions to shouldBlockPhpUpload Function ([#44512](https://github.com/laravel/framework/pull/44512)) +- Register cutInternals casters for particularly noisy objects ([#44514](https://github.com/laravel/framework/pull/44514)) +- Use get methods to access application locale ([#44521](https://github.com/laravel/framework/pull/44521)) +- return only on non empty response from channels ([09d53ee](https://github.com/laravel/framework/commit/09d53eea674db7daa8bb65aa8fa7f2ca95e62b8d), [3944a3e](https://github.com/laravel/framework/commit/3944a3e34fe860633c77b574bbfbbcdabcf7d1e7)) +- Correct channel matching ([#44531](https://github.com/laravel/framework/pull/44531)) +- Migrate mail components ([#44527](https://github.com/laravel/framework/pull/44527)) + + +## [v9.34.0](https://github.com/laravel/framework/compare/v9.33.0...v9.34.0) - 2022-10-04 + +### Added +- Short attribute syntax for Self Closing Blade Components ([#44413](https://github.com/laravel/framework/pull/44413)) +- Adds support for PHP's BackedEnum to be "rendered" on blade views ([#44445](https://github.com/laravel/framework/pull/44445)) + +### Fixed +- Fixed Precognition headers for Symfony responses ([#44424](https://github.com/laravel/framework/pull/44424)) +- Allow to create databases with dots ([#44436](https://github.com/laravel/framework/pull/44436)) +- Fixes dd source on windows ([#44451](https://github.com/laravel/framework/pull/44451)) + +### Changed +- Adds error output to db command when missing host ([#44394](https://github.com/laravel/framework/pull/44394)) +- Changed `Illuminate/Database/Schema/ForeignIdColumnDefinition::constrained()` ([#44425](https://github.com/laravel/framework/pull/44425)) +- Allow maintenance mode events to be listened to in closure based listeners ([#44417](https://github.com/laravel/framework/pull/44417)) +- Allow factories to recycle multiple models of a given typ ([#44328](https://github.com/laravel/framework/pull/44328)) +- Improves dd clickable link on multiple editors and docker environments ([#44406](https://github.com/laravel/framework/pull/44406)) + + +## [v9.33.0](https://github.com/laravel/framework/compare/v9.32.0...v9.33.0) - 2022-09-30 + +### Added +- Added `Illuminate/Support/Testing/Fakes/MailFake::cc()` ([#44319](https://github.com/laravel/framework/pull/44319)) +- Added Ignore Case of Str::contains and Str::containsAll to Stringable contains and containsAll ([#44369](https://github.com/laravel/framework/pull/44369)) +- Added missing morphs methods for the ULID support ([#44364](https://github.com/laravel/framework/pull/44364)) +- Introduce Laravel Precognition ([#44339](https://github.com/laravel/framework/pull/44339)) +- Added `Illuminate/Routing/Route::flushController()` ([#44386](https://github.com/laravel/framework/pull/44386)) + +### Fixed +- Fixes memory leak on PHPUnit's Annotations registry ([#44324](https://github.com/laravel/framework/pull/44324), [#44336](https://github.com/laravel/framework/pull/44336)) +- Fixed `Illuminate/Filesystem/FilesystemAdapter::url()` with config `prefix` ([#44330](https://github.com/laravel/framework/pull/44330)) +- Fixed the "Implicit conversion from float to int loses precision" error in Timebox Class ([#44357](https://github.com/laravel/framework/pull/44357)) + +### Changed +- Improves dd source on compiled views ([#44347](https://github.com/laravel/framework/pull/44347)) +- Only prints source on dd calls from dump.php ([#44367](https://github.com/laravel/framework/pull/44367)) +- Ensures a Carbon version that supports PHP 8.2 ([#44374](https://github.com/laravel/framework/pull/44374)) + + +## [v9.32.0](https://github.com/laravel/framework/compare/v9.31.0...v9.32.0) - 2022-09-27 + +### Added +- New env:encrypt and env:decrypt commands ([#44034](https://github.com/laravel/framework/pull/44034)) +- Share WithoutOverlapping key across jobs ([#44227](https://github.com/laravel/framework/pull/44227)) +- Add missing citext type mapping to `Illuminate/Database/Console/DatabaseInspectionCommand::$typeMappings` ([#44237](https://github.com/laravel/framework/pull/44237)) +- Short attribute syntax for Blade Components ([#44217](https://github.com/laravel/framework/pull/44217)) +- Adds source file to dd function output ([#44211](https://github.com/laravel/framework/pull/44211)) +- Add methods to get request data as integer or float ([#44239](https://github.com/laravel/framework/pull/44239)) +- Adds Eloquent User Provider query handler ([#44226](https://github.com/laravel/framework/pull/44226)) +- Added `Illuminate/Support/Testing/Fakes/BusFake::dispatchFakeBatch()` ([#44176](https://github.com/laravel/framework/pull/44176)) +- Added methods to cast Stringables ([#44238](https://github.com/laravel/framework/pull/44238)) +- Added `Illuminate/Routing/UrlGenerator::withKeyResolver()` ([#44254](https://github.com/laravel/framework/pull/44254)) +- Add a hook to the serialisation of collections ([#44272](https://github.com/laravel/framework/pull/44272)) +- Allow enum route bindings to have default values ([#44255](https://github.com/laravel/framework/pull/44255)) +- Added benchmark utility class ([b4293d7](https://github.com/laravel/framework/commit/b4293d7c18b08b363ac0af64ec04fb1d559b4698), [#44297](https://github.com/laravel/framework/pull/44297)) +- Added `Illuminate/Console/Scheduling/ManagesFrequencies::everyOddHour()` ([#44288](https://github.com/laravel/framework/pull/44288)) + +### Fixed +- Fix incrementing string keys ([#44247](https://github.com/laravel/framework/pull/44247)) +- Fix bug in Fluent Class with named arguments in migrations ([#44251](https://github.com/laravel/framework/pull/44251)) +- Fix "about" command caching report ([#44305](https://github.com/laravel/framework/pull/44305)) +- Fixes memory leaks ([#44306](https://github.com/laravel/framework/pull/44306), [#44307](https://github.com/laravel/framework/pull/44307)) + +### Changed +- Patch for timeless timing attack vulnerability in user login ([#44069](https://github.com/laravel/framework/pull/44069)) +- Refactor: register commands in artisan service ([#44257](https://github.com/laravel/framework/pull/44257)) +- Allow factories to recycle models with for method ([#44265](https://github.com/laravel/framework/pull/44265)) +- Use dedicated method for placeholder replacement in validator ([#44296](https://github.com/laravel/framework/pull/44296)) + + +## [v9.31.0](https://github.com/laravel/framework/compare/v9.30.1...v9.31.0) - 2022-09-20 + +### Added +- Added unique deferrable initially deferred constants for PostgreSQL ([#44127](https://github.com/laravel/framework/pull/44127)) +- Request lifecycle duration handler ([#44122](https://github.com/laravel/framework/pull/44122)) +- Added Model::withoutTimestamps(...) ([#44138](https://github.com/laravel/framework/pull/44138)) +- Added manifestHash function to Illuminate\Foundation\Vite ([#44136](https://github.com/laravel/framework/pull/44136)) +- Added support for operator <=> in `/Illuminate/Collections/Traits/EnumeratesValues::operatorForWhere()` ([#44154](https://github.com/laravel/framework/pull/44154)) +- Added that Illuminate/Database/Connection::registerDoctrineType() can accept object as well as classname for new doctrine type ([#44149](https://github.com/laravel/framework/pull/44149)) +- Added Fake Batches ([#44104](https://github.com/laravel/framework/pull/44104), [#44173](https://github.com/laravel/framework/pull/44173)) +- Added `Model::getAppends()` ([#44180](https://github.com/laravel/framework/pull/44180)) +- Added missing Str::wrap() static method ([#44207](https://github.com/laravel/framework/pull/44207)) +- Added require `symfony/uid` ([#44202](https://github.com/laravel/framework/pull/44202)) +- Make Vite macroable ([#44198](https://github.com/laravel/framework/pull/44198)) + +### Fixed +- Async fix in `Illuminate/Http/Client/PendingRequest` ([#44179](https://github.com/laravel/framework/pull/44179)) +- Fixes artisan serve command with PHP_CLI_SERVER_WORKERS environment variable ([#44204](https://github.com/laravel/framework/pull/44204)) +- Fixed `InteractsWithDatabase::castAsJson($value)` incorrectly handles SQLite Database ([#44196](https://github.com/laravel/framework/pull/44196)) + +### Changed +- Improve Blade compilation exception messages ([#44134](https://github.com/laravel/framework/pull/44134)) +- Improve test failure output ([#43943](https://github.com/laravel/framework/pull/43943)) +- Prompt to create MySQL db when migrating ([#44153](https://github.com/laravel/framework/pull/44153)) +- Improve UUID and ULID support for Eloquent ([#44146](https://github.com/laravel/framework/pull/44146)) + + +## [v9.30.1](https://github.com/laravel/framework/compare/v9.30.0...v9.30.1) - 2022-09-15 + +### Added +- Allow using a model instance in place of nested model factories ([#44107](https://github.com/laravel/framework/pull/44107)) +- Added UUID and ULID support for Eloquent ([#44074](https://github.com/laravel/framework/pull/44074)) +- Implement except method for fake classes to define what should not be faked ([#44117](https://github.com/laravel/framework/pull/44117)) +- Added interacts with queue middleware to send queued mailable ([#44124](https://github.com/laravel/framework/pull/44124)) +- Added new exception string to `Illuminate/Database/DetectsLostConnections` ([#44121](https://github.com/laravel/framework/pull/44121)) + +### Fixed +- Fixed BC from [Passing event into viaQueue and viaConnection of Queued Listener](https://github.com/laravel/framework/pull/44080) ([#44137](https://github.com/laravel/framework/pull/44137)) + +### Changed +- Enhance column modifying ([#44101](https://github.com/laravel/framework/pull/44101)) +- Allow to define which jobs should be actually dispatched when using Bus::fake ([#44106](https://github.com/laravel/framework/pull/44106)) + + +## [v9.30.0](https://github.com/laravel/framework/compare/v9.29.0...v9.30.0) - 2022-09-13 + +### Added +- Added stop_buffering config option to logger ([#44071](https://github.com/laravel/framework/pull/44071)) +- Added read-only filesystem adapter decoration as a config option ([#44079](https://github.com/laravel/framework/pull/44079)) +- Added scoped filesystem driver ([#44105](https://github.com/laravel/framework/pull/44105)) +- Add force option to all make commands ([#44100](https://github.com/laravel/framework/pull/44100)) + +### Fixed +- Fixed QueryBuilder whereNot with array conditions ([#44083](https://github.com/laravel/framework/pull/44083)) + +### Changed +- Passing event into viaQueue and viaConnection of Queued Listener ([#44080](https://github.com/laravel/framework/pull/44080)) +- Improve testability of batched jobs ([#44075](https://github.com/laravel/framework/pull/44075)) +- Allow any kind of whitespace in cron expression ([#44110](https://github.com/laravel/framework/pull/44110)) + + +## [v9.29.0](https://github.com/laravel/framework/compare/v9.28.0...v9.29.0) - 2022-09-09 + +### Added +- Added RequiredIfAccepted validation rule ([#44035](https://github.com/laravel/framework/pull/44035)) +- Added `Illuminate/Foundation/Vite::assetPath()` ([#44037](https://github.com/laravel/framework/pull/44037)) +- Added ability to discard Eloquent Model changes ([#43772](https://github.com/laravel/framework/pull/43772)) +- Added ability to determine if attachments exist to `Illuminate/Mail/Mailable` ([#43967](https://github.com/laravel/framework/pull/43967)) +- Added `Illuminate/Support/Testing/Fakes/BusFake::assertNothingBatched()` ([#44056](https://github.com/laravel/framework/pull/44056)) + +### Reverted +- Reverted [Fixed RoueGroup::merge to format merged prefixes correctly](https://github.com/laravel/framework/pull/44011). ([#44072](https://github.com/laravel/framework/pull/44072)) + +### Fixed +- Avoid Passing null to parameter exception on PHP 8.1 ([#43951](https://github.com/laravel/framework/pull/43951)) +- Align Remember Me Cookie Duration with CookieJar expiration ([#44026](https://github.com/laravel/framework/pull/44026)) +- Fix Stringable typehints with Enumerable ([#44030](https://github.com/laravel/framework/pull/44030)) +- Fixed middleware "SetCacheHeaders" with file responses ([#44063](https://github.com/laravel/framework/pull/44063)) + +### Changed +- Don't use locks for queue job popping for PlanetScale's MySQL-compatible Vitess engine ([#44027](https://github.com/laravel/framework/pull/44027)) +- Avoid matching 'use' in custom Stub templates in `Illuminate/Console/GeneratorCommand.php` ([#44049](https://github.com/laravel/framework/pull/44049)) + + +## [v9.28.0](https://github.com/laravel/framework/compare/v9.27.0...v9.28.0) - 2022-09-06 + +### Added +- Added view data assertions to TestView ([#43923](https://github.com/laravel/framework/pull/43923)) +- Added `Illuminate/Routing/Redirector::getIntendedUrl()` ([#43938](https://github.com/laravel/framework/pull/43938)) +- Added Eloquent mode to prevent prevently silently discarding fills for attributes not in $fillable ([#43893](https://github.com/laravel/framework/pull/43893)) +- Added `Illuminate/Testing/PendingCommand::assertOk()` ([#43968](https://github.com/laravel/framework/pull/43968)) +- Make Application macroable ([#43966](https://github.com/laravel/framework/pull/43966)) +- Introducing Signal Traps ([#43933](https://github.com/laravel/framework/pull/43933)) +- Allow registering instances of commands ([#43986](https://github.com/laravel/framework/pull/43986)) +- Support Enumerable in Stringable ([#44012](https://github.com/laravel/framework/pull/44012)) + +### Fixed +- Fixed RoueGroup::merge to format merged prefixes correctly. ([#44011](https://github.com/laravel/framework/pull/44011)) +- Fixes providesTemporaryUrls on AwsS3V3Adapter ([#44009](https://github.com/laravel/framework/pull/44009)) +- Fix ordering of stylesheets when using @vite ([#43962](https://github.com/laravel/framework/pull/43962)) + +### Changed +- Allow invokable rules to specify custom messsages ([#43925](https://github.com/laravel/framework/pull/43925)) +- Support objects like GMP for custom Model casts ([#43959](https://github.com/laravel/framework/pull/43959)) +- Default 404 message on denyAsNotFound ([#43901](https://github.com/laravel/framework/pull/43901)) +- Changed `Illuminate/Container/Container::resolvePrimitive()` for isVariadic() ([#43985](https://github.com/laravel/framework/pull/43985)) +- Allow validator messages to use nested arrays ([#43981](https://github.com/laravel/framework/pull/43981)) +- Ensure freezeUuids always resets UUID creation after exception in callback ([#44018](https://github.com/laravel/framework/pull/44018)) + + +## [v9.27.0](https://github.com/laravel/framework/compare/v9.26.1...v9.27.0) - 2022-08-30 + +### Added +- Add getter and setter for connection in the DatabaseBatchRepository class ([#43869](https://github.com/laravel/framework/pull/43869)) + +### Fixed +- Fix for potential bug with non-backed enums ([#43842](https://github.com/laravel/framework/pull/43842)) +- Patch nested array validation rule regression bug ([#43897](https://github.com/laravel/framework/pull/43897)) +- Fix registering event listeners with array callback ([#43890](https://github.com/laravel/framework/pull/43890)) + +### Changed +- Explicitly add column name to SQLite query in `Illuminate/Database/Console/DatabaseInspectionCommand::getSqliteTableSize()` ([#43832](https://github.com/laravel/framework/pull/43832)) +- Allow broadcast on demand notifications ([d2b1446](https://github.com/laravel/framework/commit/d2b14466c27a3d62219256cea27088e6ecd9d32f)) +- Make Vite::hotFile() public ([#43875](https://github.com/laravel/framework/pull/43875)) +- Prompt to create sqlite db when migrating ([#43867](https://github.com/laravel/framework/pull/43867)) +- Call prepare() on HttpException responses ([#43895](https://github.com/laravel/framework/pull/43895)) +- Make the model:prune command easier to extend ([#43919](https://github.com/laravel/framework/pull/43919)) + + +## [v9.26.1](https://github.com/laravel/framework/compare/v9.26.0...v9.26.1) - 2022-08-23 + +### Revert +- Revert "[9.x] Add statusText for an assertion message" ([#43831](https://github.com/laravel/framework/pull/43831)) + +### Fixed +- Fixed `withoutVite` ([#43826](https://github.com/laravel/framework/pull/43826)) + + +## [v9.26.0](https://github.com/laravel/framework/compare/v9.25.1...v9.26.0) - 2022-08-23 + +### Added +- Adding support for non-backed enums in Models ([#43728](https://github.com/laravel/framework/pull/43728)) +- Added vite asset url helpers ([#43702](https://github.com/laravel/framework/pull/43702)) +- Added Authentication keyword for SqlServerConnector.php ([#43757](https://github.com/laravel/framework/pull/43757)) +- Added support for additional where* methods to route groups ([#43731](https://github.com/laravel/framework/pull/43731)) +- Added min_digits and max_digits validation ([#43797](https://github.com/laravel/framework/pull/43797)) +- Added closure support to dispatch conditionals in bus ([#43784](https://github.com/laravel/framework/pull/43784)) +- Added configurable paths to Vite ([#43620](https://github.com/laravel/framework/pull/43620)) + +### Fixed +- Fix unique lock release for broadcast events ([#43738](https://github.com/laravel/framework/pull/43738)) +- Fix empty collection class serialization ([#43758](https://github.com/laravel/framework/pull/43758)) +- Fixes creation of deprecations channel ([#43812](https://github.com/laravel/framework/pull/43812)) + +### Changed +- Improve display of failures for assertDatabaseHas ([#43736](https://github.com/laravel/framework/pull/43736)) +- Always use the write PDO connection to read the just stored pending batch in bus ([#43737](https://github.com/laravel/framework/pull/43737)) +- Move unique lock release to method ([#43740](https://github.com/laravel/framework/pull/43740)) +- Remove timeoutAt fallback from Job base class ([#43749](https://github.com/laravel/framework/pull/43749)) +- Convert closures to arrow functions ([#43778](https://github.com/laravel/framework/pull/43778)) +- Use except also in `Illuminate/Routing/Middleware/ValidateSignature::handle()` ([e554d47](https://github.com/laravel/framework/commit/e554d471daab568877c039e955a01cb2f06a2e7b)) +- Adjust forever time for cookies ([#43806](https://github.com/laravel/framework/pull/43806)) +- Make string padding UTF-8 safe ([f1762ed](https://github.com/laravel/framework/commit/f1762ed1660f2a71189f1a32efe5b410ec428268)) + + +## [v9.25.1](https://github.com/laravel/framework/compare/v9.25.0...v9.25.1) - 2022-08-16 + +### Fixes +- [Fixed typos](https://github.com/laravel/framework/compare/v9.25.0...v9.25.1) + + +## [v9.25.0](https://github.com/laravel/framework/compare/v9.24.0...v9.25.0) - 2022-08-16 + +### Added +- Added whenNotExactly to Stringable ([#43700](https://github.com/laravel/framework/pull/43700)) +- Added ability to Model::query()->touch() to mass update timestamps ([#43665](https://github.com/laravel/framework/pull/43665)) + +### Fixed +- Prevent error in db/model commands when using unsupported columns ([#43635](https://github.com/laravel/framework/pull/43635)) +- Fixes ensureDependenciesExist runtime error ([#43626](https://github.com/laravel/framework/pull/43626)) +- Null value for auto-cast field caused deprication warning in php 8.1 ([#43706](https://github.com/laravel/framework/pull/43706)) +- db:table command properly handle table who doesn't exist ([#43669](https://github.com/laravel/framework/pull/43669)) + +### Changed +- Handle assoc mode within db commands ([#43636](https://github.com/laravel/framework/pull/43636)) +- Allow chunkById on Arrays, as well as Models ([#43666](https://github.com/laravel/framework/pull/43666)) +- Allow for int value parameters to whereMonth() and whereDay() ([#43668](https://github.com/laravel/framework/pull/43668)) +- Cleaning up old if-else statement ([#43712](https://github.com/laravel/framework/pull/43712)) +- Ensure correct 'integrity' value is used for css assets ([#43714](https://github.com/laravel/framework/pull/43714)) + + +## [v9.24.0](https://github.com/laravel/framework/compare/v9.23.0...v9.24.0) - 2022-08-09 + +### Added +- New db:show, db:table and db:monitor commands ([#43367](https://github.com/laravel/framework/pull/43367)) +- Added validation doesnt_end_with rule ([#43518](https://github.com/laravel/framework/pull/43518)) +- Added `Illuminate/Database/Eloquent/SoftDeletes::restoreQuietly()` ([#43550](https://github.com/laravel/framework/pull/43550)) +- Added mergeUnless to resource ConditionallyLoadsAttributes trait ([#43567](https://github.com/laravel/framework/pull/43567)) +- Added `Illuminate/Support/Testing/Fakes/NotificationFake::sentNotifications()` ([#43558](https://github.com/laravel/framework/pull/43558)) +- Added `implode` to `Passthru` in `Illuminate/Database/Eloquent/Builder.php` ([#43574](https://github.com/laravel/framework/pull/43574)) +- Make Config repository macroable ([#43598](https://github.com/laravel/framework/pull/43598)) +- Add whenNull to ConditionallyLoadsAtrribute trait ([#43600](https://github.com/laravel/framework/pull/43600)) +- Extract child route model relationship name into a method ([#43597](https://github.com/laravel/framework/pull/43597)) + +### Revert +- Reverted [Added `whereIn` to `Illuminate/Routing/RouteRegistrar::allowedAttributes`](https://github.com/laravel/framework/pull/43509) ([#43523](https://github.com/laravel/framework/pull/43523)) + +### Fixed +- Fix unique locking on broadcast events ([#43516](https://github.com/laravel/framework/pull/43516)) +- Fixes the issue of running docs command on windows ([#43566](https://github.com/laravel/framework/pull/43566), [#43585](https://github.com/laravel/framework/pull/43585)) +- Fixes output when running db:seed or using --seed in migrate commands ([#43593](https://github.com/laravel/framework/pull/43593)) + +### Changed +- Gracefully fail when unable to locate expected binary on the system for artisan docs command ([#43521](https://github.com/laravel/framework/pull/43521)) +- Improve output for some Artisan commands ([#43547](https://github.com/laravel/framework/pull/43547)) +- Alternative database name in Postgres DSN, allow pgbouncer aliased databases to continue working on 9.x ([#43542](https://github.com/laravel/framework/pull/43542)) +- Allow @class() for component tags ([#43140](https://github.com/laravel/framework/pull/43140)) +- Attribute Cast Performance Improvements ([#43554](https://github.com/laravel/framework/pull/43554)) +- Queue worker daemon should also listen for SIGQUIT ([#43607](https://github.com/laravel/framework/pull/43607)) +- Keep original keys when using Collection->sortBy() with an array of sort operations ([#43609](https://github.com/laravel/framework/pull/43609)) + + +## [v9.23.0](https://github.com/laravel/framework/compare/v9.22.1...v9.23.0) - 2022-08-02 + +### Added +- Added whereNot method to Fluent JSON testing matchers ([#43383](https://github.com/laravel/framework/pull/43383)) +- Added deleteQuietly method to Model and use arrow func for related methods ([#43447](https://github.com/laravel/framework/pull/43447)) +- Added conditionable trait to Filesystem adapters ([#43450](https://github.com/laravel/framework/pull/43450)) +- Introduce artisan docs command ([#43357](https://github.com/laravel/framework/pull/43357)) +- Added Support CSP nonce, SRI, and arbitrary attributes with Vite ([#43442](https://github.com/laravel/framework/pull/43442)) +- Support conditionables that get condition from target object ([#43449](https://github.com/laravel/framework/pull/43449)) +- Added `whereIn` to `Illuminate/Routing/RouteRegistrar::allowedAttributes` ([#43509](https://github.com/laravel/framework/pull/43509)) + +### Fixed +- Prevent redis crash when large number of jobs are scheduled for a specific time ([#43310](https://github.com/laravel/framework/pull/43310)) + +### Changed +- Make Command components Factory extensible ([#43439](https://github.com/laravel/framework/pull/43439)) +- Solve Blade component showing quote formatted for the console ([#43446](https://github.com/laravel/framework/pull/43446)) +- Improves output capture from serve command ([#43461](https://github.com/laravel/framework/pull/43461)) +- Allow terser singleton bindings ([#43469](https://github.com/laravel/framework/pull/43469)) + + +## [v9.22.1](https://github.com/laravel/framework/compare/v9.22.0...v9.22.1) - 2022-07-26 + +### Added +- Added unique locking to broadcast events ([#43416](https://github.com/laravel/framework/pull/43416)) + +### Fixed +- Fixes Artisan serve command on Windows ([#43437](https://github.com/laravel/framework/pull/43437)) + + +## [v9.22.0](https://github.com/laravel/framework/compare/v9.21.6...v9.22.0) - 2022-07-26 + +### Added +- Added ability to attach an array of files in MailMessage ([#43080](https://github.com/laravel/framework/pull/43080)) +- Added conditional lines to MailMessage ([#43387](https://github.com/laravel/framework/pull/43387)) +- Add support for multiple hash algorithms to `Illuminate/Filesystem/Filesystem::hash()` ([#43407](https://github.com/laravel/framework/pull/43407)) + +### Fixed +- Fixes for model:show when attribute default is an enum ([#43370](https://github.com/laravel/framework/pull/43370)) +- Fixed DynamoDB locks with 0 seconds duration ([#43365](https://github.com/laravel/framework/pull/43365)) +- Fixed overriding global locale ([#43426](https://github.com/laravel/framework/pull/43426)) + +### Changed +- Round milliseconds in console output runtime ([#43400](https://github.com/laravel/framework/pull/43400)) +- Improves serve Artisan command ([#43375](https://github.com/laravel/framework/pull/43375)) + + +## [v9.21.6](https://github.com/laravel/framework/compare/v9.21.5...v9.21.6) - 2022-07-22 + +### Revert +- Revert ["Protect against ambiguous columns"](https://github.com/laravel/framework/pull/43278) ([#43362](https://github.com/laravel/framework/pull/43362)) + +### Fixed +- Fixes default attribute value when using enums on model:show ([#43360](https://github.com/laravel/framework/pull/43360)) + + +## [v9.21.5](https://github.com/laravel/framework/compare/v9.21.4...v9.21.5) - 2022-07-21 + +### Added +- Adds fluent File validation rule ([#43271](https://github.com/laravel/framework/pull/43271)) + +### Revert +- Revert ["Prevent double throwing chained exception on sync queue"](https://github.com/laravel/framework/pull/42950) ([#43354](https://github.com/laravel/framework/pull/43354)) + + +### Changed +- Allow section payload to be lazy in the "about" command ([#43329](https://github.com/laravel/framework/pull/43329)) + + +## [v9.21.4](https://github.com/laravel/framework/compare/v9.21.3...v9.21.4) - 2022-07-21 + +### Added +- Added `Illuminate/Filesystem/FilesystemAdapter::supportsTemporaryUrl()` ([#43317](https://github.com/laravel/framework/pull/43317)) + +### Fixed +- Fixes confirm component default value ([#43334](https://github.com/laravel/framework/pull/43334)) + +### Changed +- Improves console output when command not found ([#43323](https://github.com/laravel/framework/pull/43323)) + + +## [v9.21.3](https://github.com/laravel/framework/compare/v9.21.2...v9.21.3) - 2022-07-20 + +### Fixed +- Fixes usage of Migrator without output ([#43326](https://github.com/laravel/framework/pull/43326)) + + +## [v9.21.2](https://github.com/laravel/framework/compare/v9.21.1...v9.21.2) - 2022-07-20 + +### Fixed +- Fixes queue:monitor command dispatching QueueBusy ([#43320](https://github.com/laravel/framework/pull/43320)) +- Ensure relation names are properly "snaked" in JsonResource::whenCounted() method ([#43322](https://github.com/laravel/framework/pull/43322)) +- Fixed Bootstrap 5 pagination ([#43319](https://github.com/laravel/framework/pull/43319)) + + +## [v9.21.1](https://github.com/laravel/framework/compare/v9.21.0...v9.21.1) - 2022-07-20 + +### Added +- Added "Logs" driver to the about command ([#43307](https://github.com/laravel/framework/pull/43307)) +- Allows to install doctrine/dbal from model:show command ([#43288](https://github.com/laravel/framework/pull/43288)) +- Added to stub publish command flag that restricts to only existing files ([#43314](https://github.com/laravel/framework/pull/43314)) + +### Fixed +- Fixes for model:show command ([#43301](https://github.com/laravel/framework/pull/43301)) + +### Changed +- Handle varying composer -V output ([#43286](https://github.com/laravel/framework/pull/43286)) +- Replace resolve() with app() for Lumen compatible ([#43312](https://github.com/laravel/framework/pull/43312)) +- Allow using backed enums as route parameters ([#43294](https://github.com/laravel/framework/pull/43294)) + + +## [v9.21.0](https://github.com/laravel/framework/compare/v9.20.0...v9.21.0) - 2022-07-19 + +### Added +- Added inspiring quote ([#43180](https://github.com/laravel/framework/pull/43180), [#43189](https://github.com/laravel/framework/pull/43189)) +- Introducing a fresh new look for Artisan ([#43065](https://github.com/laravel/framework/pull/43065)) +- Added whenCounted to JsonResource ([#43101](https://github.com/laravel/framework/pull/43101)) +- Artisan model:show command ([#43156](https://github.com/laravel/framework/pull/43156)) +- Artisan `about` Command ([#43147](https://github.com/laravel/framework/pull/43147), [51b5eda](https://github.com/laravel/framework/commit/51b5edaa2f8dfb0acb520ecb394706ade2200a35), [#43225](https://github.com/laravel/framework/pull/43225), [#43276](https://github.com/laravel/framework/pull/43276)) +- Adds enum casting to Request ([#43239](https://github.com/laravel/framework/pull/43239)) + +### Revert +- Revert ["Fix default parameter bug in routes"](https://github.com/laravel/framework/pull/42942) ([#43208](https://github.com/laravel/framework/pull/43208)) +- Revert route change PR ([#43255](https://github.com/laravel/framework/pull/43255)) + +### Fixed +- Fix transaction attempts counter for sqlsrv ([#43176](https://github.com/laravel/framework/pull/43176)) + +### Changed +- Make assertDatabaseHas failureDescription more multibyte character friendly ([#43181](https://github.com/laravel/framework/pull/43181)) +- ValidationException summarize only when use strings ([#43177](https://github.com/laravel/framework/pull/43177)) +- Improve mode function in collection ([#43240](https://github.com/laravel/framework/pull/43240)) +- clear Facade resolvedInstances in queue worker resetScope callback ([#43215](https://github.com/laravel/framework/pull/43215)) +- Improves queue:work command ([#43252](https://github.com/laravel/framework/pull/43252)) +- Remove null default attributes names when UPDATED_AT or CREATED_AT is null at Model::replicate ([#43279](https://github.com/laravel/framework/pull/43279)) +- Protect against ambiguous columns ([#43278](https://github.com/laravel/framework/pull/43278)) +- Use readpast query hint instead of holdlock for sqlsrv database queue ([#43259](https://github.com/laravel/framework/pull/43259)) +- Vendor publish flag that restricts to only existing files ([#43212](https://github.com/laravel/framework/pull/43212)) + + +## [v9.20.0](https://github.com/laravel/framework/compare/v9.19.0...v9.20.0) - 2022-07-13 + +### Added +- Added quote from Mustafa Kemal Atatürk ([#43022](https://github.com/laravel/framework/pull/43022)) +- Allow Collection random() to accept a callable ([#43028](https://github.com/laravel/framework/pull/43028)) +- Added `Str::inlineMarkdown()` ([#43126](https://github.com/laravel/framework/pull/43126)) +- Allow authorization responses to specify HTTP status codes ([#43097](https://github.com/laravel/framework/pull/43097)) +- Added required directive ([#43103](https://github.com/laravel/framework/pull/43103)) +- Added replicateQuietly to Model ([#43141](https://github.com/laravel/framework/pull/43141)) +- Added ignore param to ValidateSignature middleware ([#43160](https://github.com/laravel/framework/pull/43160)) + +### Fixed +- Fixed forceCreate on MorphMany not returning newly created object ([#42996](https://github.com/laravel/framework/pull/42996)) +- Fixed missing return in `Illuminate/Mail/Attachment::fromStorageDisk()` ([#43023](https://github.com/laravel/framework/pull/43023)) +- Fixed inconsistent content type when using ResponseSequence ([#43051](https://github.com/laravel/framework/pull/43051)) +- Prevent double throwing chained exception on sync queue ([#42950](https://github.com/laravel/framework/pull/42950)) +- Avoid matching multi-line imports in GenerateCommand stub templates ([#43093](https://github.com/laravel/framework/pull/43093)) + +### Changed +- Disable Column Statistics for php artisan schema:dump on MariaDB ([#43027](https://github.com/laravel/framework/pull/43027)) +- Bind a Vite Null Object to the Container instead of a Closure in `Illuminate/Foundation/Testing/Concerns/InteractsWithContainer::withoutVite()` ([#43040](https://github.com/laravel/framework/pull/43040)) +- Early return when message format is the default in `Illuminate/Support/MessageBag::transform()` ([#43149](https://github.com/laravel/framework/pull/43149)) + + +## [v9.19.0](https://github.com/laravel/framework/compare/v9.18.0...v9.19.0) - 2022-06-28 + +### Added +- Add new allowMaxRedirects method to PendingRequest ([#42902](https://github.com/laravel/framework/pull/42902)) +- Add support to detect dirty encrypted model attributes ([#42888](https://github.com/laravel/framework/pull/42888)) +- Added Vite ([#42785](https://github.com/laravel/framework/pull/42785)) + +### Fixed +- Fixed bug on forceCreate on a MorphMay relationship not including morph type ([#42929](https://github.com/laravel/framework/pull/42929)) +- Fix default parameter bug in routes ([#42942](https://github.com/laravel/framework/pull/42942)) +- Handle cursor paginator when no items are found ([#42963](https://github.com/laravel/framework/pull/42963)) +- Fix undefined constant error when use slot name as key of object ([#42943](https://github.com/laravel/framework/pull/42943)) +- Fix BC break for Log feature tests ([#42987](https://github.com/laravel/framework/pull/42987)) + +### Changed +- Allow instance of Enum pass Enum Rule ([#42906](https://github.com/laravel/framework/pull/42906)) ## [v9.18.0](https://github.com/laravel/framework/compare/v9.17.0...v9.18.0) - 2022-06-21 diff --git a/README.md b/README.md index 6e9702b3a98c..df935e86ac3f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Laravel is accessible, yet powerful, providing tools needed for large, robust ap Laravel has the most extensive and thorough documentation and video tutorial library of any modern web application framework. The [Laravel documentation](https://laravel.com/docs) is in-depth and complete, making it a breeze to get started learning the framework. +You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch. + If you're not in the mood to read, [Laracasts](https://laracasts.com) contains over 1100 video tutorials covering a range of topics including Laravel, modern PHP, unit testing, JavaScript, and more. Boost the skill level of yourself and your entire team by digging into our comprehensive video library. ## Contributing diff --git a/bin/facades.php b/bin/facades.php new file mode 100644 index 000000000000..ab80c41b8988 --- /dev/null +++ b/bin/facades.php @@ -0,0 +1,769 @@ +in(__DIR__.'/../src/Illuminate/Support/Facades') + ->notName('Facade.php'); + +resolveFacades($finder)->each(function ($facade) use ($linting) { + $proxies = resolveDocSees($facade); + + // Build a list of methods that are available on the Facade... + + $resolvedMethods = $proxies->map(fn ($fqcn) => new ReflectionClass($fqcn)) + ->flatMap(fn ($class) => [$class, ...resolveDocMixins($class)]) + ->flatMap(resolveMethods(...)) + ->reject(isMagic(...)) + ->reject(isInternal(...)) + ->reject(isDeprecated(...)) + ->reject(fulfillsBuiltinInterface(...)) + ->reject(fn ($method) => conflictsWithFacade($facade, $method)) + ->unique(resolveName(...)) + ->map(normaliseDetails(...)); + + // Prepare the @method docblocks... + + $methods = $resolvedMethods->map(function ($method) { + if (is_string($method)) { + return " * @method static {$method}"; + } + + $parameters = $method['parameters']->map(function ($parameter) { + $rest = $parameter['variadic'] ? '...' : ''; + + $default = $parameter['optional'] ? ' = '.resolveDefaultValue($parameter) : ''; + + return "{$parameter['type']} {$rest}{$parameter['name']}{$default}"; + }); + + return " * @method static {$method['returns']} {$method['name']}({$parameters->join(', ')})"; + }); + + // Fix: ensure we keep the references to the Carbon library on the Date Facade... + + if ($facade->getName() === Date::class) { + $methods->prepend(' *') + ->prepend(' * @see https://github.com/briannesbitt/Carbon/blob/master/src/Carbon/Factory.php') + ->prepend(' * @see https://carbon.nesbot.com/docs/'); + } + + // To support generics, we want to preserve any mixins on the class... + + $directMixins = resolveDocTags($facade->getDocComment() ?: '', '@mixin'); + + // Generate the docblock... + + $docblock = <<< PHP + /** + {$methods->join(PHP_EOL)} + * + {$proxies->map(fn ($class) => " * @see {$class}")->merge($proxies->isNotEmpty() && $directMixins->isNotEmpty() ? [' *'] : [])->merge($directMixins->map(fn ($class) => " * @mixin {$class}"))->join(PHP_EOL)} + */ + PHP; + + if (($facade->getDocComment() ?: '') === $docblock) { + return; + } + + if ($linting) { + echo "Did not find expected docblock for [{$facade->getName()}].".PHP_EOL.PHP_EOL; + echo $docblock.PHP_EOL.PHP_EOL; + echo 'Run the following command to update your docblocks locally:'.PHP_EOL.'php -f bin/facades.php'; + exit(1); + } + + // Update the facade docblock... + + echo "Updating docblock for [{$facade->getName()}].".PHP_EOL; + $contents = file_get_contents($facade->getFileName()); + $contents = Str::replace($facade->getDocComment(), $docblock, $contents); + file_put_contents($facade->getFileName(), $contents); +}); + +echo 'Done.'; +exit(0); + +/** + * Resolve the facades from the given directory. + * + * @param \Symfony\Component\Finder\Finder $finder + * @return \Illuminate\Support\Collection<\ReflectionClass> + */ +function resolveFacades($finder) +{ + return collect($finder) + ->map(fn ($file) => $file->getBaseName('.php')) + ->map(fn ($name) => "\\Illuminate\\Support\\Facades\\{$name}") + ->map(fn ($class) => new ReflectionClass($class)); +} + +/** + * Resolve the classes referenced in the @see docblocks. + * + * @param \ReflectionClass $class + * @return \Illuminate\Support\Collection + */ +function resolveDocSees($class) +{ + return resolveDocTags($class->getDocComment() ?: '', '@see') + ->reject(fn ($tag) => Str::startsWith($tag, 'https://')); +} + +/** + * Resolve the classes referenced methods in the @methods docblocks. + * + * @param \ReflectionClass $class + * @return \Illuminate\Support\Collection + */ +function resolveDocMethods($class) +{ + return resolveDocTags($class->getDocComment() ?: '', '@method') + ->map(fn ($tag) => Str::squish($tag)) + ->map(fn ($tag) => Str::before($tag, ')').')'); +} + +/** + * Resolve the parameters type from the @param docblocks. + * + * @param \ReflectionMethodDecorator $method + * @param \ReflectionParameter $parameter + * @return string|null + */ +function resolveDocParamType($method, $parameter) +{ + $paramTypeNode = collect(parseDocblock($method->getDocComment())->getParamTagValues()) + ->firstWhere('parameterName', '$'.$parameter->getName()); + + // As we didn't find a param type, we will now recursivly check if the prototype has a value specified... + + if ($paramTypeNode === null) { + try { + $prototype = new ReflectionMethodDecorator($method->getPrototype(), $method->sourceClass()->getName()); + + return resolveDocParamType($prototype, $parameter); + } catch (Throwable) { + return null; + } + } + + $type = resolveDocblockTypes($method, $paramTypeNode->type); + + return is_string($type) ? trim($type, '()') : null; +} + +/** + * Resolve the return type from the @return docblock. + * + * @param \ReflectionMethodDecorator $method + * @return string|null + */ +function resolveReturnDocType($method) +{ + $returnTypeNode = array_values(parseDocblock($method->getDocComment())->getReturnTagValues())[0] ?? null; + + if ($returnTypeNode === null) { + return null; + } + + $type = resolveDocblockTypes($method, $returnTypeNode->type); + + return is_string($type) ? trim($type, '()') : null; +} + +/** + * Parse the given docblock. + * + * @param string $docblock + * @return \PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode + */ +function parseDocblock($docblock) +{ + return (new PhpDocParser(new TypeParser(new ConstExprParser), new ConstExprParser))->parse( + new TokenIterator((new Lexer)->tokenize($docblock ?: '/** */')) + ); +} + +/** + * Resolve the types from the docblock. + * + * @param \ReflectionMethodDecorator $method + * @param \PHPStan\PhpDocParser\Ast\Type\TypeNode $typeNode + * @return string + */ +function resolveDocblockTypes($method, $typeNode) +{ + if ($typeNode instanceof UnionTypeNode) { + return '('.collect($typeNode->types) + ->map(fn ($node) => resolveDocblockTypes($method, $node)) + ->unique() + ->implode('|').')'; + } + + if ($typeNode instanceof IntersectionTypeNode) { + return '('.collect($typeNode->types) + ->map(fn ($node) => resolveDocblockTypes($method, $node)) + ->unique() + ->implode('&').')'; + } + + if ($typeNode instanceof GenericTypeNode) { + return resolveDocblockTypes($method, $typeNode->type); + } + + if ($typeNode instanceof ThisTypeNode) { + return '\\'.$method->sourceClass()->getName(); + } + + if ($typeNode instanceof ArrayTypeNode) { + return resolveDocblockTypes($method, $typeNode->type).'[]'; + } + + if ($typeNode instanceof IdentifierTypeNode) { + if ($typeNode->name === 'static') { + return '\\'.$method->sourceClass()->getName(); + } + + if ($typeNode->name === 'self') { + return '\\'.$method->getDeclaringClass()->getName(); + } + + if (isBuiltIn($typeNode->name)) { + return (string) $typeNode; + } + + if ($typeNode->name === 'class-string') { + return 'string'; + } + + $guessedFqcn = resolveClassImports($method->getDeclaringClass())->get($typeNode->name) ?? '\\'.$method->getDeclaringClass()->getNamespaceName().'\\'.$typeNode->name; + + foreach ([$typeNode->name, $guessedFqcn] as $name) { + if (class_exists($name)) { + return (string) $name; + } + + if (interface_exists($name)) { + return (string) $name; + } + + if (enum_exists($name)) { + return (string) $name; + } + + if (isKnownOptionalDependency($name)) { + return (string) $name; + } + } + + return handleUnknownIdentifierType($method, $typeNode); + } + + if ($typeNode instanceof ConditionalTypeNode) { + return handleConditionalType($method, $typeNode); + } + + if ($typeNode instanceof NullableTypeNode) { + return '?'.resolveDocblockTypes($method, $typeNode->type); + } + + if ($typeNode instanceof CallableTypeNode) { + return resolveDocblockTypes($method, $typeNode->identifier); + } + + echo 'Unhandled type: '.$typeNode::class; + echo PHP_EOL; + echo 'You may need to update the `resolveDocblockTypes` to handle this type.'; + echo PHP_EOL; +} + +/** + * Handle conditional types. + * + * @param \ReflectionMethodDecorator $method + * @param \PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode $typeNode + * @return string + */ +function handleConditionalType($method, $typeNode) +{ + if ( + in_array($method->getname(), ['pull', 'get']) && + $method->getDeclaringClass()->getName() === Repository::class + ) { + return 'mixed'; + } + + echo 'Found unknown conditional type. You will need to update the `handleConditionalType` to handle this new conditional type.'; + echo PHP_EOL; +} + +/** + * Handle unknown identifier types. + * + * @param \ReflectionMethodDecorator $method + * @param \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode $typeNode + * @return string + */ +function handleUnknownIdentifierType($method, $typeNode) +{ + if ( + $typeNode->name === 'TCacheValue' && + $method->getDeclaringClass()->getName() === Repository::class + ) { + return 'mixed'; + } + + if ( + $typeNode->name === 'TWhenParameter' && + in_array(Conditionable::class, class_uses_recursive($method->getDeclaringClass()->getName())) + ) { + return 'mixed'; + } + + if ( + $typeNode->name === 'TWhenReturnType' && + in_array(Conditionable::class, class_uses_recursive($method->getDeclaringClass()->getName())) + ) { + return 'mixed'; + } + + if ( + $typeNode->name === 'TUnlessParameter' && + in_array(Conditionable::class, class_uses_recursive($method->getDeclaringClass()->getName())) + ) { + return 'mixed'; + } + + if ( + $typeNode->name === 'TUnlessReturnType' && + in_array(Conditionable::class, class_uses_recursive($method->getDeclaringClass()->getName())) + ) { + return 'mixed'; + } + + if ( + $typeNode->name === 'TEnum' && + $method->getDeclaringClass()->getName() === Request::class + ) { + return 'object'; + } + + echo 'Found unknown type: '.$typeNode->name; + echo PHP_EOL; + echo 'You may need to update the `handleUnknownIdentifierType` to handle this new type / generic.'; + echo PHP_EOL; +} + +/** + * Determine if the type is a built-in. + * + * @param string $type + * @return bool + */ +function isBuiltIn($type) +{ + return in_array($type, [ + 'null', 'bool', 'int', 'float', 'string', 'array', 'object', + 'resource', 'never', 'void', 'mixed', 'iterable', 'self', 'static', + 'parent', 'true', 'false', 'callable', + ]); +} + +/** + * Determine if the type is known optional dependency. + * + * @param string $type + * @return bool + */ +function isKnownOptionalDependency($type) +{ + return in_array($type, [ + '\Pusher\Pusher', + '\GuzzleHttp\Psr7\RequestInterface', + ]); +} + +/** + * Resolve the declared type. + * + * @param \ReflectionType|null $type + * @return string|null + */ +function resolveType($type) +{ + if ($type instanceof ReflectionIntersectionType) { + return collect($type->getTypes()) + ->map(resolveType(...)) + ->filter() + ->join('&'); + } + + if ($type instanceof ReflectionUnionType) { + return collect($type->getTypes()) + ->map(resolveType(...)) + ->filter() + ->join('|'); + } + + if ($type instanceof ReflectionNamedType && $type->getName() === 'null') { + return ($type->isBuiltin() ? '' : '\\').$type->getName(); + } + + if ($type instanceof ReflectionNamedType && $type->getName() !== 'null') { + return ($type->isBuiltin() ? '' : '\\').$type->getName().($type->allowsNull() ? '|null' : ''); + } + + return null; +} + +/** + * Resolve the docblock tags. + * + * @param string $docblock + * @param string $tag + * @return \Illuminate\Support\Collection + */ +function resolveDocTags($docblock, $tag) +{ + return Str::of($docblock) + ->explode("\n") + ->skip(1) + ->reverse() + ->skip(1) + ->reverse() + ->map(fn ($line) => ltrim($line, ' \*')) + ->filter(fn ($line) => Str::startsWith($line, $tag)) + ->map(fn ($line) => Str::of($line)->after($tag)->trim()->toString()) + ->values(); +} + +/** + * Recursivly resolve docblock mixins. + * + * @param \ReflectionClass $class + * @return \Illuminate\Support\Collection<\ReflectionClass> + */ +function resolveDocMixins($class) +{ + return resolveDocTags($class->getDocComment() ?: '', '@mixin') + ->map(fn ($mixin) => new ReflectionClass($mixin)) + ->flatMap(fn ($mixin) => [$mixin, ...resolveDocMixins($mixin)]); +} + +/** + * Resolve the classes referenced methods in the @methods docblocks. + * + * @param \ReflectionMethodDecorator $method + * @return \Illuminate\Support\Collection + */ +function resolveDocParameters($method) +{ + return resolveDocTags($method->getDocComment() ?: '', '@param') + ->map(fn ($tag) => Str::squish($tag)); +} + +/** + * Determine if the method is magic. + * + * @param \ReflectionMethod|string $method + * @return bool + */ +function isMagic($method) +{ + return Str::startsWith(is_string($method) ? $method : $method->getName(), '__'); +} + +/** + * Determine if the method is marked as @internal. + * + * @param \ReflectionMethod|string $method + * @return bool + */ +function isInternal($method) +{ + if (is_string($method)) { + return false; + } + + return resolveDocTags($method->getDocComment(), '@internal')->isNotEmpty(); +} + +/** + * Determine if the method is deprecated. + * + * @param \ReflectionMethod|string $method + * @return bool + */ +function isDeprecated($method) +{ + if (is_string($method)) { + return false; + } + + return $method->isDeprecated() || resolveDocTags($method->getDocComment(), '@deprecated')->isNotEmpty(); +} + +/** + * Determine if the method is for a builtin contract. + * + * @param \ReflectionMethodDecorator|string $method + * @return bool + */ +function fulfillsBuiltinInterface($method) +{ + if (is_string($method)) { + return false; + } + + if ($method->sourceClass()->implementsInterface(ArrayAccess::class)) { + return in_array($method->getName(), ['offsetExists', 'offsetGet', 'offsetSet', 'offsetUnset']); + } + + return false; +} + +/** + * Resolve the methods name. + * + * @param \ReflectionMethod|string $method + * @return string + */ +function resolveName($method) +{ + return is_string($method) + ? Str::of($method)->after(' ')->before('(')->toString() + : $method->getName(); +} + +/** + * Resolve the classes methods. + * + * @param \ReflectionClass $class + * @return \Illuminate\Support\Collection<\ReflectionMethodDecorator|string> + */ +function resolveMethods($class) +{ + return collect($class->getMethods(ReflectionMethod::IS_PUBLIC)) + ->map(fn ($method) => new ReflectionMethodDecorator($method, $class->getName())) + ->merge(resolveDocMethods($class)); +} + +/** + * Determine if the given method conflicts with a Facade method. + * + * @param \ReflectionClass $facade + * @param \ReflectionMethod|string $method + * @return bool + */ +function conflictsWithFacade($facade, $method) +{ + return collect($facade->getMethods(ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC)) + ->map(fn ($method) => $method->getName()) + ->contains(is_string($method) ? $method : $method->getName()); +} + +/** + * Normalise the method details into a easier format to work with. + * + * @param \ReflectionMethodDecorator|string $method + * @return array|string + */ +function normaliseDetails($method) +{ + return is_string($method) ? $method : [ + 'name' => $method->getName(), + 'parameters' => resolveParameters($method) + ->map(fn ($parameter) => [ + 'name' => '$'.$parameter->getName(), + 'optional' => $parameter->isOptional() && ! $parameter->isVariadic(), + 'default' => $parameter->isDefaultValueAvailable() + ? $parameter->getDefaultValue() + : "❌ Unknown default for [{$parameter->getName()}] in [{$parameter->getDeclaringClass()?->getName()}::{$parameter->getDeclaringFunction()->getName()}] ❌", + 'variadic' => $parameter->isVariadic(), + 'type' => resolveDocParamType($method, $parameter) ?? resolveType($parameter->getType()) ?? 'void', + ]), + 'returns' => resolveReturnDocType($method) ?? resolveType($method->getReturnType()) ?? 'void', + ]; +} + +/** + * Resolve the parameters for the method. + * + * @param \ReflectionMethodDecorator $method + * @return \Illuminate\Support\Collection + */ +function resolveParameters($method) +{ + $dynamicParameters = resolveDocParameters($method) + ->skip($method->getNumberOfParameters()) + ->mapInto(DynamicParameter::class); + + return collect($method->getParameters())->merge($dynamicParameters); +} + +/** + * Resolve the classes imports. + * + * @param \ReflectionClass $class + * @return \Illuminate\Support\Collection + */ +function resolveClassImports($class) +{ + return Str::of(file_get_contents($class->getFileName())) + ->explode(PHP_EOL) + ->take($class->getStartLine() - 1) + ->filter(fn ($line) => preg_match('/^use [A-Za-z0-9\\\\]+( as [A-Za-z0-9]+)?;$/', $line) === 1) + ->map(fn ($line) => Str::of($line)->after('use ')->before(';')) + ->mapWithKeys(fn ($class) => [ + ($class->contains(' as ') ? $class->after(' as ') : $class->classBasename())->toString() => $class->start('\\')->before(' as ')->toString(), + ]); +} + +/** + * Resolve the default value for the parameter. + * + * @param array $parameter + * @return string + */ +function resolveDefaultValue($parameter) +{ + // Reflection limitation fix for: + // - Illuminate\Filesystem\Filesystem::ensureDirectoryExists() + // - Illuminate\Filesystem\Filesystem::makeDirectory() + if ($parameter['name'] === '$mode' && $parameter['default'] === 493) { + return '0755'; + } + + $default = json_encode($parameter['default']); + + return Str::of($default === false ? 'unknown' : $default) + ->replace('"', "'") + ->replace('\\/', '/') + ->toString(); +} + +/** + * @mixin \ReflectionMethod + */ +class ReflectionMethodDecorator +{ + /** + * @param \ReflectionMethod $method + * @param class-string $sourceClass + */ + public function __construct(private $method, private $sourceClass) + { + // + } + + /** + * @param string $name + * @param array $arguments + * @return mixed + */ + public function __call($name, $arguments) + { + return $this->method->{$name}(...$arguments); + } + + /** + * @return \ReflectionMethod + */ + public function toBase() + { + return $this->method; + } + + /** + * @return \ReflectionClass + */ + public function sourceClass() + { + return new ReflectionClass($this->sourceClass); + } +} + +class DynamicParameter +{ + /** + * @param string $definition + */ + public function __construct(private $definition) + { + // + } + + /** + * @return string + */ + public function getName() + { + return Str::of($this->definition) + ->after('$') + ->before(' ') + ->toString(); + } + + /** + * @return bool + */ + public function isOptional() + { + return true; + } + + /** + * @return bool + */ + public function isVariadic() + { + return Str::contains($this->definition, " ...\${$this->getName()}"); + } + + /** + * @return bool + */ + public function isDefaultValueAvailable() + { + return true; + } + + /** + * @return null + */ + public function getDefaultValue() + { + return null; + } +} diff --git a/composer.json b/composer.json index 74f27ac25ba7..1e3136f3198b 100644 --- a/composer.json +++ b/composer.json @@ -16,22 +16,30 @@ ], "require": { "php": "^8.0.2", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", "ext-mbstring": "*", "ext-openssl": "*", - "doctrine/inflector": "^2.0", - "dragonmantank/cron-expression": "^3.1", - "egulias/email-validator": "^3.1", + "ext-session": "*", + "ext-tokenizer": "*", + "brick/math": "^0.9.3|^0.10.2|^0.11", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.3.2", + "egulias/email-validator": "^3.2.1|^4.0", "fruitcake/php-cors": "^1.2", - "laravel/serializable-closure": "^1.0", - "league/commonmark": "^2.2", - "league/flysystem": "^3.0.16", + "guzzlehttp/uri-template": "^1.0", + "laravel/serializable-closure": "^1.2.2", + "league/commonmark": "^2.2.1", + "league/flysystem": "^3.8.0", "monolog/monolog": "^2.0", - "nesbot/carbon": "^2.53.1", + "nesbot/carbon": "^2.62.1", + "nunomaduro/termwind": "^1.13", "psr/container": "^1.1.1|^2.0.1", "psr/log": "^1.0|^2.0|^3.0", "psr/simple-cache": "^1.0|^2.0|^3.0", - "ramsey/uuid": "^4.2.2", - "symfony/console": "^6.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^6.0.9", "symfony/error-handler": "^6.0", "symfony/finder": "^6.0", "symfony/http-foundation": "^6.0", @@ -40,8 +48,9 @@ "symfony/mime": "^6.0", "symfony/process": "^6.0", "symfony/routing": "^6.0", + "symfony/uid": "^6.0", "symfony/var-dumper": "^6.0", - "tijsverkoyen/css-to-inline-styles": "^2.2.2", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", "vlucas/phpdotenv": "^5.4.1", "voku/portable-ascii": "^2.0" }, @@ -80,20 +89,26 @@ "illuminate/view": "self.version" }, "require-dev": { - "aws/aws-sdk-php": "^3.198.1", + "ext-gmp": "*", + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.235.5", "doctrine/dbal": "^2.13.3|^3.1.4", - "fakerphp/faker": "^1.9.2", - "guzzlehttp/guzzle": "^7.2", + "fakerphp/faker": "^1.21", + "guzzlehttp/guzzle": "^7.5", "league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-ftp": "^3.0", + "league/flysystem-path-prefixing": "^3.3", + "league/flysystem-read-only": "^3.3", "league/flysystem-sftp-v3": "^3.0", - "mockery/mockery": "^1.4.4", - "orchestra/testbench-core": "^7.1", + "mockery/mockery": "^1.5.1", + "orchestra/testbench-core": "^7.24", "pda/pheanstalk": "^4.0", + "phpstan/phpdoc-parser": "^1.15", "phpstan/phpstan": "^1.4.7", "phpunit/phpunit": "^9.5.8", - "predis/predis": "^1.1.9|^2.0", - "symfony/cache": "^6.0" + "predis/predis": "^1.1.9|^2.0.2", + "symfony/cache": "^6.0", + "symfony/http-client": "^6.0" }, "provide": { "psr/container-implementation": "1.1|2.0", @@ -132,29 +147,33 @@ } }, "suggest": { - "ext-bcmath": "Required to use the multiple_of validation rule.", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", "ext-memcached": "Required to use the memcache cache driver.", - "ext-pcntl": "Required to use all features of the queue worker.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0).", "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", - "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.198.1).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.235.5).", "brianium/paratest": "Required to run tests in parallel (^6.0).", "doctrine/dbal": "Required to rename columns and drop SQLite columns (^2.13.3|^3.1.4).", "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", - "guzzlehttp/guzzle": "Required to use the HTTP Client and the ping methods on schedules (^7.2).", + "guzzlehttp/guzzle": "Required to use the HTTP Client and the ping methods on schedules (^7.5).", "laravel/tinker": "Required to use the tinker console command (^2.0).", "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.0).", "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.0).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.3).", + "league/flysystem-read-only": "Required to use read-only disks (^3.3)", "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.0).", - "mockery/mockery": "Required to use mocking (^1.4.4).", + "mockery/mockery": "Required to use mocking (^1.5.1).", "nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).", "pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).", "phpunit/phpunit": "Required to use assertions and run tests (^9.5.8).", - "predis/predis": "Required to use the predis connector (^1.1.9|^2.0).", + "predis/predis": "Required to use the predis connector (^1.1.9|^2.0.2).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", "symfony/cache": "Required to PSR-6 cache bridge (^6.0).", @@ -170,6 +189,6 @@ "composer/package-versions-deprecated": true } }, - "minimum-stability": "dev", + "minimum-stability": "stable", "prefer-stable": true } diff --git a/docker-compose.yml b/docker-compose.yml index ef0a87950983..cbc24aa9d6d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3' services: dynamodb: - image: amazon/dynamodb-local + image: amazon/dynamodb-local:1.22.0 ports: - "8000:8000" command: ["-jar", "DynamoDBLocal.jar", "-sharedDb", "-inMemory"] diff --git a/src/Illuminate/Auth/Access/AuthorizationException.php b/src/Illuminate/Auth/Access/AuthorizationException.php index 7fe6ceba9581..1454bde2a01d 100644 --- a/src/Illuminate/Auth/Access/AuthorizationException.php +++ b/src/Illuminate/Auth/Access/AuthorizationException.php @@ -14,6 +14,13 @@ class AuthorizationException extends Exception */ protected $response; + /** + * The HTTP response status code. + * + * @var int|null + */ + protected $status; + /** * Create a new authorization exception instance. * @@ -22,7 +29,7 @@ class AuthorizationException extends Exception * @param \Throwable|null $previous * @return void */ - public function __construct($message = null, $code = null, Throwable $previous = null) + public function __construct($message = null, $code = null, ?Throwable $previous = null) { parent::__construct($message ?? 'This action is unauthorized.', 0, $previous); @@ -52,6 +59,49 @@ public function setResponse($response) return $this; } + /** + * Set the HTTP response status code. + * + * @param int|null $status + * @return $this + */ + public function withStatus($status) + { + $this->status = $status; + + return $this; + } + + /** + * Set the HTTP response status code to 404. + * + * @return $this + */ + public function asNotFound() + { + return $this->withStatus(404); + } + + /** + * Determine if the HTTP status code has been set. + * + * @return bool + */ + public function hasStatus() + { + return $this->status !== null; + } + + /** + * Get the HTTP status code. + * + * @return int|null + */ + public function status() + { + return $this->status; + } + /** * Create a deny response object from this exception. * @@ -59,6 +109,6 @@ public function setResponse($response) */ public function toResponse() { - return Response::deny($this->message, $this->code); + return Response::deny($this->message, $this->code)->withStatus($this->status); } } diff --git a/src/Illuminate/Auth/Access/Gate.php b/src/Illuminate/Auth/Access/Gate.php index c7d9c80e9c8c..94592d351f70 100644 --- a/src/Illuminate/Auth/Access/Gate.php +++ b/src/Illuminate/Auth/Access/Gate.php @@ -4,6 +4,7 @@ use Closure; use Exception; +use Illuminate\Auth\Access\Events\GateEvaluated; use Illuminate\Contracts\Auth\Access\Gate as GateContract; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Events\Dispatcher; @@ -88,7 +89,7 @@ class Gate implements GateContract */ public function __construct(Container $container, callable $userResolver, array $abilities = [], array $policies = [], array $beforeCallbacks = [], array $afterCallbacks = [], - callable $guessPolicyNamesUsingCallback = null) + ?callable $guessPolicyNamesUsingCallback = null) { $this->policies = $policies; $this->container = $container; @@ -212,7 +213,7 @@ public function define($ability, $callback) * @param array|null $abilities * @return $this */ - public function resource($name, $class, array $abilities = null) + public function resource($name, $class, ?array $abilities = null) { $abilities = $abilities ?: [ 'viewAny' => 'viewAny', @@ -592,7 +593,7 @@ protected function dispatchGateEvaluatedEvent($user, $ability, array $arguments, { if ($this->container->bound(Dispatcher::class)) { $this->container->make(Dispatcher::class)->dispatch( - new Events\GateEvaluated($user, $ability, $result, $arguments) + new GateEvaluated($user, $ability, $result, $arguments) ); } } diff --git a/src/Illuminate/Auth/Access/HandlesAuthorization.php b/src/Illuminate/Auth/Access/HandlesAuthorization.php index 66e5786e38e8..ed2162459a44 100644 --- a/src/Illuminate/Auth/Access/HandlesAuthorization.php +++ b/src/Illuminate/Auth/Access/HandlesAuthorization.php @@ -27,4 +27,29 @@ protected function deny($message = null, $code = null) { return Response::deny($message, $code); } + + /** + * Deny with a HTTP status code. + * + * @param int $status + * @param string|null $message + * @param int|null $code + * @return \Illuminate\Auth\Access\Response + */ + public function denyWithStatus($status, $message = null, $code = null) + { + return Response::denyWithStatus($status, $message, $code); + } + + /** + * Deny with a 404 HTTP status code. + * + * @param string|null $message + * @param int|null $code + * @return \Illuminate\Auth\Access\Response + */ + public function denyAsNotFound($message = null, $code = null) + { + return Response::denyWithStatus(404, $message, $code); + } } diff --git a/src/Illuminate/Auth/Access/Response.php b/src/Illuminate/Auth/Access/Response.php index ab5edf39fdcd..77cabb521de9 100644 --- a/src/Illuminate/Auth/Access/Response.php +++ b/src/Illuminate/Auth/Access/Response.php @@ -27,6 +27,13 @@ class Response implements Arrayable */ protected $code; + /** + * The HTTP response status code. + * + * @var int|null + */ + protected $status; + /** * Create a new response. * @@ -66,6 +73,31 @@ public static function deny($message = null, $code = null) return new static(false, $message, $code); } + /** + * Create a new "deny" Response with a HTTP status code. + * + * @param int $status + * @param string|null $message + * @param mixed $code + * @return \Illuminate\Auth\Access\Response + */ + public static function denyWithStatus($status, $message = null, $code = null) + { + return static::deny($message, $code)->withStatus($status); + } + + /** + * Create a new "deny" Response with a 404 HTTP status code. + * + * @param string|null $message + * @param mixed $code + * @return \Illuminate\Auth\Access\Response + */ + public static function denyAsNotFound($message = null, $code = null) + { + return static::denyWithStatus(404, $message, $code); + } + /** * Determine if the response was allowed. * @@ -117,12 +149,46 @@ public function authorize() { if ($this->denied()) { throw (new AuthorizationException($this->message(), $this->code())) - ->setResponse($this); + ->setResponse($this) + ->withStatus($this->status); } return $this; } + /** + * Set the HTTP response status code. + * + * @param null|int $status + * @return $this + */ + public function withStatus($status) + { + $this->status = $status; + + return $this; + } + + /** + * Set the HTTP response status code to 404. + * + * @return $this + */ + public function asNotFound() + { + return $this->withStatus(404); + } + + /** + * Get the HTTP status code. + * + * @return int|null + */ + public function status() + { + return $this->status; + } + /** * Convert the response to an array. * diff --git a/src/Illuminate/Auth/AuthManager.php b/src/Illuminate/Auth/AuthManager.php index cc23eb8ee593..e95da5ec4ae4 100755 --- a/src/Illuminate/Auth/AuthManager.php +++ b/src/Illuminate/Auth/AuthManager.php @@ -6,6 +6,10 @@ use Illuminate\Contracts\Auth\Factory as FactoryContract; use InvalidArgumentException; +/** + * @mixin \Illuminate\Contracts\Auth\Guard + * @mixin \Illuminate\Contracts\Auth\StatefulGuard + */ class AuthManager implements FactoryContract { use CreatesUserProviders; @@ -120,7 +124,11 @@ public function createSessionDriver($name, $config) { $provider = $this->createUserProvider($config['provider'] ?? null); - $guard = new SessionGuard($name, $provider, $this->app['session.store']); + $guard = new SessionGuard( + $name, + $provider, + $this->app['session.store'], + ); // When using the remember me functionality of the authentication services we // will need to be set the encryption instance of the guard, which allows diff --git a/src/Illuminate/Auth/Console/ClearResetsCommand.php b/src/Illuminate/Auth/Console/ClearResetsCommand.php index 9c3d9e033987..2ea96681f8e7 100644 --- a/src/Illuminate/Auth/Console/ClearResetsCommand.php +++ b/src/Illuminate/Auth/Console/ClearResetsCommand.php @@ -42,6 +42,6 @@ public function handle() { $this->laravel['auth.password']->broker($this->argument('name'))->getRepository()->deleteExpired(); - $this->info('Expired reset tokens cleared successfully.'); + $this->components->info('Expired reset tokens cleared successfully.'); } } diff --git a/src/Illuminate/Auth/EloquentUserProvider.php b/src/Illuminate/Auth/EloquentUserProvider.php index 44f58981cb5f..39a744e0c098 100755 --- a/src/Illuminate/Auth/EloquentUserProvider.php +++ b/src/Illuminate/Auth/EloquentUserProvider.php @@ -24,6 +24,13 @@ class EloquentUserProvider implements UserProvider */ protected $model; + /** + * The callback that may modify the user retrieval queries. + * + * @var (\Closure(\Illuminate\Database\Eloquent\Builder):mixed)|null + */ + protected $queryCallback; + /** * Create a new database user provider. * @@ -73,8 +80,7 @@ public function retrieveByToken($identifier, $token) $rememberToken = $retrievedModel->getRememberToken(); - return $rememberToken && hash_equals($rememberToken, $token) - ? $retrievedModel : null; + return $rememberToken && hash_equals($rememberToken, $token) ? $retrievedModel : null; } /** @@ -142,7 +148,9 @@ public function retrieveByCredentials(array $credentials) */ public function validateCredentials(UserContract $user, array $credentials) { - $plain = $credentials['password']; + if (is_null($plain = $credentials['password'])) { + return false; + } return $this->hasher->check($plain, $user->getAuthPassword()); } @@ -155,9 +163,13 @@ public function validateCredentials(UserContract $user, array $credentials) */ protected function newModelQuery($model = null) { - return is_null($model) + $query = is_null($model) ? $this->createModel()->newQuery() : $model->newQuery(); + + with($query, $this->queryCallback); + + return $query; } /** @@ -217,4 +229,27 @@ public function setModel($model) return $this; } + + /** + * Get the callback that modifies the query before retrieving users. + * + * @return \Closure|null + */ + public function getQueryCallback() + { + return $this->queryCallback; + } + + /** + * Sets the callback to modify the query before retrieving users. + * + * @param (\Closure(\Illuminate\Database\Eloquent\Builder):mixed)|null $queryCallback + * @return $this + */ + public function withQuery($queryCallback = null) + { + $this->queryCallback = $queryCallback; + + return $this; + } } diff --git a/src/Illuminate/Auth/GuardHelpers.php b/src/Illuminate/Auth/GuardHelpers.php index aa9ebf9ec64a..21094bf8a82b 100644 --- a/src/Illuminate/Auth/GuardHelpers.php +++ b/src/Illuminate/Auth/GuardHelpers.php @@ -13,7 +13,7 @@ trait GuardHelpers /** * The currently authenticated user. * - * @var \Illuminate\Contracts\Auth\Authenticatable + * @var \Illuminate\Contracts\Auth\Authenticatable|null */ protected $user; @@ -95,6 +95,18 @@ public function setUser(AuthenticatableContract $user) return $this; } + /** + * Forget the current user. + * + * @return $this + */ + public function forgetUser() + { + $this->user = null; + + return $this; + } + /** * Get the user provider used by the guard. * diff --git a/src/Illuminate/Auth/Passwords/PasswordBroker.php b/src/Illuminate/Auth/Passwords/PasswordBroker.php index cbbc897abd85..5d212c503bd5 100755 --- a/src/Illuminate/Auth/Passwords/PasswordBroker.php +++ b/src/Illuminate/Auth/Passwords/PasswordBroker.php @@ -45,7 +45,7 @@ public function __construct(TokenRepositoryInterface $tokens, UserProvider $user * @param \Closure|null $callback * @return string */ - public function sendResetLink(array $credentials, Closure $callback = null) + public function sendResetLink(array $credentials, ?Closure $callback = null) { // First we will check to see if we found a user at the given credentials and // if we did not we will redirect back to this current URI with a piece of diff --git a/src/Illuminate/Auth/RequestGuard.php b/src/Illuminate/Auth/RequestGuard.php index d0af83cb4f4f..7c1dfdc553e0 100644 --- a/src/Illuminate/Auth/RequestGuard.php +++ b/src/Illuminate/Auth/RequestGuard.php @@ -33,7 +33,7 @@ class RequestGuard implements Guard * @param \Illuminate\Contracts\Auth\UserProvider|null $provider * @return void */ - public function __construct(callable $callback, Request $request, UserProvider $provider = null) + public function __construct(callable $callback, Request $request, ?UserProvider $provider = null) { $this->request = $request; $this->callback = $callback; diff --git a/src/Illuminate/Auth/SessionGuard.php b/src/Illuminate/Auth/SessionGuard.php index 652835f9f637..548bee987a73 100644 --- a/src/Illuminate/Auth/SessionGuard.php +++ b/src/Illuminate/Auth/SessionGuard.php @@ -20,6 +20,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use Illuminate\Support\Timebox; use Illuminate\Support\Traits\Macroable; use InvalidArgumentException; use RuntimeException; @@ -58,7 +59,7 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth * * @var int */ - protected $rememberDuration = 2628000; + protected $rememberDuration = 576000; /** * The session used by the guard. @@ -88,6 +89,13 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth */ protected $events; + /** + * The timebox instance. + * + * @var \Illuminate\Support\Timebox + */ + protected $timebox; + /** * Indicates if the logout method has been called. * @@ -109,17 +117,20 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth * @param \Illuminate\Contracts\Auth\UserProvider $provider * @param \Illuminate\Contracts\Session\Session $session * @param \Symfony\Component\HttpFoundation\Request|null $request + * @param \Illuminate\Support\Timebox|null $timebox * @return void */ public function __construct($name, UserProvider $provider, Session $session, - Request $request = null) + ?Request $request = null, + ?Timebox $timebox = null) { $this->name = $name; $this->session = $session; $this->request = $request; $this->provider = $provider; + $this->timebox = $timebox ?: new Timebox; } /** @@ -390,7 +401,7 @@ public function attempt(array $credentials = [], $remember = false) * Attempt to authenticate a user with credentials and additional callbacks. * * @param array $credentials - * @param array|callable $callbacks + * @param array|callable|null $callbacks * @param bool $remember * @return bool */ @@ -423,13 +434,17 @@ public function attemptWhen(array $credentials = [], $callbacks = null, $remembe */ protected function hasValidCredentials($user, $credentials) { - $validated = ! is_null($user) && $this->provider->validateCredentials($user, $credentials); + return $this->timebox->call(function ($timebox) use ($user, $credentials) { + $validated = ! is_null($user) && $this->provider->validateCredentials($user, $credentials); - if ($validated) { - $this->fireValidatedEvent($user); - } + if ($validated) { + $timebox->returnEarly(); + + $this->fireValidatedEvent($user); + } - return $validated; + return $validated; + }, 200 * 1000); } /** @@ -613,9 +628,12 @@ protected function clearUserDataFromStorage() { $this->session->remove($this->getName()); + $this->getCookieJar()->unqueue($this->getRecallerName()); + if (! is_null($this->recaller())) { - $this->getCookieJar()->queue($this->getCookieJar() - ->forget($this->getRecallerName())); + $this->getCookieJar()->queue( + $this->getCookieJar()->forget($this->getRecallerName()) + ); } } @@ -931,4 +949,14 @@ public function setRequest(Request $request) return $this; } + + /** + * Get the timebox instance used by the guard. + * + * @return \Illuminate\Support\Timebox + */ + public function getTimebox() + { + return $this->timebox; + } } diff --git a/src/Illuminate/Auth/composer.json b/src/Illuminate/Auth/composer.json index ae928a33bb36..1c3c4641fb27 100644 --- a/src/Illuminate/Auth/composer.json +++ b/src/Illuminate/Auth/composer.json @@ -15,6 +15,7 @@ ], "require": { "php": "^8.0.2", + "ext-hash": "*", "illuminate/collections": "^9.0", "illuminate/contracts": "^9.0", "illuminate/http": "^9.0", diff --git a/src/Illuminate/Broadcasting/BroadcastManager.php b/src/Illuminate/Broadcasting/BroadcastManager.php index bec197e83ffe..8d52245c64ee 100644 --- a/src/Illuminate/Broadcasting/BroadcastManager.php +++ b/src/Illuminate/Broadcasting/BroadcastManager.php @@ -10,9 +10,12 @@ use Illuminate\Broadcasting\Broadcasters\NullBroadcaster; use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster; use Illuminate\Broadcasting\Broadcasters\RedisBroadcaster; +use Illuminate\Bus\UniqueLock; use Illuminate\Contracts\Broadcasting\Factory as FactoryContract; +use Illuminate\Contracts\Broadcasting\ShouldBeUnique; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Contracts\Bus\Dispatcher as BusDispatcherContract; +use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Foundation\CachesRoutes; use InvalidArgumentException; use Psr\Log\LoggerInterface; @@ -61,7 +64,7 @@ public function __construct($app) * @param array|null $attributes * @return void */ - public function routes(array $attributes = null) + public function routes(?array $attributes = null) { if ($this->app instanceof CachesRoutes && $this->app->routesAreCached()) { return; @@ -83,7 +86,7 @@ public function routes(array $attributes = null) * @param array|null $attributes * @return void */ - public function userRoutes(array $attributes = null) + public function userRoutes(?array $attributes = null) { if ($this->app instanceof CachesRoutes && $this->app->routesAreCached()) { return; @@ -107,7 +110,7 @@ public function userRoutes(array $attributes = null) * @param array|null $attributes * @return void */ - public function channelRoutes(array $attributes = null) + public function channelRoutes(?array $attributes = null) { return $this->routes($attributes); } @@ -165,9 +168,34 @@ public function queue($event) $queue = $event->queue; } - $this->app->make('queue')->connection($event->connection ?? null)->pushOn( - $queue, new BroadcastEvent(clone $event) - ); + $broadcastEvent = new BroadcastEvent(clone $event); + + if ($event instanceof ShouldBeUnique) { + $broadcastEvent = new UniqueBroadcastEvent(clone $event); + + if ($this->mustBeUniqueAndCannotAcquireLock($broadcastEvent)) { + return; + } + } + + $this->app->make('queue') + ->connection($event->connection ?? null) + ->pushOn($queue, $broadcastEvent); + } + + /** + * Determine if the broadcastable event must be unique and determine if we can acquire the necessary lock. + * + * @param mixed $event + * @return bool + */ + protected function mustBeUniqueAndCannotAcquireLock($event) + { + return ! (new UniqueLock( + method_exists($event, 'uniqueVia') + ? $event->uniqueVia() + : $this->app->make(Cache::class) + ))->acquire($event); } /** @@ -217,6 +245,10 @@ protected function resolve($name) { $config = $this->getConfig($name); + if (is_null($config)) { + throw new InvalidArgumentException("Broadcast connection [{$name}] is not defined."); + } + if (isset($this->customCreators[$config['driver']])) { return $this->callCustomCreator($config); } diff --git a/src/Illuminate/Broadcasting/Broadcasters/Broadcaster.php b/src/Illuminate/Broadcasting/Broadcasters/Broadcaster.php index 3cd1cd9ecc84..25badb9c46d3 100644 --- a/src/Illuminate/Broadcasting/Broadcasters/Broadcaster.php +++ b/src/Illuminate/Broadcasting/Broadcasters/Broadcaster.php @@ -11,7 +11,6 @@ use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Support\Arr; use Illuminate\Support\Reflector; -use Illuminate\Support\Str; use ReflectionClass; use ReflectionFunction; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -117,7 +116,11 @@ protected function verifyUserCanAccessChannel($request, $channel) $handler = $this->normalizeChannelHandlerToCallable($callback); - if ($result = $handler($this->retrieveUser($request, $channel), ...$parameters)) { + $result = $handler($this->retrieveUser($request, $channel), ...$parameters); + + if ($result === false) { + throw new AccessDeniedHttpException; + } elseif ($result) { return $this->validAuthenticationResponse($request, $result); } } @@ -368,6 +371,6 @@ protected function retrieveChannelOptions($channel) */ protected function channelNameMatchesPattern($channel, $pattern) { - return Str::is(preg_replace('/\{(.*?)\}/', '*', $pattern), $channel); + return preg_match('/^'.preg_replace('/\{(.*?)\}/', '([^\.]+)', $pattern).'$/', $channel); } } diff --git a/src/Illuminate/Broadcasting/Broadcasters/RedisBroadcaster.php b/src/Illuminate/Broadcasting/Broadcasters/RedisBroadcaster.php index 5ae2e03c8f44..03245eac6e6f 100644 --- a/src/Illuminate/Broadcasting/Broadcasters/RedisBroadcaster.php +++ b/src/Illuminate/Broadcasting/Broadcasters/RedisBroadcaster.php @@ -23,7 +23,7 @@ class RedisBroadcaster extends Broadcaster /** * The Redis connection to use for broadcasting. * - * @var ?string + * @var string|null */ protected $connection = null; diff --git a/src/Illuminate/Broadcasting/UniqueBroadcastEvent.php b/src/Illuminate/Broadcasting/UniqueBroadcastEvent.php new file mode 100644 index 000000000000..83c752df08fb --- /dev/null +++ b/src/Illuminate/Broadcasting/UniqueBroadcastEvent.php @@ -0,0 +1,61 @@ +uniqueId = get_class($event); + + if (method_exists($event, 'uniqueId')) { + $this->uniqueId .= $event->uniqueId(); + } elseif (property_exists($event, 'uniqueId')) { + $this->uniqueId .= $event->uniqueId; + } + + if (method_exists($event, 'uniqueFor')) { + $this->uniqueFor = $event->uniqueFor(); + } elseif (property_exists($event, 'uniqueFor')) { + $this->uniqueFor = $event->uniqueFor; + } + + parent::__construct($event); + } + + /** + * Resolve the cache implementation that should manage the event's uniqueness. + * + * @return \Illuminate\Contracts\Cache\Repository + */ + public function uniqueVia() + { + return method_exists($this->event, 'uniqueVia') + ? $this->event->uniqueVia() + : Container::getInstance()->make(Repository::class); + } +} diff --git a/src/Illuminate/Broadcasting/composer.json b/src/Illuminate/Broadcasting/composer.json index f0736a37dacb..6c65e6b85c0a 100644 --- a/src/Illuminate/Broadcasting/composer.json +++ b/src/Illuminate/Broadcasting/composer.json @@ -15,10 +15,10 @@ ], "require": { "php": "^8.0.2", - "ext-json": "*", "psr/log": "^1.0|^2.0|^3.0", "illuminate/bus": "^9.0", "illuminate/collections": "^9.0", + "illuminate/container": "^9.0", "illuminate/contracts": "^9.0", "illuminate/queue": "^9.0", "illuminate/support": "^9.0" @@ -34,6 +34,7 @@ } }, "suggest": { + "ext-hash": "Required to use the Ably and Pusher broadcast drivers.", "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0)." }, diff --git a/src/Illuminate/Bus/Batch.php b/src/Illuminate/Bus/Batch.php index 644bfbce9dc6..fb8bb106b9d9 100644 --- a/src/Illuminate/Bus/Batch.php +++ b/src/Illuminate/Bus/Batch.php @@ -424,7 +424,7 @@ public function delete() * @param \Throwable|null $e * @return void */ - protected function invokeHandlerCallback($handler, Batch $batch, Throwable $e = null) + protected function invokeHandlerCallback($handler, Batch $batch, ?Throwable $e = null) { try { return $handler($batch, $e); diff --git a/src/Illuminate/Bus/Batchable.php b/src/Illuminate/Bus/Batchable.php index 3d88c3c7633b..e42bc5c3f59f 100644 --- a/src/Illuminate/Bus/Batchable.php +++ b/src/Illuminate/Bus/Batchable.php @@ -2,7 +2,10 @@ namespace Illuminate\Bus; +use Carbon\CarbonImmutable; use Illuminate\Container\Container; +use Illuminate\Support\Str; +use Illuminate\Support\Testing\Fakes\BatchFake; trait Batchable { @@ -13,6 +16,13 @@ trait Batchable */ public $batchId; + /** + * The fake batch, if applicable. + * + * @var \Illuminate\Support\Testing\Fakes\BatchFake + */ + private $fakeBatch; + /** * Get the batch instance for the job, if applicable. * @@ -20,6 +30,10 @@ trait Batchable */ public function batch() { + if ($this->fakeBatch) { + return $this->fakeBatch; + } + if ($this->batchId) { return Container::getInstance()->make(BatchRepository::class)->find($this->batchId); } @@ -49,4 +63,46 @@ public function withBatchId(string $batchId) return $this; } + + /** + * Indicate that the job should use a fake batch. + * + * @param string $id + * @param string $name + * @param int $totalJobs + * @param int $pendingJobs + * @param int $failedJobs + * @param array $failedJobIds + * @param array $options + * @param \Carbon\CarbonImmutable $createdAt + * @param \Carbon\CarbonImmutable|null $cancelledAt + * @param \Carbon\CarbonImmutable|null $finishedAt + * @return array{0: $this, 1: \Illuminate\Support\Testing\Fakes\BatchFake} + */ + public function withFakeBatch(string $id = '', + string $name = '', + int $totalJobs = 0, + int $pendingJobs = 0, + int $failedJobs = 0, + array $failedJobIds = [], + array $options = [], + ?CarbonImmutable $createdAt = null, + ?CarbonImmutable $cancelledAt = null, + ?CarbonImmutable $finishedAt = null) + { + $this->fakeBatch = new BatchFake( + empty($id) ? (string) Str::uuid() : $id, + $name, + $totalJobs, + $pendingJobs, + $failedJobs, + $failedJobIds, + $options, + $createdAt ?? CarbonImmutable::now(), + $cancelledAt, + $finishedAt, + ); + + return [$this, $this->fakeBatch]; + } } diff --git a/src/Illuminate/Bus/BusServiceProvider.php b/src/Illuminate/Bus/BusServiceProvider.php index ff3eef81b6c5..bd6192d0c48e 100644 --- a/src/Illuminate/Bus/BusServiceProvider.php +++ b/src/Illuminate/Bus/BusServiceProvider.php @@ -64,6 +64,7 @@ public function provides() DispatcherContract::class, QueueingDispatcherContract::class, BatchRepository::class, + DatabaseBatchRepository::class, ]; } } diff --git a/src/Illuminate/Bus/DatabaseBatchRepository.php b/src/Illuminate/Bus/DatabaseBatchRepository.php index de91c880ccea..624da19ae6e0 100644 --- a/src/Illuminate/Bus/DatabaseBatchRepository.php +++ b/src/Illuminate/Bus/DatabaseBatchRepository.php @@ -76,6 +76,7 @@ public function get($limit = 50, $before = null) public function find(string $batchId) { $batch = $this->connection->table($this->table) + ->useWritePdo() ->where('id', $batchId) ->first(); @@ -276,6 +277,29 @@ public function pruneUnfinished(DateTimeInterface $before) return $totalDeleted; } + /** + * Prune all of the cancelled entries older than the given date. + * + * @param \DateTimeInterface $before + * @return int + */ + public function pruneCancelled(DateTimeInterface $before) + { + $query = $this->connection->table($this->table) + ->whereNotNull('cancelled_at') + ->where('created_at', '<', $before->getTimestamp()); + + $totalDeleted = 0; + + do { + $deleted = $query->take(1000)->delete(); + + $totalDeleted += $deleted; + } while ($deleted !== 0); + + return $totalDeleted; + } + /** * Execute the given Closure within a storage specific transaction. * @@ -340,4 +364,25 @@ protected function toBatch($batch) $batch->finished_at ? CarbonImmutable::createFromTimestamp($batch->finished_at) : $batch->finished_at ); } + + /** + * Get the underlying database connection. + * + * @return \Illuminate\Database\Connection + */ + public function getConnection() + { + return $this->connection; + } + + /** + * Set the underlying database connection. + * + * @param \Illuminate\Database\Connection $connection + * @return void + */ + public function setConnection(Connection $connection) + { + $this->connection = $connection; + } } diff --git a/src/Illuminate/Bus/Dispatcher.php b/src/Illuminate/Bus/Dispatcher.php index 4dc390e653fb..99d40c9ba774 100644 --- a/src/Illuminate/Bus/Dispatcher.php +++ b/src/Illuminate/Bus/Dispatcher.php @@ -58,7 +58,7 @@ class Dispatcher implements QueueingDispatcher * @param \Closure|null $queueResolver * @return void */ - public function __construct(Container $container, Closure $queueResolver = null) + public function __construct(Container $container, ?Closure $queueResolver = null) { $this->container = $container; $this->queueResolver = $queueResolver; @@ -263,7 +263,7 @@ protected function pushCommandToQueue($queue, $command) public function dispatchAfterResponse($command, $handler = null) { $this->container->terminating(function () use ($command, $handler) { - $this->dispatchNow($command, $handler); + $this->dispatchSync($command, $handler); }); } diff --git a/src/Illuminate/Bus/UniqueLock.php b/src/Illuminate/Bus/UniqueLock.php index d1bd774cfe8e..a4066b77c1c6 100644 --- a/src/Illuminate/Bus/UniqueLock.php +++ b/src/Illuminate/Bus/UniqueLock.php @@ -32,10 +32,6 @@ public function __construct(Cache $cache) */ public function acquire($job) { - $uniqueId = method_exists($job, 'uniqueId') - ? $job->uniqueId() - : ($job->uniqueId ?? ''); - $uniqueFor = method_exists($job, 'uniqueFor') ? $job->uniqueFor() : ($job->uniqueFor ?? 0); @@ -44,9 +40,36 @@ public function acquire($job) ? $job->uniqueVia() : $this->cache; - return (bool) $cache->lock( - $key = 'laravel_unique_job:'.get_class($job).$uniqueId, - $uniqueFor - )->get(); + return (bool) $cache->lock($this->getKey($job), $uniqueFor)->get(); + } + + /** + * Release the lock for the given job. + * + * @param mixed $job + * @return void + */ + public function release($job) + { + $cache = method_exists($job, 'uniqueVia') + ? $job->uniqueVia() + : $this->cache; + + $cache->lock($this->getKey($job))->forceRelease(); + } + + /** + * Generate the lock key for the given job. + * + * @param mixed $job + * @return string + */ + protected function getKey($job) + { + $uniqueId = method_exists($job, 'uniqueId') + ? $job->uniqueId() + : ($job->uniqueId ?? ''); + + return 'laravel_unique_job:'.get_class($job).$uniqueId; } } diff --git a/src/Illuminate/Cache/CacheManager.php b/src/Illuminate/Cache/CacheManager.php index 9d87148193a0..8bfdd31676c7 100755 --- a/src/Illuminate/Cache/CacheManager.php +++ b/src/Illuminate/Cache/CacheManager.php @@ -11,7 +11,8 @@ use InvalidArgumentException; /** - * @mixin \Illuminate\Contracts\Cache\Repository + * @mixin \Illuminate\Cache\Repository + * @mixin \Illuminate\Contracts\Cache\LockProvider */ class CacheManager implements FactoryContract { @@ -57,7 +58,7 @@ public function store($name = null) { $name = $name ?: $this->getDefaultDriver(); - return $this->stores[$name] = $this->get($name); + return $this->stores[$name] ??= $this->resolve($name); } /** @@ -71,17 +72,6 @@ public function driver($driver = null) return $this->store($driver); } - /** - * Attempt to get the store from the local cache. - * - * @param string $name - * @return \Illuminate\Contracts\Cache\Repository - */ - protected function get($name) - { - return $this->stores[$name] ?? $this->resolve($name); - } - /** * Resolve the given store. * diff --git a/src/Illuminate/Cache/Console/CacheTableCommand.php b/src/Illuminate/Cache/Console/CacheTableCommand.php index ea18d4f20812..19b591bdda36 100644 --- a/src/Illuminate/Cache/Console/CacheTableCommand.php +++ b/src/Illuminate/Cache/Console/CacheTableCommand.php @@ -73,7 +73,7 @@ public function handle() $this->files->put($fullPath, $this->files->get(__DIR__.'/stubs/cache.stub')); - $this->info('Migration created successfully.'); + $this->components->info('Migration created successfully.'); $this->composer->dumpAutoloads(); } diff --git a/src/Illuminate/Cache/Console/ClearCommand.php b/src/Illuminate/Cache/Console/ClearCommand.php index ca8d2a27bdb6..7d3336c712a9 100755 --- a/src/Illuminate/Cache/Console/ClearCommand.php +++ b/src/Illuminate/Cache/Console/ClearCommand.php @@ -82,14 +82,14 @@ public function handle() $this->flushFacades(); if (! $successful) { - return $this->error('Failed to clear cache. Make sure you have the appropriate permissions.'); + return $this->components->error('Failed to clear cache. Make sure you have the appropriate permissions.'); } $this->laravel['events']->dispatch( 'cache:cleared', [$this->argument('store'), $this->tags()] ); - $this->info('Application cache cleared successfully.'); + $this->components->info('Application cache cleared successfully.'); } /** diff --git a/src/Illuminate/Cache/Console/ForgetCommand.php b/src/Illuminate/Cache/Console/ForgetCommand.php index 41e7adbdee14..c7fc830cd999 100755 --- a/src/Illuminate/Cache/Console/ForgetCommand.php +++ b/src/Illuminate/Cache/Console/ForgetCommand.php @@ -65,6 +65,6 @@ public function handle() $this->argument('key') ); - $this->info('The ['.$this->argument('key').'] key has been removed from the cache.'); + $this->components->info('The ['.$this->argument('key').'] key has been removed from the cache.'); } } diff --git a/src/Illuminate/Cache/DynamoDbLock.php b/src/Illuminate/Cache/DynamoDbLock.php index 54eec53f78b5..922260792938 100644 --- a/src/Illuminate/Cache/DynamoDbLock.php +++ b/src/Illuminate/Cache/DynamoDbLock.php @@ -34,9 +34,11 @@ public function __construct(DynamoDbStore $dynamo, $name, $seconds, $owner = nul */ public function acquire() { - return $this->dynamo->add( - $this->name, $this->owner, $this->seconds - ); + if ($this->seconds > 0) { + return $this->dynamo->add($this->name, $this->owner, $this->seconds); + } else { + return $this->dynamo->add($this->name, $this->owner, 86400); + } } /** diff --git a/src/Illuminate/Cache/FileLock.php b/src/Illuminate/Cache/FileLock.php new file mode 100644 index 000000000000..a5638b6832f4 --- /dev/null +++ b/src/Illuminate/Cache/FileLock.php @@ -0,0 +1,16 @@ +store->add($this->name, $this->owner, $this->seconds); + } +} diff --git a/src/Illuminate/Cache/FileStore.php b/src/Illuminate/Cache/FileStore.php index 42292295f0ce..6a6feb8a545f 100755 --- a/src/Illuminate/Cache/FileStore.php +++ b/src/Illuminate/Cache/FileStore.php @@ -12,7 +12,7 @@ class FileStore implements Store, LockProvider { - use InteractsWithTime, HasCacheLock, RetrievesMultipleKeys; + use InteractsWithTime, RetrievesMultipleKeys; /** * The Illuminate Filesystem instance. @@ -200,6 +200,31 @@ public function forever($key, $value) return $this->put($key, $value, 0); } + /** + * Get a lock instance. + * + * @param string $name + * @param int $seconds + * @param string|null $owner + * @return \Illuminate\Contracts\Cache\Lock + */ + public function lock($name, $seconds = 0, $owner = null) + { + return new FileLock($this, $name, $seconds, $owner); + } + + /** + * Restore a lock instance using the owner identifier. + * + * @param string $name + * @param string $owner + * @return \Illuminate\Contracts\Cache\Lock + */ + public function restoreLock($name, $owner) + { + return $this->lock($name, 0, $owner); + } + /** * Remove an item from the cache. * diff --git a/src/Illuminate/Cache/RateLimiting/Limit.php b/src/Illuminate/Cache/RateLimiting/Limit.php index 330cab39bba1..9bf058bb0728 100644 --- a/src/Illuminate/Cache/RateLimiting/Limit.php +++ b/src/Illuminate/Cache/RateLimiting/Limit.php @@ -7,7 +7,7 @@ class Limit /** * The rate limit signature key. * - * @var mixed|string + * @var mixed */ public $key; @@ -35,7 +35,7 @@ class Limit /** * Create a new limit instance. * - * @param mixed|string $key + * @param mixed $key * @param int $maxAttempts * @param int $decayMinutes * @return void @@ -107,7 +107,7 @@ public static function none() /** * Set the key of the rate limit. * - * @param string $key + * @param mixed $key * @return $this */ public function by($key) diff --git a/src/Illuminate/Cache/RedisStore.php b/src/Illuminate/Cache/RedisStore.php index 4896c9183d03..3f40bfcc5f56 100755 --- a/src/Illuminate/Cache/RedisStore.php +++ b/src/Illuminate/Cache/RedisStore.php @@ -74,6 +74,10 @@ public function get($key) */ public function many(array $keys) { + if (count($keys) === 0) { + return []; + } + $results = []; $values = $this->connection()->mget(array_map(function ($key) { diff --git a/src/Illuminate/Cache/Repository.php b/src/Illuminate/Cache/Repository.php index a9a1aa9dc3d5..85e7b826d50f 100755 --- a/src/Illuminate/Cache/Repository.php +++ b/src/Illuminate/Cache/Repository.php @@ -62,7 +62,7 @@ public function __construct(Store $store) /** * Determine if an item exists in the cache. * - * @param string $key + * @param array|string $key * @return bool */ public function has($key): bool @@ -84,9 +84,11 @@ public function missing($key) /** * Retrieve an item from the cache by key. * + * @template TCacheValue + * * @param array|string $key - * @param mixed $default - * @return mixed + * @param TCacheValue|(\Closure(): TCacheValue) $default + * @return (TCacheValue is null ? mixed : TCacheValue) */ public function get($key, $default = null): mixed { @@ -175,9 +177,11 @@ protected function handleManyResult($keys, $key, $value) /** * Retrieve an item from the cache and delete it. * - * @param string $key - * @param mixed $default - * @return mixed + * @template TCacheValue + * + * @param array|string $key + * @param TCacheValue|(\Closure(): TCacheValue) $default + * @return (TCacheValue is null ? mixed : TCacheValue) */ public function pull($key, $default = null) { @@ -372,10 +376,12 @@ public function forever($key, $value) /** * Get an item from the cache, or execute the given Closure and store the result. * + * @template TCacheValue + * * @param string $key * @param \Closure|\DateTimeInterface|\DateInterval|int|null $ttl - * @param \Closure $callback - * @return mixed + * @param \Closure(): TCacheValue $callback + * @return TCacheValue */ public function remember($key, $ttl, Closure $callback) { @@ -388,7 +394,9 @@ public function remember($key, $ttl, Closure $callback) return $value; } - $this->put($key, $value = $callback(), value($ttl)); + $value = $callback(); + + $this->put($key, $value, value($ttl, $value)); return $value; } @@ -396,9 +404,11 @@ public function remember($key, $ttl, Closure $callback) /** * Get an item from the cache, or execute the given Closure and store the result forever. * + * @template TCacheValue + * * @param string $key - * @param \Closure $callback - * @return mixed + * @param \Closure(): TCacheValue $callback + * @return TCacheValue */ public function sear($key, Closure $callback) { @@ -408,9 +418,11 @@ public function sear($key, Closure $callback) /** * Get an item from the cache, or execute the given Closure and store the result forever. * + * @template TCacheValue + * * @param string $key - * @param \Closure $callback - * @return mixed + * @param \Closure(): TCacheValue $callback + * @return TCacheValue */ public function rememberForever($key, Closure $callback) { diff --git a/src/Illuminate/Cache/composer.json b/src/Illuminate/Cache/composer.json index 92dd4fb03f23..8e8aaf7ff902 100755 --- a/src/Illuminate/Cache/composer.json +++ b/src/Illuminate/Cache/composer.json @@ -34,6 +34,8 @@ } }, "suggest": { + "ext-apcu": "Required to use the APC cache driver.", + "ext-filter": "Required to use the DynamoDb cache driver.", "ext-memcached": "Required to use the memcache cache driver.", "illuminate/database": "Required to use the database cache driver (^9.0).", "illuminate/filesystem": "Required to use the file cache driver (^9.0).", diff --git a/src/Illuminate/Collections/Arr.php b/src/Illuminate/Collections/Arr.php index afda8def49a3..9df6cf472a4c 100644 --- a/src/Illuminate/Collections/Arr.php +++ b/src/Illuminate/Collections/Arr.php @@ -185,7 +185,7 @@ public static function exists($array, $key) * @param mixed $default * @return mixed */ - public static function first($array, callable $callback = null, $default = null) + public static function first($array, ?callable $callback = null, $default = null) { if (is_null($callback)) { if (empty($array)) { @@ -214,7 +214,7 @@ public static function first($array, callable $callback = null, $default = null) * @param mixed $default * @return mixed */ - public static function last($array, callable $callback = null, $default = null) + public static function last($array, ?callable $callback = null, $default = null) { if (is_null($callback)) { return empty($array) ? value($default) : end($array); @@ -617,7 +617,7 @@ public static function query($array) * * @param array $array * @param int|null $number - * @param bool|false $preserveKeys + * @param bool $preserveKeys * @return mixed * * @throws \InvalidArgumentException @@ -731,6 +731,18 @@ public static function sort($array, $callback = null) return Collection::make($array)->sortBy($callback)->all(); } + /** + * Sort the array in descending order using the given callback or "dot" notation. + * + * @param array $array + * @param callable|array|string|null $callback + * @return array + */ + public static function sortDesc($array, $callback = null) + { + return Collection::make($array)->sortByDesc($callback)->all(); + } + /** * Recursively sort an array by keys and values. * @@ -783,6 +795,29 @@ public static function toCssClasses($array) return implode(' ', $classes); } + /** + * Conditionally compile styles from an array into a style list. + * + * @param array $array + * @return string + */ + public static function toCssStyles($array) + { + $styleList = static::wrap($array); + + $styles = []; + + foreach ($styleList as $class => $constraint) { + if (is_numeric($class)) { + $styles[] = Str::finish($constraint, ';'); + } elseif ($constraint) { + $styles[] = Str::finish($class, ';'); + } + } + + return implode(' ', $styles); + } + /** * Filter the array using the given callback. * @@ -803,9 +838,7 @@ public static function where($array, callable $callback) */ public static function whereNotNull($array) { - return static::where($array, function ($value) { - return ! is_null($value); - }); + return static::where($array, fn ($value) => ! is_null($value)); } /** diff --git a/src/Illuminate/Collections/Collection.php b/src/Illuminate/Collections/Collection.php index 097bab54d7a9..2dd2f793f669 100644 --- a/src/Illuminate/Collections/Collection.php +++ b/src/Illuminate/Collections/Collection.php @@ -84,11 +84,9 @@ public function avg($callback = null) { $callback = $this->valueRetriever($callback); - $items = $this->map(function ($value) use ($callback) { - return $callback($value); - })->filter(function ($value) { - return ! is_null($value); - }); + $items = $this + ->map(fn ($value) => $callback($value)) + ->filter(fn ($value) => ! is_null($value)); if ($count = $items->count()) { return $items->sum() / $count; @@ -104,9 +102,8 @@ public function avg($callback = null) public function median($key = null) { $values = (isset($key) ? $this->pluck($key) : $this) - ->filter(function ($item) { - return ! is_null($item); - })->sort()->values(); + ->filter(fn ($item) => ! is_null($item)) + ->sort()->values(); $count = $values->count(); @@ -141,17 +138,14 @@ public function mode($key = null) $counts = new static; - $collection->each(function ($value) use ($counts) { - $counts[$value] = isset($counts[$value]) ? $counts[$value] + 1 : 1; - }); + $collection->each(fn ($value) => $counts[$value] = isset($counts[$value]) ? $counts[$value] + 1 : 1); $sorted = $counts->sort(); $highestValue = $sorted->last(); - return $sorted->filter(function ($value) use ($highestValue) { - return $value == $highestValue; - })->sort()->keys()->all(); + return $sorted->filter(fn ($value) => $value == $highestValue) + ->sort()->keys()->all(); } /** @@ -187,6 +181,26 @@ public function contains($key, $operator = null, $value = null) return $this->contains($this->operatorForWhere(...func_get_args())); } + /** + * Determine if an item exists, using strict comparison. + * + * @param (callable(TValue): bool)|TValue|array-key $key + * @param TValue|null $value + * @return bool + */ + public function containsStrict($key, $value = null) + { + if (func_num_args() === 2) { + return $this->contains(fn ($item) => data_get($item, $key) === $value); + } + + if ($this->useAsCallable($key)) { + return ! is_null($this->first($key)); + } + + return in_array($key, $this->items, true); + } + /** * Determine if an item is not contained in the collection. * @@ -333,14 +347,10 @@ public function duplicatesStrict($callback = null) protected function duplicateComparator($strict) { if ($strict) { - return function ($a, $b) { - return $a === $b; - }; + return fn ($a, $b) => $a === $b; } - return function ($a, $b) { - return $a == $b; - }; + return fn ($a, $b) => $a == $b; } /** @@ -363,10 +373,10 @@ public function except($keys) /** * Run a filter over each of the items. * - * @param (callable(TValue, TKey): bool)|null $callback + * @param (callable(TValue, TKey): bool)|null $callback * @return static */ - public function filter(callable $callback = null) + public function filter(?callable $callback = null) { if ($callback) { return new static(Arr::where($this->items, $callback)); @@ -384,7 +394,7 @@ public function filter(callable $callback = null) * @param TFirstDefault|(\Closure(): TFirstDefault) $default * @return TValue|TFirstDefault */ - public function first(callable $callback = null, $default = null) + public function first(?callable $callback = null, $default = null) { return Arr::first($this->items, $callback, $default); } @@ -611,6 +621,41 @@ public function intersect($items) return new static(array_intersect($this->items, $this->getArrayableItems($items))); } + /** + * Intersect the collection with the given items, using the callback. + * + * @param \Illuminate\Contracts\Support\Arrayable|iterable $items + * @param callable(TValue, TValue): int $callback + * @return static + */ + public function intersectUsing($items, callable $callback) + { + return new static(array_uintersect($this->items, $this->getArrayableItems($items), $callback)); + } + + /** + * Intersect the collection with the given items with additional index check. + * + * @param \Illuminate\Contracts\Support\Arrayable|iterable $items + * @return static + */ + public function intersectAssoc($items) + { + return new static(array_intersect_assoc($this->items, $this->getArrayableItems($items))); + } + + /** + * Intersect the collection with the given items with additional index check, using the callback. + * + * @param \Illuminate\Contracts\Support\Arrayable|iterable $items + * @param callable(TValue, TValue): int $callback + * @return static + */ + public function intersectAssocUsing($items, callable $callback) + { + return new static(array_intersect_uassoc($this->items, $this->getArrayableItems($items), $callback)); + } + /** * Intersect the collection with the given items by key. * @@ -693,7 +738,7 @@ public function keys() * @param TLastDefault|(\Closure(): TLastDefault) $default * @return TValue|TLastDefault */ - public function last(callable $callback = null, $default = null) + public function last(?callable $callback = null, $default = null) { return Arr::last($this->items, $callback, $default); } @@ -703,7 +748,7 @@ public function last(callable $callback = null, $default = null) * * @param string|int|array $value * @param string|null $key - * @return static + * @return static */ public function pluck($value, $key = null) { @@ -811,7 +856,7 @@ public function mergeRecursive($items) * @template TCombineValue * * @param \Illuminate\Contracts\Support\Arrayable|iterable $values - * @return static + * @return static */ public function combine($values) { @@ -856,7 +901,7 @@ public function nth($step, $offset = 0) /** * Get the items with the specified keys. * - * @param \Illuminate\Support\Enumerable|array|string $keys + * @param \Illuminate\Support\Enumerable|array|string|null $keys * @return static */ public function only($keys) @@ -978,7 +1023,7 @@ public function put($key, $value) /** * Get one or a specified number of items randomly from the collection. * - * @param int|null $number + * @param (callable(self): int)|int|null $number * @return static|TValue * * @throws \InvalidArgumentException @@ -989,6 +1034,10 @@ public function random($number = null) return Arr::random($this->items); } + if (is_callable($number)) { + return new static(Arr::random($this->items, $number($this))); + } + return new static(Arr::random($this->items, $number)); } @@ -1366,7 +1415,7 @@ protected function sortByMany(array $comparisons = []) { $items = $this->items; - usort($items, function ($a, $b) use ($comparisons) { + uasort($items, function ($a, $b) use ($comparisons) { foreach ($comparisons as $comparison) { $comparison = Arr::wrap($comparison); @@ -1578,13 +1627,9 @@ public function values() */ public function zip($items) { - $arrayableItems = array_map(function ($items) { - return $this->getArrayableItems($items); - }, func_get_args()); + $arrayableItems = array_map(fn ($items) => $this->getArrayableItems($items), func_get_args()); - $params = array_merge([function () { - return new static(func_get_args()); - }, $this->items], $arrayableItems); + $params = array_merge([fn () => new static(func_get_args()), $this->items], $arrayableItems); return new static(array_map(...$params)); } @@ -1626,7 +1671,7 @@ public function count(): int /** * Count the number of items in the collection by a field or using a callback. * - * @param (callable(TValue, TKey): mixed)|string|null $countBy + * @param (callable(TValue, TKey): array-key)|string|null $countBy * @return static */ public function countBy($countBy = null) diff --git a/src/Illuminate/Collections/Enumerable.php b/src/Illuminate/Collections/Enumerable.php index c7f56f20ed89..bdb330d1e414 100644 --- a/src/Illuminate/Collections/Enumerable.php +++ b/src/Illuminate/Collections/Enumerable.php @@ -37,7 +37,7 @@ public static function make($items = []); * @param callable|null $callback * @return static */ - public static function times($number, callable $callback = null); + public static function times($number, ?callable $callback = null); /** * Create a collection with the given range. @@ -51,11 +51,10 @@ public static function range($from, $to); /** * Wrap the given value in a collection if applicable. * - * @template TWrapKey of array-key * @template TWrapValue * - * @param iterable $value - * @return static + * @param iterable|TWrapValue $value + * @return static */ public static function wrap($value); @@ -296,7 +295,7 @@ public function except($keys); * @param (callable(TValue): bool)|null $callback * @return static */ - public function filter(callable $callback = null); + public function filter(?callable $callback = null); /** * Apply the callback if the given "value" is (or resolves to) truthy. @@ -308,7 +307,7 @@ public function filter(callable $callback = null); * @param (callable($this): TWhenReturnType)|null $default * @return $this|TWhenReturnType */ - public function when($value, callable $callback = null, callable $default = null); + public function when($value, ?callable $callback = null, ?callable $default = null); /** * Apply the callback if the collection is empty. @@ -319,7 +318,7 @@ public function when($value, callable $callback = null, callable $default = null * @param (callable($this): TWhenEmptyReturnType)|null $default * @return $this|TWhenEmptyReturnType */ - public function whenEmpty(callable $callback, callable $default = null); + public function whenEmpty(callable $callback, ?callable $default = null); /** * Apply the callback if the collection is not empty. @@ -330,7 +329,7 @@ public function whenEmpty(callable $callback, callable $default = null); * @param (callable($this): TWhenNotEmptyReturnType)|null $default * @return $this|TWhenNotEmptyReturnType */ - public function whenNotEmpty(callable $callback, callable $default = null); + public function whenNotEmpty(callable $callback, ?callable $default = null); /** * Apply the callback if the given "value" is (or resolves to) truthy. @@ -342,7 +341,7 @@ public function whenNotEmpty(callable $callback, callable $default = null); * @param (callable($this): TUnlessReturnType)|null $default * @return $this|TUnlessReturnType */ - public function unless($value, callable $callback, callable $default = null); + public function unless($value, callable $callback, ?callable $default = null); /** * Apply the callback unless the collection is empty. @@ -353,7 +352,7 @@ public function unless($value, callable $callback, callable $default = null); * @param (callable($this): TUnlessEmptyReturnType)|null $default * @return $this|TUnlessEmptyReturnType */ - public function unlessEmpty(callable $callback, callable $default = null); + public function unlessEmpty(callable $callback, ?callable $default = null); /** * Apply the callback unless the collection is not empty. @@ -364,7 +363,7 @@ public function unlessEmpty(callable $callback, callable $default = null); * @param (callable($this): TUnlessNotEmptyReturnType)|null $default * @return $this|TUnlessNotEmptyReturnType */ - public function unlessNotEmpty(callable $callback, callable $default = null); + public function unlessNotEmpty(callable $callback, ?callable $default = null); /** * Filter items by the given key value pair. @@ -476,7 +475,7 @@ public function whereInstanceOf($type); * @param TFirstDefault|(\Closure(): TFirstDefault) $default * @return TValue|TFirstDefault */ - public function first(callable $callback = null, $default = null); + public function first(?callable $callback = null, $default = null); /** * Get the first item by the given key value pair. @@ -618,7 +617,7 @@ public function keys(); * @param TLastDefault|(\Closure(): TLastDefault) $default * @return TValue|TLastDefault */ - public function last(callable $callback = null, $default = null); + public function last(?callable $callback = null, $default = null); /** * Run a map over each of the items. @@ -680,8 +679,11 @@ public function mapWithKeys(callable $callback); /** * Map a collection and flatten the result by a single level. * - * @param callable(TValue, TKey): mixed $callback - * @return static + * @template TFlatMapKey of array-key + * @template TFlatMapValue + * + * @param callable(TValue, TKey): (\Illuminate\Support\Collection|array) $callback + * @return static */ public function flatMap(callable $callback); @@ -719,7 +721,7 @@ public function mergeRecursive($items); * @template TCombineValue * * @param \Illuminate\Contracts\Support\Arrayable|iterable $values - * @return static + * @return static */ public function combine($values); @@ -735,7 +737,7 @@ public function union($items); * Get the min value of a given key. * * @param (callable(TValue):mixed)|string|null $callback - * @return TValue + * @return mixed */ public function min($callback = null); @@ -743,7 +745,7 @@ public function min($callback = null); * Get the max value of a given key. * * @param (callable(TValue):mixed)|string|null $callback - * @return TValue + * @return mixed */ public function max($callback = null); @@ -982,7 +984,7 @@ public function sortDesc($options = SORT_REGULAR); /** * Sort the collection using the given callback. * - * @param array|(callable(TValue, TKey): mixed)|string $callback + * @param array|(callable(TValue, TKey): mixed)|string $callback * @param int $options * @param bool $descending * @return static @@ -992,7 +994,7 @@ public function sortBy($callback, $options = SORT_REGULAR, $descending = false); /** * Sort the collection in descending order using the given callback. * - * @param array|(callable(TValue, TKey): mixed)|string $callback + * @param array|(callable(TValue, TKey): mixed)|string $callback * @param int $options * @return static */ @@ -1101,7 +1103,7 @@ public function pluck($value, $key = null); /** * Create a collection of all elements that do not pass a given truth test. * - * @param (callable(TValue, TKey): bool)|bool $callback + * @param (callable(TValue, TKey): bool)|bool|TValue $callback * @return static */ public function reject($callback = true); @@ -1165,10 +1167,10 @@ public function count(): int; /** * Count the number of items in the collection by a field or using a callback. * - * @param (callable(TValue, TKey): mixed)|string|null $countBy + * @param (callable(TValue, TKey): array-key)|string|null $countBy * @return static */ - public function countBy($callback = null); + public function countBy($countBy = null); /** * Zip the collection together with one or more arrays. diff --git a/src/Illuminate/Collections/LazyCollection.php b/src/Illuminate/Collections/LazyCollection.php index a337991d74f5..f0ee6de3ffd4 100644 --- a/src/Illuminate/Collections/LazyCollection.php +++ b/src/Illuminate/Collections/LazyCollection.php @@ -236,6 +236,32 @@ public function contains($key, $operator = null, $value = null) return $this->contains($this->operatorForWhere(...func_get_args())); } + /** + * Determine if an item exists, using strict comparison. + * + * @param (callable(TValue): bool)|TValue|array-key $key + * @param TValue|null $value + * @return bool + */ + public function containsStrict($key, $value = null) + { + if (func_num_args() === 2) { + return $this->contains(fn ($item) => data_get($item, $key) === $value); + } + + if ($this->useAsCallable($key)) { + return ! is_null($this->first($key)); + } + + foreach ($this as $item) { + if ($item === $key) { + return true; + } + } + + return false; + } + /** * Determine if an item is not contained in the enumerable. * @@ -266,7 +292,7 @@ public function crossJoin(...$arrays) /** * Count the number of items in the collection by a field or using a callback. * - * @param (callable(TValue, TKey): mixed)|string|null $countBy + * @param (callable(TValue, TKey): array-key)|string|null $countBy * @return static */ public function countBy($countBy = null) @@ -398,15 +424,13 @@ public function except($keys) /** * Run a filter over each of the items. * - * @param (callable(TValue): bool)|null $callback + * @param (callable(TValue, TKey): bool)|null $callback * @return static */ - public function filter(callable $callback = null) + public function filter(?callable $callback = null) { if (is_null($callback)) { - $callback = function ($value) { - return (bool) $value; - }; + $callback = fn ($value) => (bool) $value; } return new static(function () use ($callback) { @@ -427,7 +451,7 @@ public function filter(callable $callback = null) * @param TFirstDefault|(\Closure(): TFirstDefault) $default * @return TValue|TFirstDefault */ - public function first(callable $callback = null, $default = null) + public function first(?callable $callback = null, $default = null) { $iterator = $this->getIterator(); @@ -606,6 +630,41 @@ public function intersect($items) return $this->passthru('intersect', func_get_args()); } + /** + * Intersect the collection with the given items, using the callback. + * + * @param \Illuminate\Contracts\Support\Arrayable|iterable $items + * @param callable(TValue, TValue): int $callback + * @return static + */ + public function intersectUsing() + { + return $this->passthru('intersectUsing', func_get_args()); + } + + /** + * Intersect the collection with the given items with additional index check. + * + * @param \Illuminate\Contracts\Support\Arrayable|iterable $items + * @return static + */ + public function intersectAssoc($items) + { + return $this->passthru('intersectAssoc', func_get_args()); + } + + /** + * Intersect the collection with the given items with additional index check, using the callback. + * + * @param \Illuminate\Contracts\Support\Arrayable|iterable $items + * @param callable(TValue, TValue): int $callback + * @return static + */ + public function intersectAssocUsing($items, callable $callback) + { + return $this->passthru('intersectAssocUsing', func_get_args()); + } + /** * Intersect the collection with the given items by key. * @@ -672,7 +731,7 @@ public function keys() * @param TLastDefault|(\Closure(): TLastDefault) $default * @return TValue|TLastDefault */ - public function last(callable $callback = null, $default = null) + public function last(?callable $callback = null, $default = null) { $needle = $placeholder = new stdClass; @@ -798,7 +857,7 @@ public function mergeRecursive($items) * @template TCombineValue * * @param \IteratorAggregate|array|(callable(): \Generator) $values - * @return static + * @return static */ public function combine($values) { @@ -1296,7 +1355,7 @@ public function sortDesc($options = SORT_REGULAR) /** * Sort the collection using the given callback. * - * @param array|(callable(TValue, TKey): mixed)|string $callback + * @param array|(callable(TValue, TKey): mixed)|string $callback * @param int $options * @param bool $descending * @return static @@ -1309,7 +1368,7 @@ public function sortBy($callback, $options = SORT_REGULAR, $descending = false) /** * Sort the collection in descending order using the given callback. * - * @param array|(callable(TValue, TKey): mixed)|string $callback + * @param array|(callable(TValue, TKey): mixed)|string $callback * @param int $options * @return static */ @@ -1439,9 +1498,7 @@ public function takeWhile($value) /** @var callable(TValue, TKey): bool $callback */ $callback = $this->useAsCallable($value) ? $value : $this->equality($value); - return $this->takeUntil(function ($item, $key) use ($callback) { - return ! $callback($item, $key); - }); + return $this->takeUntil(fn ($item, $key) => ! $callback($item, $key)); } /** @@ -1610,7 +1667,15 @@ protected function makeIterator($source) return new ArrayIterator($source); } - return $source(); + if (is_callable($source)) { + $maybeTraversable = $source(); + + return $maybeTraversable instanceof Traversable + ? $maybeTraversable + : new ArrayIterator(Arr::wrap($maybeTraversable)); + } + + return new ArrayIterator((array) $source); } /** diff --git a/src/Illuminate/Collections/Traits/EnumeratesValues.php b/src/Illuminate/Collections/Traits/EnumeratesValues.php index 1e2f9ae62042..dbbecd96a120 100644 --- a/src/Illuminate/Collections/Traits/EnumeratesValues.php +++ b/src/Illuminate/Collections/Traits/EnumeratesValues.php @@ -114,11 +114,10 @@ public static function make($items = []) /** * Wrap the given value in a collection if applicable. * - * @template TWrapKey of array-key * @template TWrapValue * - * @param iterable $value - * @return static + * @param iterable|TWrapValue $value + * @return static */ public static function wrap($value) { @@ -160,7 +159,7 @@ public static function empty() * @param (callable(int): TTimesValue)|null $callback * @return static */ - public static function times($number, callable $callback = null) + public static function times($number, ?callable $callback = null) { if ($number < 1) { return new static; @@ -195,34 +194,6 @@ public function some($key, $operator = null, $value = null) return $this->contains(...func_get_args()); } - /** - * Determine if an item exists, using strict comparison. - * - * @param (callable(TValue): bool)|TValue|array-key $key - * @param TValue|null $value - * @return bool - */ - public function containsStrict($key, $value = null) - { - if (func_num_args() === 2) { - return $this->contains(function ($item) use ($key, $value) { - return data_get($item, $key) === $value; - }); - } - - if ($this->useAsCallable($key)) { - return ! is_null($this->first($key)); - } - - foreach ($this as $item) { - if ($item === $key) { - return true; - } - } - - return false; - } - /** * Dump the items and end the script. * @@ -386,8 +357,11 @@ public function mapToGroups(callable $callback) /** * Map a collection and flatten the result by a single level. * - * @param callable(TValue, TKey): mixed $callback - * @return static + * @template TFlatMapKey of array-key + * @template TFlatMapValue + * + * @param callable(TValue, TKey): (\Illuminate\Support\Collection|array) $callback + * @return static */ public function flatMap(callable $callback) { @@ -404,43 +378,35 @@ public function flatMap(callable $callback) */ public function mapInto($class) { - return $this->map(function ($value, $key) use ($class) { - return new $class($value, $key); - }); + return $this->map(fn ($value, $key) => new $class($value, $key)); } /** * Get the min value of a given key. * * @param (callable(TValue):mixed)|string|null $callback - * @return TValue + * @return mixed */ public function min($callback = null) { $callback = $this->valueRetriever($callback); - return $this->map(function ($value) use ($callback) { - return $callback($value); - })->filter(function ($value) { - return ! is_null($value); - })->reduce(function ($result, $value) { - return is_null($result) || $value < $result ? $value : $result; - }); + return $this->map(fn ($value) => $callback($value)) + ->filter(fn ($value) => ! is_null($value)) + ->reduce(fn ($result, $value) => is_null($result) || $value < $result ? $value : $result); } /** * Get the max value of a given key. * * @param (callable(TValue):mixed)|string|null $callback - * @return TValue + * @return mixed */ public function max($callback = null) { $callback = $this->valueRetriever($callback); - return $this->filter(function ($value) { - return ! is_null($value); - })->reduce(function ($result, $item) use ($callback) { + return $this->filter(fn ($value) => ! is_null($value))->reduce(function ($result, $item) use ($callback) { $value = $callback($item); return is_null($result) || $value > $result ? $value : $result; @@ -501,9 +467,7 @@ public function sum($callback = null) ? $this->identity() : $this->valueRetriever($callback); - return $this->reduce(function ($result, $item) use ($callback) { - return $result + $callback($item); - }, 0); + return $this->reduce(fn ($result, $item) => $result + $callback($item), 0); } /** @@ -515,7 +479,7 @@ public function sum($callback = null) * @param (callable($this): TWhenEmptyReturnType)|null $default * @return $this|TWhenEmptyReturnType */ - public function whenEmpty(callable $callback, callable $default = null) + public function whenEmpty(callable $callback, ?callable $default = null) { return $this->when($this->isEmpty(), $callback, $default); } @@ -529,7 +493,7 @@ public function whenEmpty(callable $callback, callable $default = null) * @param (callable($this): TWhenNotEmptyReturnType)|null $default * @return $this|TWhenNotEmptyReturnType */ - public function whenNotEmpty(callable $callback, callable $default = null) + public function whenNotEmpty(callable $callback, ?callable $default = null) { return $this->when($this->isNotEmpty(), $callback, $default); } @@ -543,7 +507,7 @@ public function whenNotEmpty(callable $callback, callable $default = null) * @param (callable($this): TUnlessEmptyReturnType)|null $default * @return $this|TUnlessEmptyReturnType */ - public function unlessEmpty(callable $callback, callable $default = null) + public function unlessEmpty(callable $callback, ?callable $default = null) { return $this->whenNotEmpty($callback, $default); } @@ -557,7 +521,7 @@ public function unlessEmpty(callable $callback, callable $default = null) * @param (callable($this): TUnlessNotEmptyReturnType)|null $default * @return $this|TUnlessNotEmptyReturnType */ - public function unlessNotEmpty(callable $callback, callable $default = null) + public function unlessNotEmpty(callable $callback, ?callable $default = null) { return $this->whenEmpty($callback, $default); } @@ -621,9 +585,7 @@ public function whereIn($key, $values, $strict = false) { $values = $this->getArrayableItems($values); - return $this->filter(function ($item) use ($key, $values, $strict) { - return in_array(data_get($item, $key), $values, $strict); - }); + return $this->filter(fn ($item) => in_array(data_get($item, $key), $values, $strict)); } /** @@ -659,9 +621,9 @@ public function whereBetween($key, $values) */ public function whereNotBetween($key, $values) { - return $this->filter(function ($item) use ($key, $values) { - return data_get($item, $key) < reset($values) || data_get($item, $key) > end($values); - }); + return $this->filter( + fn ($item) => data_get($item, $key) < reset($values) || data_get($item, $key) > end($values) + ); } /** @@ -676,9 +638,7 @@ public function whereNotIn($key, $values, $strict = false) { $values = $this->getArrayableItems($values); - return $this->reject(function ($item) use ($key, $values, $strict) { - return in_array(data_get($item, $key), $values, $strict); - }); + return $this->reject(fn ($item) => in_array(data_get($item, $key), $values, $strict)); } /** @@ -751,9 +711,7 @@ public function pipeInto($class) public function pipeThrough($callbacks) { return Collection::make($callbacks)->reduce( - function ($carry, $callback) { - return $callback($carry); - }, + fn ($carry, $callback) => $callback($carry), $this, ); } @@ -809,7 +767,7 @@ class_basename(static::class), gettype($result) /** * Create a collection of all elements that do not pass a given truth test. * - * @param (callable(TValue, TKey): bool)|bool $callback + * @param (callable(TValue, TKey): bool)|bool|TValue $callback * @return static */ public function reject($callback = true) @@ -886,9 +844,7 @@ public function collect() */ public function toArray() { - return $this->map(function ($value) { - return $value instanceof Arrayable ? $value->toArray() : $value; - })->all(); + return $this->map(fn ($value) => $value instanceof Arrayable ? $value->toArray() : $value)->all(); } /** @@ -1000,12 +956,12 @@ protected function getArrayableItems($items) return $items->all(); } elseif ($items instanceof Arrayable) { return $items->toArray(); + } elseif ($items instanceof Traversable) { + return iterator_to_array($items); } elseif ($items instanceof Jsonable) { return json_decode($items->toJson(), true); } elseif ($items instanceof JsonSerializable) { return (array) $items->jsonSerialize(); - } elseif ($items instanceof Traversable) { - return iterator_to_array($items); } elseif ($items instanceof UnitEnum) { return [$items]; } @@ -1062,6 +1018,7 @@ protected function operatorForWhere($key, $operator = null, $value = null) case '>=': return $retrieved >= $value; case '===': return $retrieved === $value; case '!==': return $retrieved !== $value; + case '<=>': return $retrieved <=> $value; } }; } @@ -1089,9 +1046,7 @@ protected function valueRetriever($value) return $value; } - return function ($item) use ($value) { - return data_get($item, $value); - }; + return fn ($item) => data_get($item, $value); } /** @@ -1102,9 +1057,7 @@ protected function valueRetriever($value) */ protected function equality($value) { - return function ($item) use ($value) { - return $item === $value; - }; + return fn ($item) => $item === $value; } /** @@ -1115,9 +1068,7 @@ protected function equality($value) */ protected function negate(Closure $callback) { - return function (...$params) use ($callback) { - return ! $callback(...$params); - }; + return fn (...$params) => ! $callback(...$params); } /** @@ -1127,8 +1078,6 @@ protected function negate(Closure $callback) */ protected function identity() { - return function ($value) { - return $value; - }; + return fn ($value) => $value; } } diff --git a/src/Illuminate/Collections/helpers.php b/src/Illuminate/Collections/helpers.php index 45fc6d40510d..9babf4e0f881 100644 --- a/src/Illuminate/Collections/helpers.php +++ b/src/Illuminate/Collections/helpers.php @@ -13,7 +13,7 @@ * @param \Illuminate\Contracts\Support\Arrayable|iterable|null $value * @return \Illuminate\Support\Collection */ - function collect($value = null) + function collect($value = []) { return new Collection($value); } @@ -180,6 +180,7 @@ function last($array) * Return the default value of the given value. * * @param mixed $value + * @param mixed ...$args * @return mixed */ function value($value, ...$args) diff --git a/src/Illuminate/Conditionable/HigherOrderWhenProxy.php b/src/Illuminate/Conditionable/HigherOrderWhenProxy.php index 173a78396790..579114cf1989 100644 --- a/src/Illuminate/Conditionable/HigherOrderWhenProxy.php +++ b/src/Illuminate/Conditionable/HigherOrderWhenProxy.php @@ -18,17 +18,54 @@ class HigherOrderWhenProxy */ protected $condition; + /** + * Indicates whether the proxy has a condition. + * + * @var bool + */ + protected $hasCondition = false; + + /** + * Determine whether the condition should be negated. + * + * @var bool + */ + protected $negateConditionOnCapture; + /** * Create a new proxy instance. * * @param mixed $target - * @param bool $condition * @return void */ - public function __construct($target, $condition) + public function __construct($target) { $this->target = $target; - $this->condition = $condition; + } + + /** + * Set the condition on the proxy. + * + * @param bool $condition + * @return $this + */ + public function condition($condition) + { + [$this->condition, $this->hasCondition] = [$condition, true]; + + return $this; + } + + /** + * Indicate that the condition should be negated. + * + * @return $this + */ + public function negateConditionOnCapture() + { + $this->negateConditionOnCapture = true; + + return $this; } /** @@ -39,6 +76,12 @@ public function __construct($target, $condition) */ public function __get($key) { + if (! $this->hasCondition) { + $condition = $this->target->{$key}; + + return $this->condition($this->negateConditionOnCapture ? ! $condition : $condition); + } + return $this->condition ? $this->target->{$key} : $this->target; @@ -53,6 +96,12 @@ public function __get($key) */ public function __call($method, $parameters) { + if (! $this->hasCondition) { + $condition = $this->target->{$method}(...$parameters); + + return $this->condition($this->negateConditionOnCapture ? ! $condition : $condition); + } + return $this->condition ? $this->target->{$method}(...$parameters) : $this->target; diff --git a/src/Illuminate/Conditionable/Traits/Conditionable.php b/src/Illuminate/Conditionable/Traits/Conditionable.php index 7ab08cab0920..5e3194bbcb6a 100644 --- a/src/Illuminate/Conditionable/Traits/Conditionable.php +++ b/src/Illuminate/Conditionable/Traits/Conditionable.php @@ -13,17 +13,21 @@ trait Conditionable * @template TWhenParameter * @template TWhenReturnType * - * @param (\Closure($this): TWhenParameter)|TWhenParameter $value + * @param (\Closure($this): TWhenParameter)|TWhenParameter|null $value * @param (callable($this, TWhenParameter): TWhenReturnType)|null $callback * @param (callable($this, TWhenParameter): TWhenReturnType)|null $default * @return $this|TWhenReturnType */ - public function when($value, callable $callback = null, callable $default = null) + public function when($value = null, ?callable $callback = null, ?callable $default = null) { $value = $value instanceof Closure ? $value($this) : $value; + if (func_num_args() === 0) { + return new HigherOrderWhenProxy($this); + } + if (func_num_args() === 1) { - return new HigherOrderWhenProxy($this, $value); + return (new HigherOrderWhenProxy($this))->condition($value); } if ($value) { @@ -41,17 +45,21 @@ public function when($value, callable $callback = null, callable $default = null * @template TUnlessParameter * @template TUnlessReturnType * - * @param (\Closure($this): TUnlessParameter)|TUnlessParameter $value + * @param (\Closure($this): TUnlessParameter)|TUnlessParameter|null $value * @param (callable($this, TUnlessParameter): TUnlessReturnType)|null $callback * @param (callable($this, TUnlessParameter): TUnlessReturnType)|null $default * @return $this|TUnlessReturnType */ - public function unless($value, callable $callback = null, callable $default = null) + public function unless($value = null, ?callable $callback = null, ?callable $default = null) { $value = $value instanceof Closure ? $value($this) : $value; + if (func_num_args() === 0) { + return (new HigherOrderWhenProxy($this))->negateConditionOnCapture(); + } + if (func_num_args() === 1) { - return new HigherOrderWhenProxy($this, ! $value); + return (new HigherOrderWhenProxy($this))->condition(! $value); } if (! $value) { diff --git a/src/Illuminate/Config/Repository.php b/src/Illuminate/Config/Repository.php index 4a28b2ff3793..640d6731bc27 100644 --- a/src/Illuminate/Config/Repository.php +++ b/src/Illuminate/Config/Repository.php @@ -5,9 +5,12 @@ use ArrayAccess; use Illuminate\Contracts\Config\Repository as ConfigContract; use Illuminate\Support\Arr; +use Illuminate\Support\Traits\Macroable; class Repository implements ArrayAccess, ConfigContract { + use Macroable; + /** * All of the configuration items. * diff --git a/src/Illuminate/Console/Application.php b/src/Illuminate/Console/Application.php index 3c7f5c9bf9b2..83b2ff0efdd2 100755 --- a/src/Illuminate/Console/Application.php +++ b/src/Illuminate/Console/Application.php @@ -87,7 +87,7 @@ public function __construct(Container $laravel, Dispatcher $events, $version) * * @return int */ - public function run(InputInterface $input = null, OutputInterface $output = null): int + public function run(?InputInterface $input = null, ?OutputInterface $output = null): int { $commandName = $this->getCommandName( $input = $input ?: new ArgvInput @@ -262,7 +262,7 @@ protected function addToParent(SymfonyCommand $command) /** * Add a command, resolving through the application. * - * @param string $command + * @param \Illuminate\Console\Command|string $command * @return \Symfony\Component\Console\Command\Command|null */ public function resolve($command) @@ -273,6 +273,10 @@ public function resolve($command) return null; } + if ($command instanceof Command) { + return $this->add($command); + } + return $this->add($this->laravel->make($command)); } diff --git a/src/Illuminate/Console/BufferedConsoleOutput.php b/src/Illuminate/Console/BufferedConsoleOutput.php index 4bb5ca228541..aa4e6ceedc4e 100644 --- a/src/Illuminate/Console/BufferedConsoleOutput.php +++ b/src/Illuminate/Console/BufferedConsoleOutput.php @@ -27,6 +27,8 @@ public function fetch() /** * {@inheritdoc} + * + * @return void */ protected function doWrite(string $message, bool $newline) { diff --git a/src/Illuminate/Console/CacheCommandMutex.php b/src/Illuminate/Console/CacheCommandMutex.php new file mode 100644 index 000000000000..223174c34010 --- /dev/null +++ b/src/Illuminate/Console/CacheCommandMutex.php @@ -0,0 +1,98 @@ +cache = $cache; + } + + /** + * Attempt to obtain a command mutex for the given command. + * + * @param \Illuminate\Console\Command $command + * @return bool + */ + public function create($command) + { + return $this->cache->store($this->store)->add( + $this->commandMutexName($command), + true, + method_exists($command, 'isolationLockExpiresAt') + ? $command->isolationLockExpiresAt() + : CarbonInterval::hour(), + ); + } + + /** + * Determine if a command mutex exists for the given command. + * + * @param \Illuminate\Console\Command $command + * @return bool + */ + public function exists($command) + { + return $this->cache->store($this->store)->has( + $this->commandMutexName($command) + ); + } + + /** + * Release the mutex for the given command. + * + * @param \Illuminate\Console\Command $command + * @return bool + */ + public function forget($command) + { + return $this->cache->store($this->store)->forget( + $this->commandMutexName($command) + ); + } + + /** + * @param \Illuminate\Console\Command $command + * @return string + */ + protected function commandMutexName($command) + { + return 'framework'.DIRECTORY_SEPARATOR.'command-'.$command->getName(); + } + + /** + * Specify the cache store that should be used. + * + * @param string|null $store + * @return $this + */ + public function useStore($store) + { + $this->store = $store; + + return $this; + } +} diff --git a/src/Illuminate/Console/Command.php b/src/Illuminate/Console/Command.php index 6d9ae8c89381..676847465852 100755 --- a/src/Illuminate/Console/Command.php +++ b/src/Illuminate/Console/Command.php @@ -2,9 +2,12 @@ namespace Illuminate\Console; +use Illuminate\Console\View\Components\Factory; +use Illuminate\Contracts\Console\Isolatable; use Illuminate\Support\Traits\Macroable; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class Command extends SymfonyCommand @@ -12,6 +15,8 @@ class Command extends SymfonyCommand use Concerns\CallsCommands, Concerns\HasParameters, Concerns\InteractsWithIO, + Concerns\InteractsWithSignals, + Concerns\PromptsForMissingInput, Macroable; /** @@ -38,7 +43,7 @@ class Command extends SymfonyCommand /** * The console command description. * - * @var string + * @var string|null */ protected $description; @@ -75,7 +80,11 @@ public function __construct() // Once we have constructed the command, we'll set the description and other // related properties of the command. If a signature wasn't used to build // the command we'll set the arguments and the options on this command. - $this->setDescription((string) $this->description); + if (! isset($this->description)) { + $this->setDescription((string) static::getDefaultDescription()); + } else { + $this->setDescription((string) $this->description); + } $this->setHelp((string) $this->help); @@ -84,6 +93,10 @@ public function __construct() if (! isset($this->signature)) { $this->specifyParameters(); } + + if ($this instanceof Isolatable) { + $this->configureIsolation(); + } } /** @@ -104,6 +117,22 @@ protected function configureUsingFluentDefinition() $this->getDefinition()->addOptions($options); } + /** + * Configure the console command for isolation. + * + * @return void + */ + protected function configureIsolation() + { + $this->getDefinition()->addOption(new InputOption( + 'isolated', + null, + InputOption::VALUE_OPTIONAL, + 'Do not run the command if another instance of the command is already running', + false + )); + } + /** * Run the console command. * @@ -117,9 +146,15 @@ public function run(InputInterface $input, OutputInterface $output): int OutputStyle::class, ['input' => $input, 'output' => $output] ); - return parent::run( - $this->input = $input, $this->output - ); + $this->components = $this->laravel->make(Factory::class, ['output' => $this->output]); + + try { + return parent::run( + $this->input = $input, $this->output + ); + } finally { + $this->untrap(); + } } /** @@ -131,9 +166,38 @@ public function run(InputInterface $input, OutputInterface $output): int */ protected function execute(InputInterface $input, OutputInterface $output) { + if ($this instanceof Isolatable && $this->option('isolated') !== false && + ! $this->commandIsolationMutex()->create($this)) { + $this->comment(sprintf( + 'The [%s] command is already running.', $this->getName() + )); + + return (int) (is_numeric($this->option('isolated')) + ? $this->option('isolated') + : self::SUCCESS); + } + $method = method_exists($this, 'handle') ? 'handle' : '__invoke'; - return (int) $this->laravel->call([$this, $method]); + try { + return (int) $this->laravel->call([$this, $method]); + } finally { + if ($this instanceof Isolatable && $this->option('isolated') !== false) { + $this->commandIsolationMutex()->forget($this); + } + } + } + + /** + * Get a command isolation mutex instance for the command. + * + * @return \Illuminate\Console\CommandMutex + */ + protected function commandIsolationMutex() + { + return $this->laravel->bound(CommandMutex::class) + ? $this->laravel->make(CommandMutex::class) + : $this->laravel->make(CacheCommandMutex::class); } /** diff --git a/src/Illuminate/Console/CommandMutex.php b/src/Illuminate/Console/CommandMutex.php new file mode 100644 index 000000000000..7196128126a2 --- /dev/null +++ b/src/Illuminate/Console/CommandMutex.php @@ -0,0 +1,30 @@ +option('test') && ! $this->option('pest')) { - return; + return false; } - $this->call('make:test', [ + return $this->callSilent('make:test', [ 'name' => Str::of($path)->after($this->laravel['path'])->beforeLast('.php')->append('Test')->replace('\\', '/'), '--pest' => $this->option('pest'), - ]); + ]) == 0; } } diff --git a/src/Illuminate/Console/Concerns/InteractsWithIO.php b/src/Illuminate/Console/Concerns/InteractsWithIO.php index bdb594b0781f..13f6197589e2 100644 --- a/src/Illuminate/Console/Concerns/InteractsWithIO.php +++ b/src/Illuminate/Console/Concerns/InteractsWithIO.php @@ -15,6 +15,15 @@ trait InteractsWithIO { + /** + * The console components factory. + * + * @var \Illuminate\Console\View\Components\Factory + * + * @internal This property is not meant to be used or overwritten outside the framework. + */ + protected $components; + /** * The input interface implementation. * @@ -198,7 +207,7 @@ public function secret($question, $fallback = true) * * @param string $question * @param array $choices - * @param string|null $default + * @param string|int|null $default * @param mixed|null $attempts * @param bool $multiple * @return string|array @@ -355,17 +364,18 @@ public function warn($string, $verbosity = null) * Write a string in an alert box. * * @param string $string + * @param int|string|null $verbosity * @return void */ - public function alert($string) + public function alert($string, $verbosity = null) { $length = Str::length(strip_tags($string)) + 12; - $this->comment(str_repeat('*', $length)); - $this->comment('* '.$string.' *'); - $this->comment(str_repeat('*', $length)); + $this->comment(str_repeat('*', $length), $verbosity); + $this->comment('* '.$string.' *', $verbosity); + $this->comment(str_repeat('*', $length), $verbosity); - $this->newLine(); + $this->comment('', $verbosity); } /** diff --git a/src/Illuminate/Console/Concerns/InteractsWithSignals.php b/src/Illuminate/Console/Concerns/InteractsWithSignals.php new file mode 100644 index 000000000000..895072c15c72 --- /dev/null +++ b/src/Illuminate/Console/Concerns/InteractsWithSignals.php @@ -0,0 +1,51 @@ +|int $signals + * @param callable(int $signal): void $callback + * @return void + */ + public function trap($signals, $callback) + { + Signals::whenAvailable(function () use ($signals, $callback) { + $this->signals ??= new Signals( + $this->getApplication()->getSignalRegistry(), + ); + + collect(Arr::wrap($signals)) + ->each(fn ($signal) => $this->signals->register($signal, $callback)); + }); + } + + /** + * Untrap signal handlers set within the command's handler. + * + * @return void + * + * @internal + */ + public function untrap() + { + if (! is_null($this->signals)) { + $this->signals->unregister(); + + $this->signals = null; + } + } +} diff --git a/src/Illuminate/Console/Concerns/PromptsForMissingInput.php b/src/Illuminate/Console/Concerns/PromptsForMissingInput.php new file mode 100644 index 000000000000..ef1186eec17f --- /dev/null +++ b/src/Illuminate/Console/Concerns/PromptsForMissingInput.php @@ -0,0 +1,108 @@ +promptForMissingArguments($input, $output); + } + } + + /** + * Prompt the user for any missing arguments. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + protected function promptForMissingArguments(InputInterface $input, OutputInterface $output) + { + $prompted = collect($this->getDefinition()->getArguments()) + ->filter(fn ($argument) => $argument->isRequired() && is_null($input->getArgument($argument->getName()))) + ->filter(fn ($argument) => $argument->getName() !== 'command') + ->each(fn ($argument) => $input->setArgument( + $argument->getName(), + $this->askPersistently( + $this->promptForMissingArgumentsUsing()[$argument->getName()] ?? + 'What is '.lcfirst($argument->getDescription()).'?' + ) + )) + ->isNotEmpty(); + + if ($prompted) { + $this->afterPromptingForMissingArguments($input, $output); + } + } + + /** + * Prompt for missing input arguments using the returned questions. + * + * @return array + */ + protected function promptForMissingArgumentsUsing() + { + return []; + } + + /** + * Perform actions after the user was prompted for missing arguments. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + { + // + } + + /** + * Whether the input contains any options that differ from the default values. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @return bool + */ + protected function didReceiveOptions(InputInterface $input) + { + return collect($this->getDefinition()->getOptions()) + ->reject(fn ($option) => $input->getOption($option->getName()) === $option->getDefault()) + ->isNotEmpty(); + } + + /** + * Continue asking a question until an answer is provided. + * + * @param string $question + * @return string + */ + private function askPersistently($question) + { + $answer = null; + + while ($answer === null) { + $answer = $this->components->ask($question); + + if ($answer === null) { + $this->components->error('The answer is required.'); + } + } + + return $answer; + } +} diff --git a/src/Illuminate/Console/ConfirmableTrait.php b/src/Illuminate/Console/ConfirmableTrait.php index 8d0d6df77808..bf639706f568 100644 --- a/src/Illuminate/Console/ConfirmableTrait.php +++ b/src/Illuminate/Console/ConfirmableTrait.php @@ -13,7 +13,7 @@ trait ConfirmableTrait * @param \Closure|bool|null $callback * @return bool */ - public function confirmToProceed($warning = 'Application In Production!', $callback = null) + public function confirmToProceed($warning = 'Application In Production', $callback = null) { $callback = is_null($callback) ? $this->getDefaultConfirmCallback() : $callback; @@ -24,12 +24,14 @@ public function confirmToProceed($warning = 'Application In Production!', $callb return true; } - $this->alert($warning); + $this->components->alert($warning); - $confirmed = $this->confirm('Do you really wish to run this command?'); + $confirmed = $this->components->confirm('Do you really wish to run this command?'); if (! $confirmed) { - $this->comment('Command Canceled!'); + $this->newLine(); + + $this->components->warn('Command canceled.'); return false; } diff --git a/src/Illuminate/Console/Contracts/NewLineAware.php b/src/Illuminate/Console/Contracts/NewLineAware.php new file mode 100644 index 000000000000..135cecba8062 --- /dev/null +++ b/src/Illuminate/Console/Contracts/NewLineAware.php @@ -0,0 +1,13 @@ +isReservedName($this->getNameInput())) { - $this->error('The name "'.$this->getNameInput().'" is reserved by PHP.'); + $this->components->error('The name "'.$this->getNameInput().'" is reserved by PHP.'); return false; } @@ -162,7 +167,7 @@ public function handle() if ((! $this->hasOption('force') || ! $this->option('force')) && $this->alreadyExists($this->getNameInput())) { - $this->error($this->type.' already exists!'); + $this->components->error($this->type.' already exists.'); return false; } @@ -174,11 +179,15 @@ public function handle() $this->files->put($path, $this->sortImports($this->buildClass($name))); - $this->info($this->type.' created successfully.'); + $info = $this->type; if (in_array(CreatesMatchingTest::class, class_uses_recursive($this))) { - $this->handleTestCreation($path); + if ($this->handleTestCreation($path)) { + $info .= ' and test'; + } } + + $this->components->info(sprintf('%s [%s] created successfully.', $info, $path)); } /** @@ -227,6 +236,40 @@ protected function qualifyModel(string $model) : $rootNamespace.$model; } + /** + * Get a list of possible model names. + * + * @return array + */ + protected function possibleModels() + { + $modelPath = is_dir(app_path('Models')) ? app_path('Models') : app_path(); + + return collect((new Finder)->files()->depth(0)->in($modelPath)) + ->map(fn ($file) => $file->getBasename('.php')) + ->values() + ->all(); + } + + /** + * Get a list of possible event names. + * + * @return array + */ + protected function possibleEvents() + { + $eventPath = app_path('Events'); + + if (! is_dir($eventPath)) { + return []; + } + + return collect((new Finder)->files()->depth(0)->in($eventPath)) + ->map(fn ($file) => $file->getBasename('.php')) + ->values() + ->all(); + } + /** * Get the default namespace for the class. * @@ -351,7 +394,7 @@ protected function replaceClass($stub, $name) */ protected function sortImports($stub) { - if (preg_match('/(?P(?:use [^;]+;$\n?)+)/m', $stub, $match)) { + if (preg_match('/(?P(?:^use [^;{]+;$\n?)+)/m', $stub, $match)) { $imports = explode("\n", trim($match['imports'])); sort($imports); @@ -430,7 +473,19 @@ protected function viewPath($path = '') protected function getArguments() { return [ - ['name', InputArgument::REQUIRED, 'The name of the class'], + ['name', InputArgument::REQUIRED, 'The name of the '.strtolower($this->type)], + ]; + } + + /** + * Prompt for missing input arguments using the returned questions. + * + * @return array + */ + protected function promptForMissingArgumentsUsing() + { + return [ + 'name' => 'What should the '.strtolower($this->type).' be named?', ]; } } diff --git a/src/Illuminate/Console/OutputStyle.php b/src/Illuminate/Console/OutputStyle.php index 78083a9aba47..2c159ca565ee 100644 --- a/src/Illuminate/Console/OutputStyle.php +++ b/src/Illuminate/Console/OutputStyle.php @@ -2,11 +2,12 @@ namespace Illuminate\Console; +use Illuminate\Console\Contracts\NewLineAware; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -class OutputStyle extends SymfonyStyle +class OutputStyle extends SymfonyStyle implements NewLineAware { /** * The output instance. @@ -15,6 +16,13 @@ class OutputStyle extends SymfonyStyle */ private $output; + /** + * If the last output written wrote a new line. + * + * @var bool + */ + protected $newLineWritten = false; + /** * Create a new Console OutputStyle instance. * @@ -29,6 +37,54 @@ public function __construct(InputInterface $input, OutputInterface $output) parent::__construct($input, $output); } + /** + * {@inheritdoc} + * + * @return void + */ + public function write(string|iterable $messages, bool $newline = false, int $options = 0) + { + $this->newLineWritten = $newline; + + parent::write($messages, $newline, $options); + } + + /** + * {@inheritdoc} + * + * @return void + */ + public function writeln(string|iterable $messages, int $type = self::OUTPUT_NORMAL) + { + $this->newLineWritten = true; + + parent::writeln($messages, $type); + } + + /** + * {@inheritdoc} + * + * @return void + */ + public function newLine(int $count = 1) + { + $this->newLineWritten = $count > 0; + + parent::newLine($count); + } + + /** + * {@inheritdoc} + */ + public function newLineWritten() + { + if ($this->output instanceof static && $this->output->newLineWritten()) { + return true; + } + + return $this->newLineWritten; + } + /** * Returns whether verbosity is quiet (-q). * diff --git a/src/Illuminate/Console/QuestionHelper.php b/src/Illuminate/Console/QuestionHelper.php new file mode 100644 index 000000000000..43b4cf8296c1 --- /dev/null +++ b/src/Illuminate/Console/QuestionHelper.php @@ -0,0 +1,84 @@ +getQuestion()); + + $text = $this->ensureEndsWithPunctuation($text); + + $text = " $text"; + + $default = $question->getDefault(); + + if ($question->isMultiline()) { + $text .= sprintf(' (press %s to continue)', 'Windows' == PHP_OS_FAMILY + ? 'Ctrl+Z then Enter' + : 'Ctrl+D'); + } + + switch (true) { + case null === $default: + $text = sprintf('%s', $text); + + break; + + case $question instanceof ConfirmationQuestion: + $text = sprintf('%s (yes/no) [%s]', $text, $default ? 'yes' : 'no'); + + break; + + case $question instanceof ChoiceQuestion: + $choices = $question->getChoices(); + $text = sprintf('%s [%s]', $text, OutputFormatter::escape($choices[$default] ?? $default)); + + break; + + default: + $text = sprintf('%s [%s]', $text, OutputFormatter::escape($default)); + + break; + } + + $output->writeln($text); + + if ($question instanceof ChoiceQuestion) { + foreach ($question->getChoices() as $key => $value) { + with(new TwoColumnDetail($output))->render($value, $key); + } + } + + $output->write('❯ '); + } + + /** + * Ensures the given string ends with punctuation. + * + * @param string $string + * @return string + */ + protected function ensureEndsWithPunctuation($string) + { + if (! str($string)->endsWith(['?', ':', '!', '.'])) { + return "$string:"; + } + + return $string; + } +} diff --git a/src/Illuminate/Console/Scheduling/CacheEventMutex.php b/src/Illuminate/Console/Scheduling/CacheEventMutex.php index 1f6b15eacbea..3d1ad9247a1b 100644 --- a/src/Illuminate/Console/Scheduling/CacheEventMutex.php +++ b/src/Illuminate/Console/Scheduling/CacheEventMutex.php @@ -2,7 +2,9 @@ namespace Illuminate\Console\Scheduling; +use Illuminate\Cache\DynamoDbStore; use Illuminate\Contracts\Cache\Factory as Cache; +use Illuminate\Contracts\Cache\LockProvider; class CacheEventMutex implements EventMutex, CacheAware { @@ -39,6 +41,12 @@ public function __construct(Cache $cache) */ public function create(Event $event) { + if ($this->shouldUseLocks($this->cache->store($this->store)->getStore())) { + return $this->cache->store($this->store)->getStore() + ->lock($event->mutexName(), $event->expiresAt * 60) + ->acquire(); + } + return $this->cache->store($this->store)->add( $event->mutexName(), true, $event->expiresAt * 60 ); @@ -52,6 +60,12 @@ public function create(Event $event) */ public function exists(Event $event) { + if ($this->shouldUseLocks($this->cache->store($this->store)->getStore())) { + return ! $this->cache->store($this->store)->getStore() + ->lock($event->mutexName(), $event->expiresAt * 60) + ->get(fn () => true); + } + return $this->cache->store($this->store)->has($event->mutexName()); } @@ -63,9 +77,28 @@ public function exists(Event $event) */ public function forget(Event $event) { + if ($this->shouldUseLocks($this->cache->store($this->store)->getStore())) { + $this->cache->store($this->store)->getStore() + ->lock($event->mutexName(), $event->expiresAt * 60) + ->forceRelease(); + + return; + } + $this->cache->store($this->store)->forget($event->mutexName()); } + /** + * Determine if the given store should use locks for cache event mutexes. + * + * @param \Illuminate\Contracts\Cache\Store $store + * @return bool + */ + protected function shouldUseLocks($store) + { + return $store instanceof LockProvider && ! $store instanceof DynamoDbStore; + } + /** * Specify the cache store that should be used. * diff --git a/src/Illuminate/Console/Scheduling/CallbackEvent.php b/src/Illuminate/Console/Scheduling/CallbackEvent.php index e91600ac3a40..0ef6fddce633 100644 --- a/src/Illuminate/Console/Scheduling/CallbackEvent.php +++ b/src/Illuminate/Console/Scheduling/CallbackEvent.php @@ -129,6 +129,8 @@ protected function execute($container) /** * Do not allow the event to overlap each other. * + * The expiration time of the underlying cache lock may be specified in minutes. + * * @param int $expiresAt * @return $this * @@ -184,7 +186,7 @@ public function getSummaryForDisplay() */ public function mutexName() { - return 'framework/schedule-'.sha1($this->description); + return 'framework/schedule-'.sha1($this->description ?? ''); } /** diff --git a/src/Illuminate/Console/Scheduling/CommandBuilder.php b/src/Illuminate/Console/Scheduling/CommandBuilder.php index ee13c5ee372d..17ad5522da92 100644 --- a/src/Illuminate/Console/Scheduling/CommandBuilder.php +++ b/src/Illuminate/Console/Scheduling/CommandBuilder.php @@ -32,9 +32,9 @@ protected function buildForegroundCommand(Event $event) { $output = ProcessUtils::escapeArgument($event->output); - return $this->ensureCorrectUser( - $event, $event->command.($event->shouldAppendOutput ? ' >> ' : ' > ').$output.' 2>&1' - ); + return laravel_cloud() + ? $this->ensureCorrectUser($event, $event->command.' 2>&1 | tee '.($event->shouldAppendOutput ? '-a ' : '').$output) + : $this->ensureCorrectUser($event, $event->command.($event->shouldAppendOutput ? ' >> ' : ' > ').$output.' 2>&1'); } /** diff --git a/src/Illuminate/Console/Scheduling/Event.php b/src/Illuminate/Console/Scheduling/Event.php index 448f02a77be6..98a9641a3346 100644 --- a/src/Illuminate/Console/Scheduling/Event.php +++ b/src/Illuminate/Console/Scheduling/Event.php @@ -26,7 +26,7 @@ class Event /** * The command string. * - * @var string + * @var string|null */ public $command; @@ -47,7 +47,7 @@ class Event /** * The user the command should run as. * - * @var string + * @var string|null */ public $user; @@ -80,7 +80,7 @@ class Event public $onOneServer = false; /** - * The amount of time the mutex should be valid. + * The number of minutes the mutex should be valid. * * @var int */ @@ -138,7 +138,7 @@ class Event /** * The human readable description of the event. * - * @var string + * @var string|null */ public $description; @@ -149,6 +149,13 @@ class Event */ public $mutex; + /** + * The mutex name resolver callback. + * + * @var \Closure|null + */ + public $mutexNameResolver; + /** * The exit status code of the command. * @@ -245,7 +252,11 @@ protected function execute($container) { return Process::fromShellCommandline( $this->buildCommand(), base_path(), null, null, null - )->run(); + )->run( + laravel_cloud() + ? fn ($type, $line) => fwrite($type === 'out' ? STDOUT : STDERR, $line) + : fn () => true + ); } /** @@ -652,6 +663,8 @@ public function evenInMaintenanceMode() /** * Do not allow the event to overlap each other. * + * The expiration time of the underlying cache lock may be specified in minutes. + * * @param int $expiresAt * @return $this */ @@ -935,9 +948,28 @@ public function preventOverlapsUsing(EventMutex $mutex) */ public function mutexName() { + $mutexNameResolver = $this->mutexNameResolver; + + if (! is_null($mutexNameResolver) && is_callable($mutexNameResolver)) { + return $mutexNameResolver($this); + } + return 'framework'.DIRECTORY_SEPARATOR.'schedule-'.sha1($this->expression.$this->command); } + /** + * Set the mutex name or name resolver callback. + * + * @param \Closure|string $mutexName + * @return $this + */ + public function createMutexNameUsing(Closure|string $mutexName) + { + $this->mutexNameResolver = is_string($mutexName) ? fn () => $mutexName : $mutexName; + + return $this; + } + /** * Delete the mutex for the event. * diff --git a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php index 93555b76f124..8eb7fea22ee9 100644 --- a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php +++ b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php @@ -174,6 +174,16 @@ public function hourlyAt($offset) return $this->spliceIntoPosition(1, $offset); } + /** + * Schedule the event to run every odd hour. + * + * @return $this + */ + public function everyOddHour() + { + return $this->spliceIntoPosition(1, 0)->spliceIntoPosition(2, '1-23/2'); + } + /** * Schedule the event to run every two hours. * @@ -546,7 +556,7 @@ public function timezone($timezone) */ protected function spliceIntoPosition($position, $value) { - $segments = explode(' ', $this->expression); + $segments = preg_split("/\s+/", $this->expression); $segments[$position - 1] = $value; diff --git a/src/Illuminate/Console/Scheduling/Schedule.php b/src/Illuminate/Console/Scheduling/Schedule.php index cda3d5d1c28a..2f62bf9e81d9 100644 --- a/src/Illuminate/Console/Scheduling/Schedule.php +++ b/src/Illuminate/Console/Scheduling/Schedule.php @@ -22,11 +22,17 @@ class Schedule use Macroable; const SUNDAY = 0; + const MONDAY = 1; + const TUESDAY = 2; + const WEDNESDAY = 3; + const THURSDAY = 4; + const FRIDAY = 5; + const SATURDAY = 6; /** diff --git a/src/Illuminate/Console/Scheduling/ScheduleClearCacheCommand.php b/src/Illuminate/Console/Scheduling/ScheduleClearCacheCommand.php index 0dd9424c4bd3..3deb1c6bcfda 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleClearCacheCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleClearCacheCommand.php @@ -32,7 +32,7 @@ public function handle(Schedule $schedule) foreach ($schedule->events($this->laravel) as $event) { if ($event->mutex->exists($event)) { - $this->line('Deleting mutex for: '.$event->command); + $this->components->info(sprintf('Deleting mutex for [%s]', $event->command)); $event->mutex->forget($event); @@ -41,7 +41,7 @@ public function handle(Schedule $schedule) } if (! $mutexCleared) { - $this->info('No mutex files were found.'); + $this->components->info('No mutex files were found.'); } } } diff --git a/src/Illuminate/Console/Scheduling/ScheduleListCommand.php b/src/Illuminate/Console/Scheduling/ScheduleListCommand.php index 16b9bf46dda3..9f26f09cef39 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleListCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleListCommand.php @@ -21,7 +21,10 @@ class ScheduleListCommand extends Command * * @var string */ - protected $signature = 'schedule:list {--timezone= : The timezone that times should be displayed in}'; + protected $signature = 'schedule:list + {--timezone= : The timezone that times should be displayed in} + {--next : Sort the listed tasks by their next due date} + '; /** * The name of the console command. @@ -39,7 +42,7 @@ class ScheduleListCommand extends Command * * @var string */ - protected $description = 'List the scheduled commands'; + protected $description = 'List all scheduled tasks'; /** * The terminal width resolver callback. @@ -61,7 +64,7 @@ public function handle(Schedule $schedule) $events = collect($schedule->events()); if ($events->isEmpty()) { - $this->comment('No scheduled tasks have been defined.'); + $this->components->info('No scheduled tasks have been defined.'); return; } @@ -72,22 +75,25 @@ public function handle(Schedule $schedule) $timezone = new DateTimeZone($this->option('timezone') ?? config('app.timezone')); + $events = $this->sortEvents($events, $timezone); + $events = $events->map(function ($event) use ($terminalWidth, $expressionSpacing, $timezone) { $expression = $this->formatCronExpression($event->expression, $expressionSpacing); - $command = $event->command; - $description = $event->description; + $command = $event->command ?? ''; + + $description = $event->description ?? ''; if (! $this->output->isVerbose()) { $command = str_replace([Application::phpBinary(), Application::artisanBinary()], [ 'php', preg_replace("#['\"]#", '', Application::artisanBinary()), - ], $event->command); + ], $command); } if ($event instanceof CallbackEvent) { - if (class_exists($event->description)) { - $command = $event->description; + if (class_exists($description)) { + $command = $description; $description = ''; } else { $command = 'Closure at: '.$this->getClosureLocation($event); @@ -98,10 +104,7 @@ public function handle(Schedule $schedule) $nextDueDateLabel = 'Next Due:'; - $nextDueDate = Carbon::create((new CronExpression($event->expression)) - ->getNextRunDate(Carbon::now()->setTimezone($event->timezone)) - ->setTimezone($timezone) - ); + $nextDueDate = $this->getNextDueDateForEvent($event, $timezone); $nextDueDate = $this->output->isVerbose() ? $nextDueDate->format('Y-m-d H:i:s P') @@ -145,9 +148,39 @@ public function handle(Schedule $schedule) */ private function getCronExpressionSpacing($events) { - $rows = $events->map(fn ($event) => array_map('mb_strlen', explode(' ', $event->expression))); + $rows = $events->map(fn ($event) => array_map('mb_strlen', preg_split("/\s+/", $event->expression))); + + return collect($rows[0] ?? [])->keys()->map(fn ($key) => $rows->max($key))->all(); + } + + /** + * Sorts the events by due date if option set. + * + * @param \Illuminate\Support\Collection $events + * @param \DateTimeZone $timezone + * @return \Illuminate\Support\Collection + */ + private function sortEvents(\Illuminate\Support\Collection $events, DateTimeZone $timezone) + { + return $this->option('next') + ? $events->sortBy(fn ($event) => $this->getNextDueDateForEvent($event, $timezone)) + : $events; + } - return collect($rows[0] ?? [])->keys()->map(fn ($key) => $rows->max($key)); + /** + * Get the next due date for an event. + * + * @param \Illuminate\Console\Scheduling\Event $event + * @param \DateTimeZone $timezone + * @return \Illuminate\Support\Carbon + */ + private function getNextDueDateForEvent($event, DateTimeZone $timezone) + { + return Carbon::instance( + (new CronExpression($event->expression)) + ->getNextRunDate(Carbon::now()->setTimezone($event->timezone)) + ->setTimezone($timezone) + ); } /** @@ -159,7 +192,7 @@ private function getCronExpressionSpacing($events) */ private function formatCronExpression($expression, $spacing) { - $expressions = explode(' ', $expression); + $expressions = preg_split("/\s+/", $expression); return collect($spacing) ->map(fn ($length, $index) => str_pad($expressions[$index], $length)) @@ -188,8 +221,14 @@ private function getClosureLocation(CallbackEvent $event) ); } + if (is_string($callback)) { + return $callback; + } + if (is_array($callback)) { - return sprintf('%s::%s', $callback[0]::class, $callback[1]); + $className = is_string($callback[0]) ? $callback[0] : $callback[0]::class; + + return sprintf('%s::%s', $className, $callback[1]); } return sprintf('%s::__invoke', $callback::class); diff --git a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php index 680c0e107b8d..067aebd05519 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php @@ -2,6 +2,7 @@ namespace Illuminate\Console\Scheduling; +use Illuminate\Console\Application; use Illuminate\Console\Command; use Illuminate\Console\Events\ScheduledTaskFailed; use Illuminate\Console\Events\ScheduledTaskFinished; @@ -9,6 +10,7 @@ use Illuminate\Console\Events\ScheduledTaskStarting; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; use Symfony\Component\Console\Attribute\AsCommand; use Throwable; @@ -76,6 +78,13 @@ class ScheduleRunCommand extends Command */ protected $handler; + /** + * The PHP binary used by the command. + * + * @var string + */ + protected $phpBinary; + /** * Create a new command instance. * @@ -101,6 +110,9 @@ public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHand $this->schedule = $schedule; $this->dispatcher = $dispatcher; $this->handler = $handler; + $this->phpBinary = Application::phpBinary(); + + $this->newLine(); foreach ($this->schedule->dueEvents($this->laravel) as $event) { if (! $event->filtersPass($this->laravel)) { @@ -119,7 +131,9 @@ public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHand } if (! $this->eventsRan) { - $this->info('No scheduled commands are ready to run.'); + $this->components->info('No scheduled commands are ready to run.'); + } else { + $this->newLine(); } } @@ -134,7 +148,9 @@ protected function runSingleServerEvent($event) if ($this->schedule->serverShouldRun($event, $this->startedAt)) { $this->runEvent($event); } else { - $this->line('Skipping command (has already run on another server): '.$event->getSummaryForDisplay()); + $this->components->info(sprintf( + 'Skipping [%s], as command already run on another server.', $event->getSummaryForDisplay() + )); } } @@ -146,25 +162,46 @@ protected function runSingleServerEvent($event) */ protected function runEvent($event) { - $this->line('['.date('c').'] Running scheduled command: '.$event->getSummaryForDisplay()); + $summary = $event->getSummaryForDisplay(); - $this->dispatcher->dispatch(new ScheduledTaskStarting($event)); + $command = $event instanceof CallbackEvent + ? $summary + : trim(str_replace($this->phpBinary, '', $event->command)); - $start = microtime(true); + $description = sprintf( + '%s Running [%s]%s', + Carbon::now()->format('Y-m-d H:i:s'), + $command, + $event->runInBackground ? ' in background' : '', + ); - try { - $event->run($this->laravel); + $this->components->task($description, function () use ($event) { + $this->dispatcher->dispatch(new ScheduledTaskStarting($event)); - $this->dispatcher->dispatch(new ScheduledTaskFinished( - $event, - round(microtime(true) - $start, 2) - )); + $start = microtime(true); - $this->eventsRan = true; - } catch (Throwable $e) { - $this->dispatcher->dispatch(new ScheduledTaskFailed($event, $e)); + try { + $event->run($this->laravel); + + $this->dispatcher->dispatch(new ScheduledTaskFinished( + $event, + round(microtime(true) - $start, 2) + )); + + $this->eventsRan = true; + } catch (Throwable $e) { + $this->dispatcher->dispatch(new ScheduledTaskFailed($event, $e)); + + $this->handler->report($e); + } + + return $event->exitCode == 0; + }); - $this->handler->report($e); + if (! $event instanceof CallbackEvent) { + $this->components->bulletList([ + $event->getSummaryForDisplay(), + ]); } } } diff --git a/src/Illuminate/Console/Scheduling/ScheduleTestCommand.php b/src/Illuminate/Console/Scheduling/ScheduleTestCommand.php index 2ad1c88676c8..a8a1a596b77c 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleTestCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleTestCommand.php @@ -4,7 +4,6 @@ use Illuminate\Console\Application; use Illuminate\Console\Command; -use Illuminate\Support\Carbon; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'schedule:test')] @@ -43,6 +42,8 @@ class ScheduleTestCommand extends Command */ public function handle(Schedule $schedule) { + $phpBinary = Application::phpBinary(); + $commands = $schedule->events(); $commandNames = []; @@ -52,29 +53,47 @@ public function handle(Schedule $schedule) } if (empty($commandNames)) { - return $this->comment('No scheduled commands have been defined.'); + return $this->components->info('No scheduled commands have been defined.'); } if (! empty($name = $this->option('name'))) { - $commandBinary = Application::phpBinary().' '.Application::artisanBinary(); + $commandBinary = $phpBinary.' '.Application::artisanBinary(); $matches = array_filter($commandNames, function ($commandName) use ($commandBinary, $name) { return trim(str_replace($commandBinary, '', $commandName)) === $name; }); if (count($matches) !== 1) { - return $this->error('No matching scheduled command found.'); + $this->components->info('No matching scheduled command found.'); + + return; } $index = key($matches); } else { - $index = array_search($this->choice('Which command would you like to run?', $commandNames), $commandNames); + $index = array_search($this->components->choice('Which command would you like to run?', $commandNames), $commandNames); } $event = $commands[$index]; - $this->line('['.Carbon::now()->format('c').'] Running scheduled command: '.$event->getSummaryForDisplay()); + $summary = $event->getSummaryForDisplay(); + + $command = $event instanceof CallbackEvent + ? $summary + : trim(str_replace($phpBinary, '', $event->command)); + + $description = sprintf( + 'Running [%s]%s', + $command, + $event->runInBackground ? ' in background' : '', + ); + + $this->components->task($description, fn () => $event->run($this->laravel)); + + if (! $event instanceof CallbackEvent) { + $this->components->bulletList([$event->getSummaryForDisplay()]); + } - $event->run($this->laravel); + $this->newLine(); } } diff --git a/src/Illuminate/Console/Scheduling/ScheduleWorkCommand.php b/src/Illuminate/Console/Scheduling/ScheduleWorkCommand.php index 78d2411dcfdb..5ea067aa74af 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleWorkCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleWorkCommand.php @@ -4,18 +4,20 @@ use Illuminate\Console\Command; use Illuminate\Support\Carbon; +use Illuminate\Support\ProcessUtils; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; #[AsCommand(name: 'schedule:work')] class ScheduleWorkCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'schedule:work'; + protected $signature = 'schedule:work {--run-output-file= : The file to direct schedule:run output to}'; /** * The name of the console command. @@ -42,20 +44,29 @@ class ScheduleWorkCommand extends Command */ public function handle() { - $this->info('Schedule worker started successfully.'); + $this->components->info( + 'Running scheduled tasks every minute.', + $this->getLaravel()->isLocal() ? OutputInterface::VERBOSITY_NORMAL : OutputInterface::VERBOSITY_VERBOSE + ); - [$lastExecutionStartedAt, $keyOfLastExecutionWithOutput, $executions] = [null, null, []]; + [$lastExecutionStartedAt, $executions] = [null, []]; + + $command = implode(' ', array_map(fn ($arg) => ProcessUtils::escapeArgument($arg), [ + PHP_BINARY, + defined('ARTISAN_BINARY') ? ARTISAN_BINARY : 'artisan', + 'schedule:run', + ])); + + if ($this->option('run-output-file')) { + $command .= ' >> '.ProcessUtils::escapeArgument($this->option('run-output-file')).' 2>&1'; + } while (true) { usleep(100 * 1000); if (Carbon::now()->second === 0 && ! Carbon::now()->startOfMinute()->equalTo($lastExecutionStartedAt)) { - $executions[] = $execution = new Process([ - PHP_BINARY, - defined('ARTISAN_BINARY') ? ARTISAN_BINARY : 'artisan', - 'schedule:run', - ]); + $executions[] = $execution = Process::fromShellCommandline($command); $execution->start(); @@ -63,18 +74,10 @@ public function handle() } foreach ($executions as $key => $execution) { - $output = trim($execution->getIncrementalOutput()). - trim($execution->getIncrementalErrorOutput()); + $output = $execution->getIncrementalOutput(). + $execution->getIncrementalErrorOutput(); - if (! empty($output)) { - if ($key !== $keyOfLastExecutionWithOutput) { - $this->info(PHP_EOL.'['.date('c').'] Execution #'.($key + 1).' output:'); - - $keyOfLastExecutionWithOutput = $key; - } - - $this->output->writeln($output); - } + $this->output->write(ltrim($output, "\n")); if (! $execution->isRunning()) { unset($executions[$key]); diff --git a/src/Illuminate/Console/Signals.php b/src/Illuminate/Console/Signals.php new file mode 100644 index 000000000000..92a5c87098d6 --- /dev/null +++ b/src/Illuminate/Console/Signals.php @@ -0,0 +1,152 @@ +>|null + */ + protected $previousHandlers; + + /** + * The current availability resolver, if any. + * + * @var (callable(): bool)|null + */ + protected static $availabilityResolver; + + /** + * Create a new signal registrar instance. + * + * @param \Symfony\Component\Console\SignalRegistry\SignalRegistry $registry + * @return void + */ + public function __construct($registry) + { + $this->registry = $registry; + + $this->previousHandlers = $this->getHandlers(); + } + + /** + * Register a new signal handler. + * + * @param int $signal + * @param callable(int $signal): void $callback + * @return void + */ + public function register($signal, $callback) + { + $this->previousHandlers[$signal] ??= $this->initializeSignal($signal); + + with($this->getHandlers(), function ($handlers) use ($signal) { + $handlers[$signal] ??= $this->initializeSignal($signal); + + $this->setHandlers($handlers); + }); + + $this->registry->register($signal, $callback); + + with($this->getHandlers(), function ($handlers) use ($signal) { + $lastHandlerInserted = array_pop($handlers[$signal]); + + array_unshift($handlers[$signal], $lastHandlerInserted); + + $this->setHandlers($handlers); + }); + } + + /** + * Gets the signal's existing handler in array format. + * + * @return array + */ + protected function initializeSignal($signal) + { + return is_callable($existingHandler = pcntl_signal_get_handler($signal)) + ? [$existingHandler] + : null; + } + + /** + * Unregister the current signal handlers. + * + * @return array> + */ + public function unregister() + { + $previousHandlers = $this->previousHandlers; + + foreach ($previousHandlers as $signal => $handler) { + if (is_null($handler)) { + pcntl_signal($signal, SIG_DFL); + + unset($previousHandlers[$signal]); + } + } + + $this->setHandlers($previousHandlers); + } + + /** + * Execute the given callback if "signals" should be used and are available. + * + * @param callable $callback + * @return void + */ + public static function whenAvailable($callback) + { + $resolver = static::$availabilityResolver; + + if ($resolver()) { + $callback(); + } + } + + /** + * Get the registry's handlers. + * + * @return array> + */ + protected function getHandlers() + { + return (fn () => $this->signalHandlers) + ->call($this->registry); + } + + /** + * Set the registry's handlers. + * + * @param array> $handlers + * @return void + */ + protected function setHandlers($handlers) + { + (fn () => $this->signalHandlers = $handlers) + ->call($this->registry); + } + + /** + * Set the availability resolver. + * + * @param callable(): bool + * @return void + */ + public static function resolveAvailabilityUsing($resolver) + { + static::$availabilityResolver = $resolver; + } +} diff --git a/src/Illuminate/Console/View/Components/Alert.php b/src/Illuminate/Console/View/Components/Alert.php new file mode 100644 index 000000000000..a975aaf8346c --- /dev/null +++ b/src/Illuminate/Console/View/Components/Alert.php @@ -0,0 +1,28 @@ +mutate($string, [ + Mutators\EnsureDynamicContentIsHighlighted::class, + Mutators\EnsurePunctuation::class, + Mutators\EnsureRelativePaths::class, + ]); + + $this->renderView('alert', [ + 'content' => $string, + ], $verbosity); + } +} diff --git a/src/Illuminate/Console/View/Components/Ask.php b/src/Illuminate/Console/View/Components/Ask.php new file mode 100644 index 000000000000..9d359b131efd --- /dev/null +++ b/src/Illuminate/Console/View/Components/Ask.php @@ -0,0 +1,18 @@ +usingQuestionHelper(fn () => $this->output->ask($question, $default)); + } +} diff --git a/src/Illuminate/Console/View/Components/AskWithCompletion.php b/src/Illuminate/Console/View/Components/AskWithCompletion.php new file mode 100644 index 000000000000..103d73071b7a --- /dev/null +++ b/src/Illuminate/Console/View/Components/AskWithCompletion.php @@ -0,0 +1,29 @@ +setAutocompleterCallback($choices) + : $question->setAutocompleterValues($choices); + + return $this->usingQuestionHelper( + fn () => $this->output->askQuestion($question) + ); + } +} diff --git a/src/Illuminate/Console/View/Components/BulletList.php b/src/Illuminate/Console/View/Components/BulletList.php new file mode 100644 index 000000000000..da3d8817960e --- /dev/null +++ b/src/Illuminate/Console/View/Components/BulletList.php @@ -0,0 +1,28 @@ + $elements + * @param int $verbosity + * @return void + */ + public function render($elements, $verbosity = OutputInterface::VERBOSITY_NORMAL) + { + $elements = $this->mutate($elements, [ + Mutators\EnsureDynamicContentIsHighlighted::class, + Mutators\EnsureNoPunctuation::class, + Mutators\EnsureRelativePaths::class, + ]); + + $this->renderView('bullet-list', [ + 'elements' => $elements, + ], $verbosity); + } +} diff --git a/src/Illuminate/Console/View/Components/Choice.php b/src/Illuminate/Console/View/Components/Choice.php new file mode 100644 index 000000000000..8ef25be46044 --- /dev/null +++ b/src/Illuminate/Console/View/Components/Choice.php @@ -0,0 +1,29 @@ + $choices + * @param mixed $default + * @param int $attempts + * @param bool $multiple + * @return mixed + */ + public function render($question, $choices, $default = null, $attempts = null, $multiple = false) + { + return $this->usingQuestionHelper( + fn () => $this->output->askQuestion( + (new ChoiceQuestion($question, $choices, $default)) + ->setMaxAttempts($attempts) + ->setMultiselect($multiple) + ), + ); + } +} diff --git a/src/Illuminate/Console/View/Components/Component.php b/src/Illuminate/Console/View/Components/Component.php new file mode 100644 index 000000000000..e0dabbb2d1d2 --- /dev/null +++ b/src/Illuminate/Console/View/Components/Component.php @@ -0,0 +1,125 @@ + + */ + protected $mutators; + + /** + * Creates a new component instance. + * + * @param \Illuminate\Console\OutputStyle $output + * @return void + */ + public function __construct($output) + { + $this->output = $output; + } + + /** + * Renders the given view. + * + * @param string $view + * @param \Illuminate\Contracts\Support\Arrayable|array $data + * @param int $verbosity + * @return void + */ + protected function renderView($view, $data, $verbosity) + { + renderUsing($this->output); + + render((string) $this->compile($view, $data), $verbosity); + } + + /** + * Compile the given view contents. + * + * @param string $view + * @param array $data + * @return void + */ + protected function compile($view, $data) + { + extract($data); + + ob_start(); + + include __DIR__."/../../resources/views/components/$view.php"; + + return tap(ob_get_contents(), function () { + ob_end_clean(); + }); + } + + /** + * Mutates the given data with the given set of mutators. + * + * @param array|string $data + * @param array $mutators + * @return array|string + */ + protected function mutate($data, $mutators) + { + foreach ($mutators as $mutator) { + $mutator = new $mutator; + + if (is_iterable($data)) { + foreach ($data as $key => $value) { + $data[$key] = $mutator($value); + } + } else { + $data = $mutator($data); + } + } + + return $data; + } + + /** + * Eventually performs a question using the component's question helper. + * + * @param callable $callable + * @return mixed + */ + protected function usingQuestionHelper($callable) + { + $property = with(new ReflectionClass(OutputStyle::class)) + ->getParentClass() + ->getProperty('questionHelper'); + + $property->setAccessible(true); + + $currentHelper = $property->isInitialized($this->output) + ? $property->getValue($this->output) + : new SymfonyQuestionHelper(); + + $property->setValue($this->output, new QuestionHelper); + + try { + return $callable(); + } finally { + $property->setValue($this->output, $currentHelper); + } + } +} diff --git a/src/Illuminate/Console/View/Components/Confirm.php b/src/Illuminate/Console/View/Components/Confirm.php new file mode 100644 index 000000000000..1e98c1e2ad6c --- /dev/null +++ b/src/Illuminate/Console/View/Components/Confirm.php @@ -0,0 +1,20 @@ +usingQuestionHelper( + fn () => $this->output->confirm($question, $default), + ); + } +} diff --git a/src/Illuminate/Console/View/Components/Error.php b/src/Illuminate/Console/View/Components/Error.php new file mode 100644 index 000000000000..73196cc8440e --- /dev/null +++ b/src/Illuminate/Console/View/Components/Error.php @@ -0,0 +1,20 @@ +output))->render('error', $string, $verbosity); + } +} diff --git a/src/Illuminate/Console/View/Components/Factory.php b/src/Illuminate/Console/View/Components/Factory.php new file mode 100644 index 000000000000..a14a0b665a0d --- /dev/null +++ b/src/Illuminate/Console/View/Components/Factory.php @@ -0,0 +1,60 @@ +output = $output; + } + + /** + * Dynamically handle calls into the component instance. + * + * @param string $method + * @param array $parameters + * @return mixed + * + * @throws \InvalidArgumentException + */ + public function __call($method, $parameters) + { + $component = '\Illuminate\Console\View\Components\\'.ucfirst($method); + + throw_unless(class_exists($component), new InvalidArgumentException(sprintf( + 'Console component [%s] not found.', $method + ))); + + return with(new $component($this->output))->render(...$parameters); + } +} diff --git a/src/Illuminate/Console/View/Components/Info.php b/src/Illuminate/Console/View/Components/Info.php new file mode 100644 index 000000000000..765142246fed --- /dev/null +++ b/src/Illuminate/Console/View/Components/Info.php @@ -0,0 +1,20 @@ +output))->render('info', $string, $verbosity); + } +} diff --git a/src/Illuminate/Console/View/Components/Line.php b/src/Illuminate/Console/View/Components/Line.php new file mode 100644 index 000000000000..5c701ee5aa51 --- /dev/null +++ b/src/Illuminate/Console/View/Components/Line.php @@ -0,0 +1,54 @@ +> + */ + protected static $styles = [ + 'info' => [ + 'bgColor' => 'blue', + 'fgColor' => 'white', + 'title' => 'info', + ], + 'warn' => [ + 'bgColor' => 'yellow', + 'fgColor' => 'black', + 'title' => 'warn', + ], + 'error' => [ + 'bgColor' => 'red', + 'fgColor' => 'white', + 'title' => 'error', + ], + ]; + + /** + * Renders the component using the given arguments. + * + * @param string $style + * @param string $string + * @param int $verbosity + * @return void + */ + public function render($style, $string, $verbosity = OutputInterface::VERBOSITY_NORMAL) + { + $string = $this->mutate($string, [ + Mutators\EnsureDynamicContentIsHighlighted::class, + Mutators\EnsurePunctuation::class, + Mutators\EnsureRelativePaths::class, + ]); + + $this->renderView('line', array_merge(static::$styles[$style], [ + 'marginTop' => ($this->output instanceof NewLineAware && $this->output->newLineWritten()) ? 0 : 1, + 'content' => $string, + ]), $verbosity); + } +} diff --git a/src/Illuminate/Console/View/Components/Mutators/EnsureDynamicContentIsHighlighted.php b/src/Illuminate/Console/View/Components/Mutators/EnsureDynamicContentIsHighlighted.php new file mode 100644 index 000000000000..225e9f020847 --- /dev/null +++ b/src/Illuminate/Console/View/Components/Mutators/EnsureDynamicContentIsHighlighted.php @@ -0,0 +1,17 @@ +[$1]', (string) $string); + } +} diff --git a/src/Illuminate/Console/View/Components/Mutators/EnsureNoPunctuation.php b/src/Illuminate/Console/View/Components/Mutators/EnsureNoPunctuation.php new file mode 100644 index 000000000000..5f349362668e --- /dev/null +++ b/src/Illuminate/Console/View/Components/Mutators/EnsureNoPunctuation.php @@ -0,0 +1,21 @@ +endsWith(['.', '?', '!', ':'])) { + return substr_replace($string, '', -1); + } + + return $string; + } +} diff --git a/src/Illuminate/Console/View/Components/Mutators/EnsurePunctuation.php b/src/Illuminate/Console/View/Components/Mutators/EnsurePunctuation.php new file mode 100644 index 000000000000..c99fecffa9ec --- /dev/null +++ b/src/Illuminate/Console/View/Components/Mutators/EnsurePunctuation.php @@ -0,0 +1,21 @@ +endsWith(['.', '?', '!', ':'])) { + return "$string."; + } + + return $string; + } +} diff --git a/src/Illuminate/Console/View/Components/Mutators/EnsureRelativePaths.php b/src/Illuminate/Console/View/Components/Mutators/EnsureRelativePaths.php new file mode 100644 index 000000000000..babd0343d97e --- /dev/null +++ b/src/Illuminate/Console/View/Components/Mutators/EnsureRelativePaths.php @@ -0,0 +1,21 @@ +has('path.base')) { + $string = str_replace(base_path().'/', '', $string); + } + + return $string; + } +} diff --git a/src/Illuminate/Console/View/Components/Task.php b/src/Illuminate/Console/View/Components/Task.php new file mode 100644 index 000000000000..c5b326b17102 --- /dev/null +++ b/src/Illuminate/Console/View/Components/Task.php @@ -0,0 +1,58 @@ +mutate($description, [ + Mutators\EnsureDynamicContentIsHighlighted::class, + Mutators\EnsureNoPunctuation::class, + Mutators\EnsureRelativePaths::class, + ]); + + $descriptionWidth = mb_strlen(preg_replace("/\<[\w=#\/\;,:.&,%?]+\>|\\e\[\d+m/", '$1', $description) ?? ''); + + $this->output->write(" $description ", false, $verbosity); + + $startTime = microtime(true); + + $result = false; + + try { + $result = ($task ?: fn () => true)(); + } catch (Throwable $e) { + throw $e; + } finally { + $runTime = $task + ? (' '.number_format((microtime(true) - $startTime) * 1000).'ms') + : ''; + + $runTimeWidth = mb_strlen($runTime); + $width = min(terminal()->width(), 150); + $dots = max($width - $descriptionWidth - $runTimeWidth - 10, 0); + + $this->output->write(str_repeat('.', $dots), false, $verbosity); + $this->output->write("$runTime", false, $verbosity); + + $this->output->writeln( + $result !== false ? ' DONE' : ' FAIL', + $verbosity, + ); + } + } +} diff --git a/src/Illuminate/Console/View/Components/TwoColumnDetail.php b/src/Illuminate/Console/View/Components/TwoColumnDetail.php new file mode 100644 index 000000000000..1ffa089373ed --- /dev/null +++ b/src/Illuminate/Console/View/Components/TwoColumnDetail.php @@ -0,0 +1,36 @@ +mutate($first, [ + Mutators\EnsureDynamicContentIsHighlighted::class, + Mutators\EnsureNoPunctuation::class, + Mutators\EnsureRelativePaths::class, + ]); + + $second = $this->mutate($second, [ + Mutators\EnsureDynamicContentIsHighlighted::class, + Mutators\EnsureNoPunctuation::class, + Mutators\EnsureRelativePaths::class, + ]); + + $this->renderView('two-column-detail', [ + 'first' => $first, + 'second' => $second, + ], $verbosity); + } +} diff --git a/src/Illuminate/Console/View/Components/Warn.php b/src/Illuminate/Console/View/Components/Warn.php new file mode 100644 index 000000000000..20adb1f272b7 --- /dev/null +++ b/src/Illuminate/Console/View/Components/Warn.php @@ -0,0 +1,21 @@ +output)) + ->render('warn', $string, $verbosity); + } +} diff --git a/src/Illuminate/Console/composer.json b/src/Illuminate/Console/composer.json index 5a768863bc01..9242412ea995 100755 --- a/src/Illuminate/Console/composer.json +++ b/src/Illuminate/Console/composer.json @@ -15,11 +15,14 @@ ], "require": { "php": "^8.0.2", + "ext-mbstring": "*", "illuminate/collections": "^9.0", "illuminate/contracts": "^9.0", "illuminate/macroable": "^9.0", "illuminate/support": "^9.0", - "symfony/console": "^6.0", + "illuminate/view": "^9.0", + "nunomaduro/termwind": "^1.13", + "symfony/console": "^6.0.9", "symfony/process": "^6.0" }, "autoload": { @@ -33,8 +36,9 @@ } }, "suggest": { - "dragonmantank/cron-expression": "Required to use scheduler (^3.1).", - "guzzlehttp/guzzle": "Required to use the ping methods on schedules (^7.2).", + "ext-pcntl": "Required to use signal trapping.", + "dragonmantank/cron-expression": "Required to use scheduler (^3.3.2).", + "guzzlehttp/guzzle": "Required to use the ping methods on schedules (^7.5).", "illuminate/bus": "Required to use the scheduled job dispatcher (^9.0).", "illuminate/container": "Required to use the scheduler (^9.0).", "illuminate/filesystem": "Required to use the generator command (^9.0).", diff --git a/src/Illuminate/Console/resources/views/components/alert.php b/src/Illuminate/Console/resources/views/components/alert.php new file mode 100644 index 000000000000..bddcb21306d4 --- /dev/null +++ b/src/Illuminate/Console/resources/views/components/alert.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/Illuminate/Console/resources/views/components/bullet-list.php b/src/Illuminate/Console/resources/views/components/bullet-list.php new file mode 100644 index 000000000000..a016a9108121 --- /dev/null +++ b/src/Illuminate/Console/resources/views/components/bullet-list.php @@ -0,0 +1,7 @@ +
+ +
+ ⇂ +
+ +
diff --git a/src/Illuminate/Console/resources/views/components/line.php b/src/Illuminate/Console/resources/views/components/line.php new file mode 100644 index 000000000000..a759564c739a --- /dev/null +++ b/src/Illuminate/Console/resources/views/components/line.php @@ -0,0 +1,8 @@ +
+ + + + +
diff --git a/src/Illuminate/Console/resources/views/components/two-column-detail.php b/src/Illuminate/Console/resources/views/components/two-column-detail.php new file mode 100644 index 000000000000..1aeed496f8ae --- /dev/null +++ b/src/Illuminate/Console/resources/views/components/two-column-detail.php @@ -0,0 +1,11 @@ +
+ + + + + + + + + +
diff --git a/src/Illuminate/Container/Container.php b/src/Illuminate/Container/Container.php index 45d1b50bff8b..d51bcd86576e 100755 --- a/src/Illuminate/Container/Container.php +++ b/src/Illuminate/Container/Container.php @@ -631,9 +631,7 @@ protected function getReboundCallbacks($abstract) */ public function wrap(Closure $callback, array $parameters = []) { - return function () use ($callback, $parameters) { - return $this->call($callback, $parameters); - }; + return fn () => $this->call($callback, $parameters); } /** @@ -648,7 +646,25 @@ public function wrap(Closure $callback, array $parameters = []) */ public function call($callback, array $parameters = [], $defaultMethod = null) { - return BoundMethod::call($this, $callback, $parameters, $defaultMethod); + $pushedToBuildStack = false; + + if (is_array($callback) && ! in_array( + $className = (is_string($callback[0]) ? $callback[0] : get_class($callback[0])), + $this->buildStack, + true + )) { + $this->buildStack[] = $className; + + $pushedToBuildStack = true; + } + + $result = BoundMethod::call($this, $callback, $parameters, $defaultMethod); + + if ($pushedToBuildStack) { + array_pop($this->buildStack); + } + + return $result; } /** @@ -659,9 +675,7 @@ public function call($callback, array $parameters = [], $defaultMethod = null) */ public function factory($abstract) { - return function () use ($abstract) { - return $this->make($abstract); - }; + return fn () => $this->make($abstract); } /** @@ -1006,6 +1020,10 @@ protected function resolvePrimitive(ReflectionParameter $parameter) return $parameter->getDefaultValue(); } + if ($parameter->isVariadic()) { + return []; + } + $this->unresolvablePrimitive($parameter); } @@ -1061,9 +1079,7 @@ protected function resolveVariadicClass(ReflectionParameter $parameter) return $this->make($className); } - return array_map(function ($abstract) { - return $this->resolve($abstract); - }, $concrete); + return array_map(fn ($abstract) => $this->resolve($abstract), $concrete); } /** @@ -1109,7 +1125,7 @@ protected function unresolvablePrimitive(ReflectionParameter $parameter) * @param \Closure|null $callback * @return void */ - public function beforeResolving($abstract, Closure $callback = null) + public function beforeResolving($abstract, ?Closure $callback = null) { if (is_string($abstract)) { $abstract = $this->getAlias($abstract); @@ -1129,7 +1145,7 @@ public function beforeResolving($abstract, Closure $callback = null) * @param \Closure|null $callback * @return void */ - public function resolving($abstract, Closure $callback = null) + public function resolving($abstract, ?Closure $callback = null) { if (is_string($abstract)) { $abstract = $this->getAlias($abstract); @@ -1149,7 +1165,7 @@ public function resolving($abstract, Closure $callback = null) * @param \Closure|null $callback * @return void */ - public function afterResolving($abstract, Closure $callback = null) + public function afterResolving($abstract, ?Closure $callback = null) { if (is_string($abstract)) { $abstract = $this->getAlias($abstract); @@ -1388,7 +1404,7 @@ public static function getInstance() * @param \Illuminate\Contracts\Container\Container|null $container * @return \Illuminate\Contracts\Container\Container|static */ - public static function setInstance(ContainerContract $container = null) + public static function setInstance(?ContainerContract $container = null) { return static::$instance = $container; } @@ -1424,9 +1440,7 @@ public function offsetGet($key): mixed */ public function offsetSet($key, $value): void { - $this->bind($key, $value instanceof Closure ? $value : function () use ($value) { - return $value; - }); + $this->bind($key, $value instanceof Closure ? $value : fn () => $value); } /** diff --git a/src/Illuminate/Contracts/Auth/Access/Gate.php b/src/Illuminate/Contracts/Auth/Access/Gate.php index b88ab17965ed..2540506103a2 100644 --- a/src/Illuminate/Contracts/Auth/Access/Gate.php +++ b/src/Illuminate/Contracts/Auth/Access/Gate.php @@ -29,7 +29,7 @@ public function define($ability, $callback); * @param array|null $abilities * @return $this */ - public function resource($name, $class, array $abilities = null); + public function resource($name, $class, ?array $abilities = null); /** * Define a policy class for a given class type. diff --git a/src/Illuminate/Contracts/Auth/PasswordBroker.php b/src/Illuminate/Contracts/Auth/PasswordBroker.php index bbbe9b508688..c6b202329e39 100644 --- a/src/Illuminate/Contracts/Auth/PasswordBroker.php +++ b/src/Illuminate/Contracts/Auth/PasswordBroker.php @@ -48,7 +48,7 @@ interface PasswordBroker * @param \Closure|null $callback * @return string */ - public function sendResetLink(array $credentials, Closure $callback = null); + public function sendResetLink(array $credentials, ?Closure $callback = null); /** * Reset the password for the given token. diff --git a/src/Illuminate/Contracts/Broadcasting/ShouldBeUnique.php b/src/Illuminate/Contracts/Broadcasting/ShouldBeUnique.php new file mode 100644 index 000000000000..c72b7a8002f0 --- /dev/null +++ b/src/Illuminate/Contracts/Broadcasting/ShouldBeUnique.php @@ -0,0 +1,8 @@ +|CastsAttributes|CastsInboundAttributes */ public static function castUsing(array $arguments); } diff --git a/src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php b/src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php index 808d005f5c1d..878169c050cd 100644 --- a/src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php +++ b/src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php @@ -2,6 +2,10 @@ namespace Illuminate\Contracts\Database\Eloquent; +/** + * @template TGet + * @template TSet + */ interface CastsAttributes { /** @@ -11,7 +15,7 @@ interface CastsAttributes * @param string $key * @param mixed $value * @param array $attributes - * @return mixed + * @return TGet|null */ public function get($model, string $key, $value, array $attributes); @@ -20,7 +24,7 @@ public function get($model, string $key, $value, array $attributes); * * @param \Illuminate\Database\Eloquent\Model $model * @param string $key - * @param mixed $value + * @param TSet|null $value * @param array $attributes * @return mixed */ diff --git a/src/Illuminate/Contracts/Database/ModelIdentifier.php b/src/Illuminate/Contracts/Database/ModelIdentifier.php index 9893d280ef69..aacd18c079d8 100644 --- a/src/Illuminate/Contracts/Database/ModelIdentifier.php +++ b/src/Illuminate/Contracts/Database/ModelIdentifier.php @@ -34,6 +34,13 @@ class ModelIdentifier */ public $connection; + /** + * The class name of the model collection. + * + * @var string|null + */ + public $collectionClass; + /** * Create a new model identifier. * @@ -50,4 +57,17 @@ public function __construct($class, $id, array $relations, $connection) $this->relations = $relations; $this->connection = $connection; } + + /** + * Specify the collection class that should be used when serializing / restoring collections. + * + * @param string|null $collectionClass + * @return $this + */ + public function useCollectionClass(?string $collectionClass) + { + $this->collectionClass = $collectionClass; + + return $this; + } } diff --git a/src/Illuminate/Contracts/Foundation/Application.php b/src/Illuminate/Contracts/Foundation/Application.php index b46c6de4d847..3c4fbadbebd0 100644 --- a/src/Illuminate/Contracts/Foundation/Application.php +++ b/src/Illuminate/Contracts/Foundation/Application.php @@ -64,7 +64,7 @@ public function storagePath($path = ''); /** * Get or check the current application environment. * - * @param string|array $environments + * @param string|array ...$environments * @return string|bool */ public function environment(...$environments); diff --git a/src/Illuminate/Contracts/Routing/ResponseFactory.php b/src/Illuminate/Contracts/Routing/ResponseFactory.php index 86c16cab8f16..a6206a9c4849 100644 --- a/src/Illuminate/Contracts/Routing/ResponseFactory.php +++ b/src/Illuminate/Contracts/Routing/ResponseFactory.php @@ -60,7 +60,7 @@ public function jsonp($callback, $data = [], $status = 200, array $headers = [], /** * Create a new streamed response instance. * - * @param \Closure $callback + * @param callable $callback * @param int $status * @param array $headers * @return \Symfony\Component\HttpFoundation\StreamedResponse @@ -70,7 +70,7 @@ public function stream($callback, $status = 200, array $headers = []); /** * Create a new streamed response instance as a file download. * - * @param \Closure $callback + * @param callable $callback * @param string|null $name * @param array $headers * @param string|null $disposition diff --git a/src/Illuminate/Contracts/View/ViewCompilationException.php b/src/Illuminate/Contracts/View/ViewCompilationException.php new file mode 100644 index 000000000000..5c57619efd9e --- /dev/null +++ b/src/Illuminate/Contracts/View/ViewCompilationException.php @@ -0,0 +1,10 @@ +make($name, $value, 2628000, $path, $domain, $secure, $httpOnly, $raw, $sameSite); + return $this->make($name, $value, 576000, $path, $domain, $secure, $httpOnly, $raw, $sameSite); } /** @@ -135,7 +135,7 @@ public function queued($key, $default = null, $path = null) /** * Queue a cookie to send with the next response. * - * @param array $parameters + * @param mixed ...$parameters * @return void */ public function queue(...$parameters) @@ -192,7 +192,7 @@ public function unqueue($name, $path = null) * Get the path and domain, or the default values. * * @param string $path - * @param string $domain + * @param string|null $domain * @param bool|null $secure * @param string|null $sameSite * @return array @@ -206,8 +206,8 @@ protected function getPathAndDomain($path, $domain, $secure = null, $sameSite = * Set the default path and domain for the jar. * * @param string $path - * @param string $domain - * @param bool $secure + * @param string|null $domain + * @param bool|null $secure * @param string|null $sameSite * @return $this */ diff --git a/src/Illuminate/Cookie/Middleware/EncryptCookies.php b/src/Illuminate/Cookie/Middleware/EncryptCookies.php index 4d6c626eb9e5..d3fc9e97be36 100644 --- a/src/Illuminate/Cookie/Middleware/EncryptCookies.php +++ b/src/Illuminate/Cookie/Middleware/EncryptCookies.php @@ -22,7 +22,7 @@ class EncryptCookies /** * The names of the cookies that should not be encrypted. * - * @var array + * @var array */ protected $except = []; diff --git a/src/Illuminate/Cookie/composer.json b/src/Illuminate/Cookie/composer.json index a7a444cc333a..7a53f9b2e689 100755 --- a/src/Illuminate/Cookie/composer.json +++ b/src/Illuminate/Cookie/composer.json @@ -15,6 +15,7 @@ ], "require": { "php": "^8.0.2", + "ext-hash": "*", "illuminate/collections": "^9.0", "illuminate/contracts": "^9.0", "illuminate/macroable": "^9.0", diff --git a/src/Illuminate/Database/Capsule/Manager.php b/src/Illuminate/Database/Capsule/Manager.php index b877e7c6d20d..cfc47eb5abcf 100755 --- a/src/Illuminate/Database/Capsule/Manager.php +++ b/src/Illuminate/Database/Capsule/Manager.php @@ -27,7 +27,7 @@ class Manager * @param \Illuminate\Container\Container|null $container * @return void */ - public function __construct(Container $container = null) + public function __construct(?Container $container = null) { $this->setupContainer($container ?: new Container); diff --git a/src/Illuminate/Database/Concerns/BuildsQueries.php b/src/Illuminate/Database/Concerns/BuildsQueries.php index 0d70f3de3843..16dc024f5510 100644 --- a/src/Illuminate/Database/Concerns/BuildsQueries.php +++ b/src/Illuminate/Database/Concerns/BuildsQueries.php @@ -142,7 +142,7 @@ public function chunkById($count, callable $callback, $column = null, $alias = n return false; } - $lastId = $results->last()->{$alias}; + $lastId = data_get($results->last(), $alias); if ($lastId === null) { throw new RuntimeException("The chunkById operation was aborted because the [{$alias}] column is not present in the query result."); @@ -427,10 +427,10 @@ protected function getOriginalColumnNameForCursorPagination($builder, string $pa if (! is_null($columns)) { foreach ($columns as $column) { - if (($position = stripos($column, ' as ')) !== false) { - $as = substr($column, $position, 4); + if (($position = strripos($column, ' as ')) !== false) { + $original = substr($column, 0, $position); - [$original, $alias] = explode($as, $column); + $alias = substr($column, $position + 4); if ($parameter === $alias || $builder->getGrammar()->wrap($parameter) === $alias) { return $original; diff --git a/src/Illuminate/Database/Concerns/CompilesJsonPaths.php b/src/Illuminate/Database/Concerns/CompilesJsonPaths.php index cd520e788502..ade546153f5f 100644 --- a/src/Illuminate/Database/Concerns/CompilesJsonPaths.php +++ b/src/Illuminate/Database/Concerns/CompilesJsonPaths.php @@ -35,7 +35,7 @@ protected function wrapJsonPath($value, $delimiter = '->') $value = preg_replace("/([\\\\]+)?\\'/", "''", $value); $jsonPath = collect(explode($delimiter, $value)) - ->map(fn ($segment) => $this->wrapJsonPathSegment($segment)) + ->map(fn ($segment) => $this->wrapJsonPathSegment($segment)) ->join('.'); return "'$".(str_starts_with($jsonPath, '[') ? '' : '.').$jsonPath."'"; diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 4d232f2f3c8a..14661cc76ebf 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -43,6 +43,7 @@ public function transaction(Closure $callback, $attempts = 1) try { if ($this->transactions == 1) { + $this->fireConnectionEvent('committing'); $this->getPdo()->commit(); } @@ -188,7 +189,8 @@ protected function handleBeginTransactionException(Throwable $e) */ public function commit() { - if ($this->transactions == 1) { + if ($this->transactionLevel() == 1) { + $this->fireConnectionEvent('committing'); $this->getPdo()->commit(); } @@ -288,7 +290,11 @@ public function rollBack($toLevel = null) protected function performRollBack($toLevel) { if ($toLevel == 0) { - $this->getPdo()->rollBack(); + $pdo = $this->getPdo(); + + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } } elseif ($this->queryGrammar->supportsSavepoints()) { $this->getPdo()->exec( $this->queryGrammar->compileSavepointRollBack('trans'.($toLevel + 1)) diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php index d28250bf0fe7..c4bcb723bf6b 100755 --- a/src/Illuminate/Database/Connection.php +++ b/src/Illuminate/Database/Connection.php @@ -13,6 +13,7 @@ use Illuminate\Database\Events\StatementPrepared; use Illuminate\Database\Events\TransactionBeginning; use Illuminate\Database\Events\TransactionCommitted; +use Illuminate\Database\Events\TransactionCommitting; use Illuminate\Database\Events\TransactionRolledBack; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\Expression; @@ -978,6 +979,7 @@ protected function fireConnectionEvent($event) return $this->events?->dispatch(match ($event) { 'beganTransaction' => new TransactionBeginning($this), 'committed' => new TransactionCommitted($this), + 'committing' => new TransactionCommitting($this), 'rollingBack' => new TransactionRolledBack($this), default => null, }); @@ -1074,6 +1076,16 @@ public function isDoctrineAvailable() return class_exists('Doctrine\DBAL\Connection'); } + /** + * Indicates whether native alter operations will be used when dropping or renaming columns, even if Doctrine DBAL is installed. + * + * @return bool + */ + public function usingNativeSchemaOperations() + { + return ! $this->isDoctrineAvailable() || SchemaBuilder::$alwaysUsesNativeSchemaOperationsIfPossible; + } + /** * Get a Doctrine Schema Column instance. * @@ -1134,7 +1146,7 @@ public function getDoctrineConnection() /** * Register a custom Doctrine mapping type. * - * @param string $class + * @param Type|class-string $class * @param string $name * @param string $type * @return void @@ -1142,7 +1154,7 @@ public function getDoctrineConnection() * @throws \Doctrine\DBAL\DBALException * @throws \RuntimeException */ - public function registerDoctrineType(string $class, string $name, string $type): void + public function registerDoctrineType(Type|string $class, string $name, string $type): void { if (! $this->isDoctrineAvailable()) { throw new RuntimeException( @@ -1151,7 +1163,8 @@ public function registerDoctrineType(string $class, string $name, string $type): } if (! Type::hasType($name)) { - Type::addType($name, $class); + Type::getTypeRegistry() + ->register($name, is_string($class) ? new $class() : $class); } $this->doctrineTypeMappings[$name] = $type; diff --git a/src/Illuminate/Database/Connectors/PostgresConnector.php b/src/Illuminate/Database/Connectors/PostgresConnector.php index 6331bc2d786b..c54163f9b89f 100755 --- a/src/Illuminate/Database/Connectors/PostgresConnector.php +++ b/src/Illuminate/Database/Connectors/PostgresConnector.php @@ -163,6 +163,11 @@ protected function getDsn(array $config) $host = isset($host) ? "host={$host};" : ''; + // Sometimes - users may need to connect to a database that has a different + // name than the database used for "information_schema" queries. This is + // typically the case if using "pgbouncer" type software when pooling. + $database = $connect_via_database ?? $database; + $dsn = "pgsql:{$host}dbname='{$database}'"; // If a port was specified, we will add it to this Postgres DSN connections diff --git a/src/Illuminate/Database/Connectors/SQLiteConnector.php b/src/Illuminate/Database/Connectors/SQLiteConnector.php index 90dc16be24cc..ddedfbf99e9e 100755 --- a/src/Illuminate/Database/Connectors/SQLiteConnector.php +++ b/src/Illuminate/Database/Connectors/SQLiteConnector.php @@ -2,7 +2,7 @@ namespace Illuminate\Database\Connectors; -use InvalidArgumentException; +use Illuminate\Database\SQLiteDatabaseDoesNotExistException; class SQLiteConnector extends Connector implements ConnectorInterface { @@ -12,7 +12,7 @@ class SQLiteConnector extends Connector implements ConnectorInterface * @param array $config * @return \PDO * - * @throws \InvalidArgumentException + * @throws \Illuminate\Database\SQLiteDatabaseDoesNotExistException */ public function connect(array $config) { @@ -31,7 +31,7 @@ public function connect(array $config) // as the developer probably wants to know if the database exists and this // SQLite driver will not throw any exception if it does not by default. if ($path === false) { - throw new InvalidArgumentException("Database ({$config['database']}) does not exist."); + throw new SQLiteDatabaseDoesNotExistException($config['database']); } return $this->createConnection("sqlite:{$path}", $config, $options); diff --git a/src/Illuminate/Database/Connectors/SqlServerConnector.php b/src/Illuminate/Database/Connectors/SqlServerConnector.php index caefa684693f..b6ed47d196ac 100755 --- a/src/Illuminate/Database/Connectors/SqlServerConnector.php +++ b/src/Illuminate/Database/Connectors/SqlServerConnector.php @@ -29,7 +29,31 @@ public function connect(array $config) { $options = $this->getOptions($config); - return $this->createConnection($this->getDsn($config), $config, $options); + $connection = $this->createConnection($this->getDsn($config), $config, $options); + + $this->configureIsolationLevel($connection, $config); + + return $connection; + } + + /** + * Set the connection transaction isolation level. + * + * https://learn.microsoft.com/en-us/sql/t-sql/statements/set-transaction-isolation-level-transact-sql + * + * @param \PDO $connection + * @param array $config + * @return void + */ + protected function configureIsolationLevel($connection, array $config) + { + if (! isset($config['isolation_level'])) { + return; + } + + $connection->prepare( + "SET TRANSACTION ISOLATION LEVEL {$config['isolation_level']}" + )->execute(); } /** @@ -160,6 +184,10 @@ protected function getSqlSrvDsn(array $config) $arguments['LoginTimeout'] = $config['login_timeout']; } + if (isset($config['authentication'])) { + $arguments['Authentication'] = $config['authentication']; + } + return $this->buildConnectString('sqlsrv', $arguments); } diff --git a/src/Illuminate/Database/Console/DatabaseInspectionCommand.php b/src/Illuminate/Database/Console/DatabaseInspectionCommand.php new file mode 100644 index 000000000000..ae8ea88d09d8 --- /dev/null +++ b/src/Illuminate/Database/Console/DatabaseInspectionCommand.php @@ -0,0 +1,246 @@ + 'string', + 'citext' => 'string', + 'enum' => 'string', + 'geometry' => 'string', + 'geomcollection' => 'string', + 'linestring' => 'string', + 'ltree' => 'string', + 'multilinestring' => 'string', + 'multipoint' => 'string', + 'multipolygon' => 'string', + 'point' => 'string', + 'polygon' => 'string', + 'sysname' => 'string', + ]; + + /** + * The Composer instance. + * + * @var \Illuminate\Support\Composer + */ + protected $composer; + + /** + * Create a new command instance. + * + * @param \Illuminate\Support\Composer|null $composer + * @return void + */ + public function __construct(?Composer $composer = null) + { + parent::__construct(); + + $this->composer = $composer ?? $this->laravel->make(Composer::class); + } + + /** + * Register the custom Doctrine type mappings for inspection commands. + * + * @param \Doctrine\DBAL\Platforms\AbstractPlatform $platform + * @return void + */ + protected function registerTypeMappings(AbstractPlatform $platform) + { + foreach ($this->typeMappings as $type => $value) { + $platform->registerDoctrineTypeMapping($type, $value); + } + } + + /** + * Get a human-readable platform name for the given platform. + * + * @param \Doctrine\DBAL\Platforms\AbstractPlatform $platform + * @param string $database + * @return string + */ + protected function getPlatformName(AbstractPlatform $platform, $database) + { + return match (class_basename($platform)) { + 'MySQLPlatform' => 'MySQL <= 5', + 'MySQL57Platform' => 'MySQL 5.7', + 'MySQL80Platform' => 'MySQL 8', + 'PostgreSQL100Platform', 'PostgreSQLPlatform' => 'Postgres', + 'SqlitePlatform' => 'SQLite', + 'SQLServerPlatform' => 'SQL Server', + 'SQLServer2012Platform' => 'SQL Server 2012', + default => $database, + }; + } + + /** + * Get the size of a table in bytes. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @param string $table + * @return int|null + */ + protected function getTableSize(ConnectionInterface $connection, string $table) + { + return match (true) { + $connection instanceof MySqlConnection => $this->getMySQLTableSize($connection, $table), + $connection instanceof PostgresConnection => $this->getPostgresTableSize($connection, $table), + $connection instanceof SQLiteConnection => $this->getSqliteTableSize($connection, $table), + default => null, + }; + } + + /** + * Get the size of a MySQL table in bytes. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @param string $table + * @return mixed + */ + protected function getMySQLTableSize(ConnectionInterface $connection, string $table) + { + $result = $connection->selectOne('SELECT (data_length + index_length) AS size FROM information_schema.TABLES WHERE table_schema = ? AND table_name = ?', [ + $connection->getDatabaseName(), + $table, + ]); + + return Arr::wrap((array) $result)['size']; + } + + /** + * Get the size of a Postgres table in bytes. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @param string $table + * @return mixed + */ + protected function getPostgresTableSize(ConnectionInterface $connection, string $table) + { + $result = $connection->selectOne('SELECT pg_total_relation_size(?) AS size;', [ + $table, + ]); + + return Arr::wrap((array) $result)['size']; + } + + /** + * Get the size of a SQLite table in bytes. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @param string $table + * @return mixed + */ + protected function getSqliteTableSize(ConnectionInterface $connection, string $table) + { + try { + $result = $connection->selectOne('SELECT SUM(pgsize) AS size FROM dbstat WHERE name=?', [ + $table, + ]); + + return Arr::wrap((array) $result)['size']; + } catch (QueryException $e) { + return null; + } + } + + /** + * Get the number of open connections for a database. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @return int|null + */ + protected function getConnectionCount(ConnectionInterface $connection) + { + $result = match (true) { + $connection instanceof MySqlConnection => $connection->selectOne('show status where variable_name = "threads_connected"'), + $connection instanceof PostgresConnection => $connection->selectOne('select count(*) AS "Value" from pg_stat_activity'), + $connection instanceof SqlServerConnection => $connection->selectOne('SELECT COUNT(*) Value FROM sys.dm_exec_sessions WHERE status = ?', ['running']), + default => null, + }; + + if (! $result) { + return null; + } + + return Arr::wrap((array) $result)['Value']; + } + + /** + * Get the connection configuration details for the given connection. + * + * @param string $database + * @return array + */ + protected function getConfigFromDatabase($database) + { + $database ??= config('database.default'); + + return Arr::except(config('database.connections.'.$database), ['password']); + } + + /** + * Ensure the dependencies for the database commands are available. + * + * @return bool + */ + protected function ensureDependenciesExist() + { + return tap(interface_exists('Doctrine\DBAL\Driver'), function ($dependenciesExist) { + if (! $dependenciesExist && $this->components->confirm('Inspecting database information requires the Doctrine DBAL (doctrine/dbal) package. Would you like to install it?')) { + $this->installDependencies(); + } + }); + } + + /** + * Install the command's dependencies. + * + * @return void + * + * @throws \Symfony\Component\Process\Exception\ProcessSignaledException + */ + protected function installDependencies() + { + $command = collect($this->composer->findComposer()) + ->push('require doctrine/dbal') + ->implode(' '); + + $process = Process::fromShellCommandline($command, null, null, null, null); + + if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) { + try { + $process->setTty(true); + } catch (RuntimeException $e) { + $this->components->warn($e->getMessage()); + } + } + + try { + $process->run(fn ($type, $line) => $this->output->write($line)); + } catch (ProcessSignaledException $e) { + if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) { + throw $e; + } + } + } +} diff --git a/src/Illuminate/Database/Console/DbCommand.php b/src/Illuminate/Database/Console/DbCommand.php index c2c459352b07..caecafe3a644 100644 --- a/src/Illuminate/Database/Console/DbCommand.php +++ b/src/Illuminate/Database/Console/DbCommand.php @@ -34,6 +34,14 @@ public function handle() { $connection = $this->getConnection(); + if (! isset($connection['host']) && $connection['driver'] !== 'sqlite') { + $this->components->error('No host specified for this database connection.'); + $this->line(' Use the [--read] and [--write] options to specify a read or write connection.'); + $this->newLine(); + + return Command::FAILURE; + } + (new Process( array_merge([$this->getCommand($connection)], $this->commandArguments($connection)), null, diff --git a/src/Illuminate/Database/Console/DumpCommand.php b/src/Illuminate/Database/Console/DumpCommand.php index 71171f32b744..3f21aaf7cd27 100644 --- a/src/Illuminate/Database/Console/DumpCommand.php +++ b/src/Illuminate/Database/Console/DumpCommand.php @@ -59,15 +59,17 @@ public function handle(ConnectionResolverInterface $connections, Dispatcher $dis $dispatcher->dispatch(new SchemaDumped($connection, $path)); - $this->info('Database schema dumped successfully.'); + $info = 'Database schema dumped'; if ($this->option('prune')) { (new Filesystem)->deleteDirectory( database_path('migrations'), $preserve = false ); - $this->info('Migrations pruned successfully.'); + $info .= ' and pruned'; } + + $this->components->info($info.' successfully.'); } /** @@ -92,7 +94,7 @@ protected function schemaState(Connection $connection) */ protected function path(Connection $connection) { - return tap($this->option('path') ?: database_path('schema/'.$connection->getName().'-schema.dump'), function ($path) { + return tap($this->option('path') ?: database_path('schema/'.$connection->getName().'-schema.sql'), function ($path) { (new Filesystem)->ensureDirectoryExists(dirname($path)); }); } diff --git a/src/Illuminate/Database/Console/Migrations/FreshCommand.php b/src/Illuminate/Database/Console/Migrations/FreshCommand.php index 7bfba0d78821..e319e74bc06a 100644 --- a/src/Illuminate/Database/Console/Migrations/FreshCommand.php +++ b/src/Illuminate/Database/Console/Migrations/FreshCommand.php @@ -39,12 +39,16 @@ public function handle() $database = $this->input->getOption('database'); - $this->call('db:wipe', array_filter([ + $this->newLine(); + + $this->components->task('Dropping all tables', fn () => $this->callSilent('db:wipe', array_filter([ '--database' => $database, '--drop-views' => $this->option('drop-views'), '--drop-types' => $this->option('drop-types'), '--force' => true, - ])); + ])) == 0); + + $this->newLine(); $this->call('migrate', array_filter([ '--database' => $database, diff --git a/src/Illuminate/Database/Console/Migrations/InstallCommand.php b/src/Illuminate/Database/Console/Migrations/InstallCommand.php index d69c2ab6b5aa..901a83babb30 100755 --- a/src/Illuminate/Database/Console/Migrations/InstallCommand.php +++ b/src/Illuminate/Database/Console/Migrations/InstallCommand.php @@ -53,7 +53,7 @@ public function handle() $this->repository->createRepository(); - $this->info('Migration table created successfully.'); + $this->components->info('Migration table created successfully.'); } /** diff --git a/src/Illuminate/Database/Console/Migrations/MigrateCommand.php b/src/Illuminate/Database/Console/Migrations/MigrateCommand.php index ea379e3f6d28..fc43bf5232e8 100755 --- a/src/Illuminate/Database/Console/Migrations/MigrateCommand.php +++ b/src/Illuminate/Database/Console/Migrations/MigrateCommand.php @@ -3,12 +3,16 @@ namespace Illuminate\Database\Console\Migrations; use Illuminate\Console\ConfirmableTrait; +use Illuminate\Contracts\Console\Isolatable; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Events\SchemaLoaded; use Illuminate\Database\Migrations\Migrator; +use Illuminate\Database\SQLiteDatabaseDoesNotExistException; use Illuminate\Database\SqlServerConnection; +use PDOException; +use Throwable; -class MigrateCommand extends BaseCommand +class MigrateCommand extends BaseCommand implements Isolatable { use ConfirmableTrait; @@ -80,7 +84,7 @@ public function handle() // Next, we will check to see if a path option has been defined. If it has // we will use the path relative to the root of this installation folder // so that migrations may be run for any path within the applications. - $this->migrator->setOutput($this->output) + $migrations = $this->migrator->setOutput($this->output) ->run($this->getMigrationPaths(), [ 'pretend' => $this->option('pretend'), 'step' => $this->option('step'), @@ -107,10 +111,16 @@ public function handle() */ protected function prepareDatabase() { - if (! $this->migrator->repositoryExists()) { - $this->call('migrate:install', array_filter([ - '--database' => $this->option('database'), - ])); + if (! $this->repositoryExists()) { + $this->components->info('Preparing database.'); + + $this->components->task('Creating migration table', function () { + return $this->callSilent('migrate:install', array_filter([ + '--database' => $this->option('database'), + ])) == 0; + }); + + $this->newLine(); } if (! $this->migrator->hasRunAnyMigrations() && ! $this->option('pretend')) { @@ -118,6 +128,98 @@ protected function prepareDatabase() } } + /** + * Determine if the migrator repository exists. + * + * @return bool + */ + protected function repositoryExists() + { + return retry(2, fn () => $this->migrator->repositoryExists(), 0, function ($e) { + try { + if ($e->getPrevious() instanceof SQLiteDatabaseDoesNotExistException) { + return $this->createMissingSqliteDatbase($e->getPrevious()->path); + } + + $connection = $this->migrator->resolveConnection($this->option('database')); + + if ( + $e->getPrevious() instanceof PDOException && + $e->getPrevious()->getCode() === 1049 && + $connection->getDriverName() === 'mysql') { + return $this->createMissingMysqlDatabase($connection); + } + + return false; + } catch (Throwable) { + return false; + } + }); + } + + /** + * Create a missing SQLite database. + * + * @param string $path + * @return bool + */ + protected function createMissingSqliteDatbase($path) + { + if ($this->option('force')) { + return touch($path); + } + + if ($this->option('no-interaction')) { + return false; + } + + $this->components->warn('The SQLite database does not exist: '.$path); + + if (! $this->components->confirm('Would you like to create it?')) { + return false; + } + + return touch($path); + } + + /** + * Create a missing MySQL database. + * + * @return bool + */ + protected function createMissingMysqlDatabase($connection) + { + if ($this->laravel['config']->get("database.connections.{$connection->getName()}.database") !== $connection->getDatabaseName()) { + return false; + } + + if (! $this->option('force') && $this->option('no-interaction')) { + return false; + } + + if (! $this->option('force') && ! $this->option('no-interaction')) { + $this->components->warn("The database '{$connection->getDatabaseName()}' does not exist on the '{$connection->getName()}' connection."); + + if (! $this->components->confirm('Would you like to create it?')) { + return false; + } + } + + try { + $this->laravel['config']->set("database.connections.{$connection->getName()}.database", null); + + $this->laravel['db']->purge(); + + $freshConnection = $this->migrator->resolveConnection($this->option('database')); + + return tap($freshConnection->unprepared("CREATE DATABASE IF NOT EXISTS `{$connection->getDatabaseName()}`"), function () { + $this->laravel['db']->purge(); + }); + } finally { + $this->laravel['config']->set("database.connections.{$connection->getName()}.database", $connection->getDatabaseName()); + } + } + /** * Load the schema state to seed the initial database schema structure. * @@ -135,20 +237,20 @@ protected function loadSchemaState() return; } - $this->line('Loading stored database schema: '.$path); - - $startTime = microtime(true); + $this->components->info('Loading stored database schemas.'); - // Since the schema file will create the "migrations" table and reload it to its - // proper state, we need to delete it here so we don't get an error that this - // table already exists when the stored database schema file gets executed. - $this->migrator->deleteRepository(); + $this->components->task($path, function () use ($connection, $path) { + // Since the schema file will create the "migrations" table and reload it to its + // proper state, we need to delete it here so we don't get an error that this + // table already exists when the stored database schema file gets executed. + $this->migrator->deleteRepository(); - $connection->getSchemaState()->handleOutputUsing(function ($type, $buffer) { - $this->output->write($buffer); - })->load($path); + $connection->getSchemaState()->handleOutputUsing(function ($type, $buffer) { + $this->output->write($buffer); + })->load($path); + }); - $runTime = number_format((microtime(true) - $startTime) * 1000, 2); + $this->newLine(); // Finally, we will fire an event that this schema has been loaded so developers // can perform any post schema load tasks that are necessary in listeners for @@ -156,8 +258,6 @@ protected function loadSchemaState() $this->dispatcher->dispatch( new SchemaLoaded($connection, $path) ); - - $this->line('Loaded stored database schema. ('.$runTime.'ms)'); } /** diff --git a/src/Illuminate/Database/Console/Migrations/MigrateMakeCommand.php b/src/Illuminate/Database/Console/Migrations/MigrateMakeCommand.php index 95c3a206e54a..75c06345b1bd 100644 --- a/src/Illuminate/Database/Console/Migrations/MigrateMakeCommand.php +++ b/src/Illuminate/Database/Console/Migrations/MigrateMakeCommand.php @@ -2,11 +2,12 @@ namespace Illuminate\Database\Console\Migrations; +use Illuminate\Contracts\Console\PromptsForMissingInput; use Illuminate\Database\Migrations\MigrationCreator; use Illuminate\Support\Composer; use Illuminate\Support\Str; -class MigrateMakeCommand extends BaseCommand +class MigrateMakeCommand extends BaseCommand implements PromptsForMissingInput { /** * The console command signature. @@ -18,7 +19,7 @@ class MigrateMakeCommand extends BaseCommand {--table= : The table to migrate} {--path= : The location where the migration file should be created} {--realpath : Indicate any provided migration file paths are pre-resolved absolute paths} - {--fullpath : Output the full path of the migration}'; + {--fullpath : Output the full path of the migration (Deprecated)}'; /** * The console command description. @@ -110,11 +111,7 @@ protected function writeMigration($name, $table, $create) $name, $this->getMigrationPath(), $table, $create ); - if (! $this->option('fullpath')) { - $file = pathinfo($file, PATHINFO_FILENAME); - } - - $this->line("Created Migration: {$file}"); + $this->components->info(sprintf('Migration [%s] created successfully.', $file)); } /** @@ -132,4 +129,16 @@ protected function getMigrationPath() return parent::getMigrationPath(); } + + /** + * Prompt for missing input arguments using the returned questions. + * + * @return array + */ + protected function promptForMissingArgumentsUsing() + { + return [ + 'name' => 'What should the migration be named?', + ]; + } } diff --git a/src/Illuminate/Database/Console/Migrations/ResetCommand.php b/src/Illuminate/Database/Console/Migrations/ResetCommand.php index 1f2babbc8d08..c5952fa0532a 100755 --- a/src/Illuminate/Database/Console/Migrations/ResetCommand.php +++ b/src/Illuminate/Database/Console/Migrations/ResetCommand.php @@ -60,7 +60,7 @@ public function handle() // start trying to rollback and re-run all of the migrations. If it's not // present we'll just bail out with an info message for the developers. if (! $this->migrator->repositoryExists()) { - return $this->comment('Migration table not found.'); + return $this->components->warn('Migration table not found.'); } $this->migrator->setOutput($this->output)->reset( diff --git a/src/Illuminate/Database/Console/Migrations/StatusCommand.php b/src/Illuminate/Database/Console/Migrations/StatusCommand.php index f57fe53a507f..aa01f07823de 100644 --- a/src/Illuminate/Database/Console/Migrations/StatusCommand.php +++ b/src/Illuminate/Database/Console/Migrations/StatusCommand.php @@ -51,7 +51,7 @@ public function handle() { return $this->migrator->usingConnection($this->option('database'), function () { if (! $this->migrator->repositoryExists()) { - $this->error('Migration table not found.'); + $this->components->error('Migration table not found.'); return 1; } @@ -61,9 +61,21 @@ public function handle() $batches = $this->migrator->getRepository()->getMigrationBatches(); if (count($migrations = $this->getStatusFor($ran, $batches)) > 0) { - $this->table(['Ran?', 'Migration', 'Batch'], $migrations); + $this->newLine(); + + $this->components->twoColumnDetail('Migration name', 'Batch / Status'); + + $migrations + ->when($this->option('pending'), fn ($collection) => $collection->filter(function ($migration) { + return str($migration[1])->contains('Pending'); + })) + ->each( + fn ($migration) => $this->components->twoColumnDetail($migration[0], $migration[1]) + ); + + $this->newLine(); } else { - $this->error('No migrations found'); + $this->components->info('No migrations found'); } }); } @@ -81,9 +93,15 @@ protected function getStatusFor(array $ran, array $batches) ->map(function ($migration) use ($ran, $batches) { $migrationName = $this->migrator->getMigrationName($migration); - return in_array($migrationName, $ran) - ? ['Yes', $migrationName, $batches[$migrationName]] - : ['No', $migrationName]; + $status = in_array($migrationName, $ran) + ? 'Ran' + : 'Pending'; + + if (in_array($migrationName, $ran)) { + $status = '['.$batches[$migrationName].'] '.$status; + } + + return [$migrationName, $status]; }); } @@ -106,9 +124,8 @@ protected function getOptions() { return [ ['database', null, InputOption::VALUE_OPTIONAL, 'The database connection to use'], - + ['pending', null, InputOption::VALUE_NONE, 'Only list pending migrations'], ['path', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'The path(s) to the migrations files to use'], - ['realpath', null, InputOption::VALUE_NONE, 'Indicate any provided migration file paths are pre-resolved absolute paths'], ]; } diff --git a/src/Illuminate/Database/Console/MonitorCommand.php b/src/Illuminate/Database/Console/MonitorCommand.php new file mode 100644 index 000000000000..5d0f3edcbdb2 --- /dev/null +++ b/src/Illuminate/Database/Console/MonitorCommand.php @@ -0,0 +1,151 @@ +connection = $connection; + $this->events = $events; + } + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $databases = $this->parseDatabases($this->option('databases')); + + $this->displayConnections($databases); + + if ($this->option('max')) { + $this->dispatchEvents($databases); + } + } + + /** + * Parse the database into an array of the connections. + * + * @param string $databases + * @return \Illuminate\Support\Collection + */ + protected function parseDatabases($databases) + { + return collect(explode(',', $databases))->map(function ($database) { + if (! $database) { + $database = $this->laravel['config']['database.default']; + } + + $maxConnections = $this->option('max'); + + return [ + 'database' => $database, + 'connections' => $connections = $this->getConnectionCount($this->connection->connection($database)), + 'status' => $maxConnections && $connections >= $maxConnections ? 'ALERT' : 'OK', + ]; + }); + } + + /** + * Display the databases and their connection counts in the console. + * + * @param \Illuminate\Support\Collection $databases + * @return void + */ + protected function displayConnections($databases) + { + $this->newLine(); + + $this->components->twoColumnDetail('Database name', 'Connections'); + + $databases->each(function ($database) { + $status = '['.$database['connections'].'] '.$database['status']; + + $this->components->twoColumnDetail($database['database'], $status); + }); + + $this->newLine(); + } + + /** + * Dispatch the database monitoring events. + * + * @param \Illuminate\Support\Collection $databases + * @return void + */ + protected function dispatchEvents($databases) + { + $databases->each(function ($database) { + if ($database['status'] === 'OK') { + return; + } + + $this->events->dispatch( + new DatabaseBusy( + $database['database'], + $database['connections'] + ) + ); + }); + } +} diff --git a/src/Illuminate/Database/Console/PruneCommand.php b/src/Illuminate/Database/Console/PruneCommand.php index b69aa86849d7..7ea6cecdd3a9 100644 --- a/src/Illuminate/Database/Console/PruneCommand.php +++ b/src/Illuminate/Database/Console/PruneCommand.php @@ -43,7 +43,7 @@ public function handle(Dispatcher $events) $models = $this->models(); if ($models->isEmpty()) { - $this->info('No prunable models found.'); + $this->components->info('No prunable models found.'); return; } @@ -56,29 +56,50 @@ public function handle(Dispatcher $events) return; } - $events->listen(ModelsPruned::class, function ($event) { - $this->info("{$event->count} [{$event->model}] records have been pruned."); - }); - - $models->each(function ($model) { - $instance = new $model; + $pruning = []; - $chunkSize = property_exists($instance, 'prunableChunkSize') - ? $instance->prunableChunkSize - : $this->option('chunk'); + $events->listen(ModelsPruned::class, function ($event) use (&$pruning) { + if (! in_array($event->model, $pruning)) { + $pruning[] = $event->model; - $total = $this->isPrunable($model) - ? $instance->pruneAll($chunkSize) - : 0; + $this->newLine(); - if ($total == 0) { - $this->info("No prunable [$model] records found."); + $this->components->info(sprintf('Pruning [%s] records.', $event->model)); } + + $this->components->twoColumnDetail($event->model, "{$event->count} records"); + }); + + $models->each(function ($model) { + $this->pruneModel($model); }); $events->forget(ModelsPruned::class); } + /** + * Prune the given model. + * + * @param string $model + * @return void + */ + protected function pruneModel(string $model) + { + $instance = new $model; + + $chunkSize = property_exists($instance, 'prunableChunkSize') + ? $instance->prunableChunkSize + : $this->option('chunk'); + + $total = $this->isPrunable($model) + ? $instance->pruneAll($chunkSize) + : 0; + + if ($total == 0) { + $this->components->info("No prunable [$model] records found."); + } + } + /** * Determine the models that should be pruned. * @@ -121,7 +142,7 @@ protected function models() /** * Get the default path where models are located. * - * @return string + * @return string|string[] */ protected function getDefaultPath() { @@ -157,9 +178,9 @@ protected function pretendToPrune($model) })->count(); if ($count === 0) { - $this->info("No prunable [$model] records found."); + $this->components->info("No prunable [$model] records found."); } else { - $this->info("{$count} [{$model}] records will be pruned."); + $this->components->info("{$count} [{$model}] records will be pruned."); } } } diff --git a/src/Illuminate/Database/Console/Seeds/SeedCommand.php b/src/Illuminate/Database/Console/Seeds/SeedCommand.php index f64af58354c3..235958648925 100644 --- a/src/Illuminate/Database/Console/Seeds/SeedCommand.php +++ b/src/Illuminate/Database/Console/Seeds/SeedCommand.php @@ -71,6 +71,8 @@ public function handle() return 1; } + $this->components->info('Seeding database.'); + $previousConnection = $this->resolver->getDefaultConnection(); $this->resolver->setDefaultConnection($this->getDatabase()); @@ -83,8 +85,6 @@ public function handle() $this->resolver->setDefaultConnection($previousConnection); } - $this->info('Database seeding completed successfully.'); - return 0; } diff --git a/src/Illuminate/Database/Console/ShowCommand.php b/src/Illuminate/Database/Console/ShowCommand.php new file mode 100644 index 000000000000..8fca46cafd16 --- /dev/null +++ b/src/Illuminate/Database/Console/ShowCommand.php @@ -0,0 +1,189 @@ + Note: This can be slow on large databases }; + {--views : Show the database views Note: This can be slow on large databases }'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Display information about the given database'; + + /** + * Execute the console command. + * + * @param \Illuminate\Database\ConnectionResolverInterface $connections + * @return int + */ + public function handle(ConnectionResolverInterface $connections) + { + if (! $this->ensureDependenciesExist()) { + return 1; + } + + $connection = $connections->connection($database = $this->input->getOption('database')); + + $schema = $connection->getDoctrineSchemaManager(); + + $this->registerTypeMappings($schema->getDatabasePlatform()); + + $data = [ + 'platform' => [ + 'config' => $this->getConfigFromDatabase($database), + 'name' => $this->getPlatformName($schema->getDatabasePlatform(), $database), + 'open_connections' => $this->getConnectionCount($connection), + ], + 'tables' => $this->tables($connection, $schema), + ]; + + if ($this->option('views')) { + $data['views'] = $this->collectViews($connection, $schema); + } + + $this->display($data); + + return 0; + } + + /** + * Get information regarding the tables within the database. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @param \Doctrine\DBAL\Schema\AbstractSchemaManager $schema + * @return \Illuminate\Support\Collection + */ + protected function tables(ConnectionInterface $connection, AbstractSchemaManager $schema) + { + return collect($schema->listTables())->map(fn (Table $table, $index) => [ + 'table' => $table->getName(), + 'size' => $this->getTableSize($connection, $table->getName()), + 'rows' => $this->option('counts') ? $connection->table($table->getName())->count() : null, + 'engine' => rescue(fn () => $table->getOption('engine'), null, false), + 'comment' => $table->getComment(), + ]); + } + + /** + * Get information regarding the views within the database. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @param \Doctrine\DBAL\Schema\AbstractSchemaManager $schema + * @return \Illuminate\Support\Collection + */ + protected function collectViews(ConnectionInterface $connection, AbstractSchemaManager $schema) + { + return collect($schema->listViews()) + ->reject(fn (View $view) => str($view->getName()) + ->startsWith(['pg_catalog', 'information_schema', 'spt_'])) + ->map(fn (View $view) => [ + 'view' => $view->getName(), + 'rows' => $connection->table($view->getName())->count(), + ]); + } + + /** + * Render the database information. + * + * @param array $data + * @return void + */ + protected function display(array $data) + { + $this->option('json') ? $this->displayJson($data) : $this->displayForCli($data); + } + + /** + * Render the database information as JSON. + * + * @param array $data + * @return void + */ + protected function displayJson(array $data) + { + $this->output->writeln(json_encode($data)); + } + + /** + * Render the database information formatted for the CLI. + * + * @param array $data + * @return void + */ + protected function displayForCli(array $data) + { + $platform = $data['platform']; + $tables = $data['tables']; + $views = $data['views'] ?? null; + + $this->newLine(); + + $this->components->twoColumnDetail(''.$platform['name'].''); + $this->components->twoColumnDetail('Database', Arr::get($platform['config'], 'database')); + $this->components->twoColumnDetail('Host', Arr::get($platform['config'], 'host')); + $this->components->twoColumnDetail('Port', Arr::get($platform['config'], 'port')); + $this->components->twoColumnDetail('Username', Arr::get($platform['config'], 'username')); + $this->components->twoColumnDetail('URL', Arr::get($platform['config'], 'url')); + $this->components->twoColumnDetail('Open Connections', $platform['open_connections']); + $this->components->twoColumnDetail('Tables', $tables->count()); + + if ($tableSizeSum = $tables->sum('size')) { + $this->components->twoColumnDetail('Total Size', number_format($tableSizeSum / 1024 / 1024, 2).'MiB'); + } + + $this->newLine(); + + if ($tables->isNotEmpty()) { + $this->components->twoColumnDetail('Table', 'Size (MiB)'.($this->option('counts') ? ' / Rows' : '')); + + $tables->each(function ($table) { + if ($tableSize = $table['size']) { + $tableSize = number_format($tableSize / 1024 / 1024, 2); + } + + $this->components->twoColumnDetail( + $table['table'].($this->output->isVerbose() ? ' '.$table['engine'].'' : null), + ($tableSize ? $tableSize : '—').($this->option('counts') ? ' / '.number_format($table['rows']).'' : '') + ); + + if ($this->output->isVerbose()) { + if ($table['comment']) { + $this->components->bulletList([ + $table['comment'], + ]); + } + } + }); + + $this->newLine(); + } + + if ($views && $views->isNotEmpty()) { + $this->components->twoColumnDetail('View', 'Rows'); + + $views->each(fn ($view) => $this->components->twoColumnDetail($view['view'], number_format($view['rows']))); + + $this->newLine(); + } + } +} diff --git a/src/Illuminate/Database/Console/TableCommand.php b/src/Illuminate/Database/Console/TableCommand.php new file mode 100644 index 000000000000..3b08bde064e6 --- /dev/null +++ b/src/Illuminate/Database/Console/TableCommand.php @@ -0,0 +1,246 @@ +ensureDependenciesExist()) { + return 1; + } + + $connection = $connections->connection($this->input->getOption('database')); + + $schema = $connection->getDoctrineSchemaManager(); + + $this->registerTypeMappings($schema->getDatabasePlatform()); + + $table = $this->argument('table') ?: $this->components->choice( + 'Which table would you like to inspect?', + collect($schema->listTables())->flatMap(fn (Table $table) => [$table->getName()])->toArray() + ); + + if (! $schema->tablesExist([$table])) { + return $this->components->warn("Table [{$table}] doesn't exist."); + } + + $table = $schema->listTableDetails($table); + + $columns = $this->columns($table); + $indexes = $this->indexes($table); + $foreignKeys = $this->foreignKeys($table); + + $data = [ + 'table' => [ + 'name' => $table->getName(), + 'columns' => $columns->count(), + 'size' => $this->getTableSize($connection, $table->getName()), + ], + 'columns' => $columns, + 'indexes' => $indexes, + 'foreign_keys' => $foreignKeys, + ]; + + $this->display($data); + + return 0; + } + + /** + * Get the information regarding the table's columns. + * + * @param \Doctrine\DBAL\Schema\Table $table + * @return \Illuminate\Support\Collection + */ + protected function columns(Table $table) + { + return collect($table->getColumns())->map(fn (Column $column) => [ + 'column' => $column->getName(), + 'attributes' => $this->getAttributesForColumn($column), + 'default' => $column->getDefault(), + 'type' => $column->getType()->getName(), + ]); + } + + /** + * Get the attributes for a table column. + * + * @param \Doctrine\DBAL\Schema\Column $column + * @return \Illuminate\Support\Collection + */ + protected function getAttributesForColumn(Column $column) + { + return collect([ + $column->getAutoincrement() ? 'autoincrement' : null, + 'type' => $column->getType()->getName(), + $column->getUnsigned() ? 'unsigned' : null, + ! $column->getNotNull() ? 'nullable' : null, + ])->filter(); + } + + /** + * Get the information regarding the table's indexes. + * + * @param \Doctrine\DBAL\Schema\Table $table + * @return \Illuminate\Support\Collection + */ + protected function indexes(Table $table) + { + return collect($table->getIndexes())->map(fn (Index $index) => [ + 'name' => $index->getName(), + 'columns' => collect($index->getColumns()), + 'attributes' => $this->getAttributesForIndex($index), + ]); + } + + /** + * Get the attributes for a table index. + * + * @param \Doctrine\DBAL\Schema\Index $index + * @return \Illuminate\Support\Collection + */ + protected function getAttributesForIndex(Index $index) + { + return collect([ + 'compound' => count($index->getColumns()) > 1, + 'unique' => $index->isUnique(), + 'primary' => $index->isPrimary(), + ])->filter()->keys()->map(fn ($attribute) => Str::lower($attribute)); + } + + /** + * Get the information regarding the table's foreign keys. + * + * @param \Doctrine\DBAL\Schema\Table $table + * @return \Illuminate\Support\Collection + */ + protected function foreignKeys(Table $table) + { + return collect($table->getForeignKeys())->map(fn (ForeignKeyConstraint $foreignKey) => [ + 'name' => $foreignKey->getName(), + 'local_table' => $table->getName(), + 'local_columns' => collect($foreignKey->getLocalColumns()), + 'foreign_table' => $foreignKey->getForeignTableName(), + 'foreign_columns' => collect($foreignKey->getForeignColumns()), + 'on_update' => Str::lower(rescue(fn () => $foreignKey->getOption('onUpdate'), 'N/A')), + 'on_delete' => Str::lower(rescue(fn () => $foreignKey->getOption('onDelete'), 'N/A')), + ]); + } + + /** + * Render the table information. + * + * @param array $data + * @return void + */ + protected function display(array $data) + { + $this->option('json') ? $this->displayJson($data) : $this->displayForCli($data); + } + + /** + * Render the table information as JSON. + * + * @param array $data + * @return void + */ + protected function displayJson(array $data) + { + $this->output->writeln(json_encode($data)); + } + + /** + * Render the table information formatted for the CLI. + * + * @param array $data + * @return void + */ + protected function displayForCli(array $data) + { + [$table, $columns, $indexes, $foreignKeys] = [ + $data['table'], $data['columns'], $data['indexes'], $data['foreign_keys'], + ]; + + $this->newLine(); + + $this->components->twoColumnDetail(''.$table['name'].''); + $this->components->twoColumnDetail('Columns', $table['columns']); + + if ($size = $table['size']) { + $this->components->twoColumnDetail('Size', number_format($size / 1024 / 1024, 2).'MiB'); + } + + $this->newLine(); + + if ($columns->isNotEmpty()) { + $this->components->twoColumnDetail('Column', 'Type'); + + $columns->each(function ($column) { + $this->components->twoColumnDetail( + $column['column'].' '.$column['attributes']->implode(', ').'', + ($column['default'] ? ''.$column['default'].' ' : '').''.$column['type'].'' + ); + }); + + $this->newLine(); + } + + if ($indexes->isNotEmpty()) { + $this->components->twoColumnDetail('Index'); + + $indexes->each(function ($index) { + $this->components->twoColumnDetail( + $index['name'].' '.$index['columns']->implode(', ').'', + $index['attributes']->implode(', ') + ); + }); + + $this->newLine(); + } + + if ($foreignKeys->isNotEmpty()) { + $this->components->twoColumnDetail('Foreign Key', 'On Update / On Delete'); + + $foreignKeys->each(function ($foreignKey) { + $this->components->twoColumnDetail( + $foreignKey['name'].' '.$foreignKey['local_columns']->implode(', ').' references '.$foreignKey['foreign_columns']->implode(', ').' on '.$foreignKey['foreign_table'].'', + $foreignKey['on_update'].' / '.$foreignKey['on_delete'], + ); + }); + + $this->newLine(); + } + } +} diff --git a/src/Illuminate/Database/Console/WipeCommand.php b/src/Illuminate/Database/Console/WipeCommand.php index 0227782fa0c0..cb269229f9d5 100644 --- a/src/Illuminate/Database/Console/WipeCommand.php +++ b/src/Illuminate/Database/Console/WipeCommand.php @@ -53,17 +53,17 @@ public function handle() if ($this->option('drop-views')) { $this->dropAllViews($database); - $this->info('Dropped all views successfully.'); + $this->components->info('Dropped all views successfully.'); } $this->dropAllTables($database); - $this->info('Dropped all tables successfully.'); + $this->components->info('Dropped all tables successfully.'); if ($this->option('drop-types')) { $this->dropAllTypes($database); - $this->info('Dropped all types successfully.'); + $this->components->info('Dropped all types successfully.'); } return 0; diff --git a/src/Illuminate/Database/DBAL/TimestampType.php b/src/Illuminate/Database/DBAL/TimestampType.php index 4fa985153594..4a863bcd7ed5 100644 --- a/src/Illuminate/Database/DBAL/TimestampType.php +++ b/src/Illuminate/Database/DBAL/TimestampType.php @@ -11,6 +11,8 @@ class TimestampType extends Type implements PhpDateTimeMappingType { /** * {@inheritdoc} + * + * @return string */ public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) { @@ -87,6 +89,8 @@ protected function getSQLitePlatformSQLDeclaration(array $fieldDeclaration) /** * {@inheritdoc} + * + * @return string */ public function getName() { diff --git a/src/Illuminate/Database/DatabaseManager.php b/src/Illuminate/Database/DatabaseManager.php index 4f990711914a..fc8135383b33 100755 --- a/src/Illuminate/Database/DatabaseManager.php +++ b/src/Illuminate/Database/DatabaseManager.php @@ -4,6 +4,7 @@ use Doctrine\DBAL\Types\Type; use Illuminate\Database\Connectors\ConnectionFactory; +use Illuminate\Database\Events\ConnectionEstablished; use Illuminate\Support\Arr; use Illuminate\Support\ConfigurationUrlParser; use Illuminate\Support\Str; @@ -99,6 +100,12 @@ public function connection($name = null) $this->connections[$name] = $this->configure( $this->makeConnection($database), $type ); + + if ($this->app->bound('events')) { + $this->app['events']->dispatch( + new ConnectionEstablished($this->connections[$name]) + ); + } } return $this->connections[$name]; diff --git a/src/Illuminate/Database/DetectsLostConnections.php b/src/Illuminate/Database/DetectsLostConnections.php index 8765a6b649c3..f0c216fe31d9 100644 --- a/src/Illuminate/Database/DetectsLostConnections.php +++ b/src/Illuminate/Database/DetectsLostConnections.php @@ -60,6 +60,7 @@ protected function causedByLostConnection(Throwable $e) 'TCP Provider: Error code 0x274C', 'SQLSTATE[HY000] [2002] No such file or directory', 'SSL: Operation timed out', + 'Reason: Server is in script upgrade mode. Only administrator can connect at this time.', ]); } } diff --git a/src/Illuminate/Database/Eloquent/BroadcastableModelEventOccurred.php b/src/Illuminate/Database/Eloquent/BroadcastableModelEventOccurred.php index 14be425afa43..249b18301d8a 100644 --- a/src/Illuminate/Database/Eloquent/BroadcastableModelEventOccurred.php +++ b/src/Illuminate/Database/Eloquent/BroadcastableModelEventOccurred.php @@ -46,6 +46,13 @@ class BroadcastableModelEventOccurred implements ShouldBroadcast */ public $queue; + /** + * Indicates whether the job should be dispatched after all database transactions have committed. + * + * @var bool|null + */ + public $afterCommit; + /** * Create a new event instance. * diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 4f4a3c6c392b..028ab0c3008e 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -96,12 +96,15 @@ class Builder implements BuilderContract 'count', 'dd', 'doesntExist', + 'doesntExistOr', 'dump', 'exists', + 'existsOr', 'explain', 'getBindings', 'getConnection', 'getGrammar', + 'implode', 'insert', 'insertGetId', 'insertOrIgnore', @@ -109,6 +112,7 @@ class Builder implements BuilderContract 'max', 'min', 'raw', + 'rawValue', 'sum', 'toSql', ]; @@ -192,7 +196,7 @@ public function withoutGlobalScope($scope) * @param array|null $scopes * @return $this */ - public function withoutGlobalScopes(array $scopes = null) + public function withoutGlobalScopes(?array $scopes = null) { if (! is_array($scopes)) { $scopes = array_keys($this->scopes); @@ -228,7 +232,11 @@ public function whereKey($id) } if (is_array($id) || $id instanceof Arrayable) { - $this->query->whereIn($this->model->getQualifiedKeyName(), $id); + if (in_array($this->model->getKeyType(), ['int', 'integer'])) { + $this->query->whereIntegerInRaw($this->model->getQualifiedKeyName(), $id); + } else { + $this->query->whereIn($this->model->getQualifiedKeyName(), $id); + } return $this; } @@ -253,7 +261,11 @@ public function whereKeyNot($id) } if (is_array($id) || $id instanceof Arrayable) { - $this->query->whereNotIn($this->model->getQualifiedKeyName(), $id); + if (in_array($this->model->getKeyType(), ['int', 'integer'])) { + $this->query->whereIntegerNotInRaw($this->model->getQualifiedKeyName(), $id); + } else { + $this->query->whereNotIn($this->model->getQualifiedKeyName(), $id); + } return $this; } @@ -506,7 +518,7 @@ public function findOrNew($id, $columns = ['*']) * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|static[]|static|mixed */ - public function findOr($id, $columns = ['*'], Closure $callback = null) + public function findOr($id, $columns = ['*'], ?Closure $callback = null) { if ($columns instanceof Closure) { $callback = $columns; @@ -593,7 +605,7 @@ public function firstOrFail($columns = ['*']) * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Model|static|mixed */ - public function firstOr($columns = ['*'], Closure $callback = null) + public function firstOr($columns = ['*'], ?Closure $callback = null) { if ($columns instanceof Closure) { $callback = $columns; @@ -837,7 +849,7 @@ protected function enforceOrderBy() } /** - * Get an array with the values of a given column. + * Get a collection with the values of a given column. * * @param string|\Illuminate\Database\Query\Expression $column * @param string|null $key @@ -1028,6 +1040,29 @@ public function upsert(array $values, $uniqueBy, $update = null) ); } + /** + * Update the column's update timestamp. + * + * @param string|null $column + * @return int|false + */ + public function touch($column = null) + { + $time = $this->model->freshTimestamp(); + + if ($column) { + return $this->toBase()->update([$column => $time]); + } + + $column = $this->model->getUpdatedAtColumn(); + + if (! $this->model->usesTimestamps() || is_null($column)) { + return false; + } + + return $this->toBase()->update([$column => $time]); + } + /** * Increment a column's value by a given amount. * @@ -1883,8 +1918,8 @@ public static function __callStatic($method, $parameters) protected static function registerMixin($mixin, $replace) { $methods = (new ReflectionClass($mixin))->getMethods( - ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED - ); + ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED + ); foreach ($methods as $method) { if ($replace || ! static::hasGlobalMacro($method->name)) { diff --git a/src/Illuminate/Database/Eloquent/Casts/ArrayObject.php b/src/Illuminate/Database/Eloquent/Casts/ArrayObject.php index 2da92c3346c8..176b7335ccda 100644 --- a/src/Illuminate/Database/Eloquent/Casts/ArrayObject.php +++ b/src/Illuminate/Database/Eloquent/Casts/ArrayObject.php @@ -6,6 +6,12 @@ use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; +/** + * @template TKey of array-key + * @template TItem + * + * @extends \ArrayObject + */ class ArrayObject extends BaseArrayObject implements Arrayable, JsonSerializable { /** diff --git a/src/Illuminate/Database/Eloquent/Casts/AsArrayObject.php b/src/Illuminate/Database/Eloquent/Casts/AsArrayObject.php index 34865b15f4ed..23543baf95ce 100644 --- a/src/Illuminate/Database/Eloquent/Casts/AsArrayObject.php +++ b/src/Illuminate/Database/Eloquent/Casts/AsArrayObject.php @@ -11,7 +11,7 @@ class AsArrayObject implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array $arguments - * @return object|string + * @return CastsAttributes, iterable> */ public static function castUsing(array $arguments) { diff --git a/src/Illuminate/Database/Eloquent/Casts/AsCollection.php b/src/Illuminate/Database/Eloquent/Casts/AsCollection.php index 3456767db97e..1a0dd83e08b0 100644 --- a/src/Illuminate/Database/Eloquent/Casts/AsCollection.php +++ b/src/Illuminate/Database/Eloquent/Casts/AsCollection.php @@ -12,7 +12,7 @@ class AsCollection implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array $arguments - * @return object|string + * @return CastsAttributes<\Illuminate\Support\Collection, iterable> */ public static function castUsing(array $arguments) { diff --git a/src/Illuminate/Database/Eloquent/Casts/AsEncryptedArrayObject.php b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedArrayObject.php index cd65624650ec..ce2b6639eeb3 100644 --- a/src/Illuminate/Database/Eloquent/Casts/AsEncryptedArrayObject.php +++ b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedArrayObject.php @@ -12,7 +12,7 @@ class AsEncryptedArrayObject implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array $arguments - * @return object|string + * @return CastsAttributes, iterable> */ public static function castUsing(array $arguments) { diff --git a/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php index 4d9fee7ece85..64cdf003bab0 100644 --- a/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php +++ b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php @@ -13,7 +13,7 @@ class AsEncryptedCollection implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array $arguments - * @return object|string + * @return CastsAttributes<\Illuminate\Support\Collection, iterable> */ public static function castUsing(array $arguments) { diff --git a/src/Illuminate/Database/Eloquent/Casts/AsEnumArrayObject.php b/src/Illuminate/Database/Eloquent/Casts/AsEnumArrayObject.php new file mode 100644 index 000000000000..5b477853769a --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/AsEnumArrayObject.php @@ -0,0 +1,84 @@ +} $arguments + * @return CastsAttributes, iterable> + */ + public static function castUsing(array $arguments) + { + return new class($arguments) implements CastsAttributes + { + protected $arguments; + + public function __construct(array $arguments) + { + $this->arguments = $arguments; + } + + public function get($model, $key, $value, $attributes) + { + if (! isset($attributes[$key]) || is_null($attributes[$key])) { + return; + } + + $data = json_decode($attributes[$key], true); + + if (! is_array($data)) { + return; + } + + $enumClass = $this->arguments[0]; + + return new ArrayObject((new Collection($data))->map(function ($value) use ($enumClass) { + return is_subclass_of($enumClass, BackedEnum::class) + ? $enumClass::from($value) + : constant($enumClass.'::'.$value); + })->toArray()); + } + + public function set($model, $key, $value, $attributes) + { + if ($value === null) { + return [$key => null]; + } + + $storable = []; + + foreach ($value as $enum) { + $storable[] = $this->getStorableEnumValue($enum); + } + + return [$key => json_encode($storable)]; + } + + public function serialize($model, string $key, $value, array $attributes) + { + return (new Collection($value->getArrayCopy()))->map(function ($enum) { + return $this->getStorableEnumValue($enum); + })->toArray(); + } + + protected function getStorableEnumValue($enum) + { + if (is_string($enum) || is_int($enum)) { + return $enum; + } + + return $enum instanceof BackedEnum ? $enum->value : $enum->name; + } + }; + } +} diff --git a/src/Illuminate/Database/Eloquent/Casts/AsEnumCollection.php b/src/Illuminate/Database/Eloquent/Casts/AsEnumCollection.php new file mode 100644 index 000000000000..ca1feb5a981e --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/AsEnumCollection.php @@ -0,0 +1,80 @@ +} $arguments + * @return CastsAttributes, iterable> + */ + public static function castUsing(array $arguments) + { + return new class($arguments) implements CastsAttributes + { + protected $arguments; + + public function __construct(array $arguments) + { + $this->arguments = $arguments; + } + + public function get($model, $key, $value, $attributes) + { + if (! isset($attributes[$key]) || is_null($attributes[$key])) { + return; + } + + $data = json_decode($attributes[$key], true); + + if (! is_array($data)) { + return; + } + + $enumClass = $this->arguments[0]; + + return (new Collection($data))->map(function ($value) use ($enumClass) { + return is_subclass_of($enumClass, BackedEnum::class) + ? $enumClass::from($value) + : constant($enumClass.'::'.$value); + }); + } + + public function set($model, $key, $value, $attributes) + { + $value = $value !== null + ? (new Collection($value))->map(function ($enum) { + return $this->getStorableEnumValue($enum); + })->toJson() + : null; + + return [$key => $value]; + } + + public function serialize($model, string $key, $value, array $attributes) + { + return (new Collection($value))->map(function ($enum) { + return $this->getStorableEnumValue($enum); + })->toArray(); + } + + protected function getStorableEnumValue($enum) + { + if (is_string($enum) || is_int($enum)) { + return $enum; + } + + return $enum instanceof BackedEnum ? $enum->value : $enum->name; + } + }; + } +} diff --git a/src/Illuminate/Database/Eloquent/Casts/AsStringable.php b/src/Illuminate/Database/Eloquent/Casts/AsStringable.php index 912659f38d54..c2927d2ecca8 100644 --- a/src/Illuminate/Database/Eloquent/Casts/AsStringable.php +++ b/src/Illuminate/Database/Eloquent/Casts/AsStringable.php @@ -12,7 +12,7 @@ class AsStringable implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array $arguments - * @return object|string + * @return CastsAttributes<\Illuminate\Support\Stringable, string|\Stringable> */ public static function castUsing(array $arguments) { diff --git a/src/Illuminate/Database/Eloquent/Casts/Attribute.php b/src/Illuminate/Database/Eloquent/Casts/Attribute.php index 3f9fd19e2bd7..4fe2d807b690 100644 --- a/src/Illuminate/Database/Eloquent/Casts/Attribute.php +++ b/src/Illuminate/Database/Eloquent/Casts/Attribute.php @@ -39,7 +39,7 @@ class Attribute * @param callable|null $set * @return void */ - public function __construct(callable $get = null, callable $set = null) + public function __construct(?callable $get = null, ?callable $set = null) { $this->get = $get; $this->set = $set; @@ -52,7 +52,7 @@ public function __construct(callable $get = null, callable $set = null) * @param callable|null $set * @return static */ - public static function make(callable $get = null, callable $set = null): static + public static function make(?callable $get = null, ?callable $set = null): static { return new static($get, $set); } diff --git a/src/Illuminate/Database/Eloquent/Collection.php b/src/Illuminate/Database/Eloquent/Collection.php index efdd507f8f91..0904b8e6ceed 100755 --- a/src/Illuminate/Database/Eloquent/Collection.php +++ b/src/Illuminate/Database/Eloquent/Collection.php @@ -24,7 +24,7 @@ class Collection extends BaseCollection implements QueueableCollection * * @param mixed $key * @param TFindDefault $default - * @return static|TModel|TFindDefault + * @return static|TModel|TFindDefault */ public function find($key, $default = null) { @@ -44,9 +44,7 @@ public function find($key, $default = null) return $this->whereIn($this->first()->getKeyName(), $key); } - return Arr::first($this->items, function ($model) use ($key) { - return $model->getKey() == $key; - }, $default); + return Arr::first($this->items, fn ($model) => $model->getKey() == $key, $default); } /** @@ -233,9 +231,7 @@ protected function loadMissingRelation(self $models, array $path) $relation = reset($relation); } - $models->filter(function ($model) use ($name) { - return ! is_null($model) && ! $model->relationLoaded($name); - })->load($relation); + $models->filter(fn ($model) => ! is_null($model) && ! $model->relationLoaded($name))->load($relation); if (empty($path)) { return; @@ -254,19 +250,15 @@ protected function loadMissingRelation(self $models, array $path) * Load a set of relationships onto the mixed relationship collection. * * @param string $relation - * @param array $relations + * @param array $relations * @return $this */ public function loadMorph($relation, $relations) { $this->pluck($relation) ->filter() - ->groupBy(function ($model) { - return get_class($model); - }) - ->each(function ($models, $className) use ($relations) { - static::make($models)->load($relations[$className] ?? []); - }); + ->groupBy(fn ($model) => get_class($model)) + ->each(fn ($models, $className) => static::make($models)->load($relations[$className] ?? [])); return $this; } @@ -275,19 +267,15 @@ public function loadMorph($relation, $relations) * Load a set of relationship counts onto the mixed relationship collection. * * @param string $relation - * @param array $relations + * @param array $relations * @return $this */ public function loadMorphCount($relation, $relations) { $this->pluck($relation) ->filter() - ->groupBy(function ($model) { - return get_class($model); - }) - ->each(function ($models, $className) use ($relations) { - static::make($models)->loadCount($relations[$className] ?? []); - }); + ->groupBy(fn ($model) => get_class($model)) + ->each(fn ($models, $className) => static::make($models)->loadCount($relations[$className] ?? [])); return $this; } @@ -295,7 +283,7 @@ public function loadMorphCount($relation, $relations) /** * Determine if a key exists in the collection. * - * @param (callable(TModel, TKey): bool)|TModel|string $key + * @param (callable(TModel, TKey): bool)|TModel|string|int $key * @param mixed $operator * @param mixed $value * @return bool @@ -307,14 +295,10 @@ public function contains($key, $operator = null, $value = null) } if ($key instanceof Model) { - return parent::contains(function ($model) use ($key) { - return $model->is($key); - }); + return parent::contains(fn ($model) => $model->is($key)); } - return parent::contains(function ($model) use ($key) { - return $model->getKey() == $key; - }); + return parent::contains(fn ($model) => $model->getKey() == $key); } /** @@ -324,9 +308,7 @@ public function contains($key, $operator = null, $value = null) */ public function modelKeys() { - return array_map(function ($model) { - return $model->getKey(); - }, $this->items); + return array_map(fn ($model) => $model->getKey(), $this->items); } /** @@ -358,9 +340,7 @@ public function map(callable $callback) { $result = parent::map($callback); - return $result->contains(function ($item) { - return ! $item instanceof Model; - }) ? $result->toBase() : $result; + return $result->contains(fn ($item) => ! $item instanceof Model) ? $result->toBase() : $result; } /** @@ -378,9 +358,7 @@ public function mapWithKeys(callable $callback) { $result = parent::mapWithKeys($callback); - return $result->contains(function ($item) { - return ! $item instanceof Model; - }) ? $result->toBase() : $result; + return $result->contains(fn ($item) => ! $item instanceof Model) ? $result->toBase() : $result; } /** @@ -403,12 +381,8 @@ public function fresh($with = []) ->get() ->getDictionary(); - return $this->filter(function ($model) use ($freshModels) { - return $model->exists && isset($freshModels[$model->getKey()]); - }) - ->map(function ($model) use ($freshModels) { - return $freshModels[$model->getKey()]; - }); + return $this->filter(fn ($model) => $model->exists && isset($freshModels[$model->getKey()])) + ->map(fn ($model) => $freshModels[$model->getKey()]); } /** @@ -525,6 +499,28 @@ public function makeVisible($attributes) return $this->each->makeVisible($attributes); } + /** + * Set the visible attributes across the entire collection. + * + * @param array $visible + * @return $this + */ + public function setVisible($visible) + { + return $this->each->setVisible($visible); + } + + /** + * Set the hidden attributes across the entire collection. + * + * @param array $hidden + * @return $this + */ + public function setHidden($hidden) + { + return $this->each->setHidden($hidden); + } + /** * Append an attribute across the entire collection. * @@ -560,38 +556,14 @@ public function getDictionary($items = null) */ /** - * Get an array with the values of a given key. + * Count the number of items in the collection by a field or using a callback. * - * @param string|array $value - * @param string|null $key - * @return \Illuminate\Support\Collection + * @param (callable(TModel, TKey): array-key)|string|null $countBy + * @return \Illuminate\Support\Collection */ - public function pluck($value, $key = null) + public function countBy($countBy = null) { - return $this->toBase()->pluck($value, $key); - } - - /** - * Get the keys of the collection items. - * - * @return \Illuminate\Support\Collection - */ - public function keys() - { - return $this->toBase()->keys(); - } - - /** - * Zip the collection together with one or more arrays. - * - * @template TZipValue - * - * @param \Illuminate\Contracts\Support\Arrayable|iterable ...$items - * @return \Illuminate\Support\Collection> - */ - public function zip($items) - { - return $this->toBase()->zip(...func_get_args()); + return $this->toBase()->countBy($countBy); } /** @@ -625,6 +597,16 @@ public function flip() return $this->toBase()->flip(); } + /** + * Get the keys of the collection items. + * + * @return \Illuminate\Support\Collection + */ + public function keys() + { + return $this->toBase()->keys(); + } + /** * Pad collection to the specified length with a value. * @@ -639,17 +621,40 @@ public function pad($size, $value) return $this->toBase()->pad($size, $value); } + /** + * Get an array with the values of a given key. + * + * @param string|array $value + * @param string|null $key + * @return \Illuminate\Support\Collection + */ + public function pluck($value, $key = null) + { + return $this->toBase()->pluck($value, $key); + } + + /** + * Zip the collection together with one or more arrays. + * + * @template TZipValue + * + * @param \Illuminate\Contracts\Support\Arrayable|iterable ...$items + * @return \Illuminate\Support\Collection> + */ + public function zip($items) + { + return $this->toBase()->zip(...func_get_args()); + } + /** * Get the comparison function to detect duplicates. * * @param bool $strict - * @return callable(TValue, TValue): bool + * @return callable(TModel, TModel): bool */ protected function duplicateComparator($strict) { - return function ($a, $b) { - return $a->is($b); - }; + return fn ($a, $b) => $a->is($b); } /** @@ -665,10 +670,10 @@ public function getQueueableClass() return; } - $class = get_class($this->first()); + $class = $this->getQueueableModelClass($this->first()); $this->each(function ($model) use ($class) { - if (get_class($model) !== $class) { + if ($this->getQueueableModelClass($model) !== $class) { throw new LogicException('Queueing collections with multiple model types is not supported.'); } }); @@ -676,6 +681,19 @@ public function getQueueableClass() return $class; } + /** + * Get the queueable class name for the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return string + */ + protected function getQueueableModelClass($model) + { + return method_exists($model, 'getQueueableClassName') + ? $model->getQueueableClassName() + : get_class($model); + } + /** * Get the identifiers for all of the entities. * @@ -755,9 +773,7 @@ public function toQuery() $class = get_class($model); - if ($this->filter(function ($model) use ($class) { - return ! $model instanceof $class; - })->isNotEmpty()) { + if ($this->filter(fn ($model) => ! $model instanceof $class)->isNotEmpty()) { throw new LogicException('Unable to create query for collection with mixed types.'); } diff --git a/src/Illuminate/Database/Eloquent/Concerns/GuardsAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/GuardsAttributes.php index 491a6caaf88b..bfb67754beff 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/GuardsAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/GuardsAttributes.php @@ -202,7 +202,7 @@ public function isGuarded($key) } return $this->getGuarded() == ['*'] || - ! empty(preg_grep('/^'.preg_quote($key).'$/i', $this->getGuarded())) || + ! empty(preg_grep('/^'.preg_quote($key, '/').'$/i', $this->getGuarded())) || ! $this->isGuardableColumn($key); } diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 089cfba958ab..2b1b7ca2998d 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -2,6 +2,10 @@ namespace Illuminate\Database\Eloquent\Concerns; +use BackedEnum; +use Brick\Math\BigDecimal; +use Brick\Math\Exception\MathException as BrickMathException; +use Brick\Math\RoundingMode; use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use DateTimeImmutable; @@ -16,11 +20,13 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\InvalidCastException; use Illuminate\Database\Eloquent\JsonEncodingException; +use Illuminate\Database\Eloquent\MissingAttributeException; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\LazyLoadingViolationException; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Collection as BaseCollection; +use Illuminate\Support\Exceptions\MathException; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Date; use Illuminate\Support\Str; @@ -165,10 +171,17 @@ trait HasAttributes */ protected static $setAttributeMutatorCache = []; + /** + * The cache of the converted cast types. + * + * @var array + */ + protected static $castTypeCache = []; + /** * The encrypter instance that is used to encrypt attributes. * - * @var \Illuminate\Contracts\Encryption\Encrypter + * @var \Illuminate\Contracts\Encryption\Encrypter|null */ public static $encrypter; @@ -281,11 +294,11 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt // If the attribute cast was a date or a datetime, we will serialize the date as // a string. This allows the developers to customize how dates are serialized // into an array without affecting how they are persisted into the storage. - if ($attributes[$key] && in_array($value, ['date', 'datetime', 'immutable_date', 'immutable_datetime'])) { + if (isset($attributes[$key]) && in_array($value, ['date', 'datetime', 'immutable_date', 'immutable_datetime'])) { $attributes[$key] = $this->serializeDate($attributes[$key]); } - if ($attributes[$key] && ($this->isCustomDateTimeCast($value) || + if (isset($attributes[$key]) && ($this->isCustomDateTimeCast($value) || $this->isImmutableCustomDateTimeCast($value))) { $attributes[$key] = $attributes[$key]->format(explode(':', $value, 2)[1]); } @@ -295,12 +308,12 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt $attributes[$key] = $this->serializeDate($attributes[$key]); } - if ($attributes[$key] && $this->isClassSerializable($key)) { + if (isset($attributes[$key]) && $this->isClassSerializable($key)) { $attributes[$key] = $this->serializeClassCastableAttribute($key, $attributes[$key]); } if ($this->isEnumCastable($key) && (! ($attributes[$key] ?? null) instanceof Arrayable)) { - $attributes[$key] = isset($attributes[$key]) ? $attributes[$key]->value : null; + $attributes[$key] = isset($attributes[$key]) ? $this->getStorableEnumValue($attributes[$key]) : null; } if ($attributes[$key] instanceof Arrayable) { @@ -437,10 +450,35 @@ public function getAttribute($key) // since we don't want to treat any of those methods as relationships because // they are all intended as helper methods and none of these are relations. if (method_exists(self::class, $key)) { - return; + return $this->throwMissingAttributeExceptionIfApplicable($key); + } + + return $this->isRelation($key) || $this->relationLoaded($key) + ? $this->getRelationValue($key) + : $this->throwMissingAttributeExceptionIfApplicable($key); + } + + /** + * Either throw a missing attribute exception or return null depending on Eloquent's configuration. + * + * @param string $key + * @return null + * + * @throws \Illuminate\Database\Eloquent\MissingAttributeException + */ + protected function throwMissingAttributeExceptionIfApplicable($key) + { + if ($this->exists && + ! $this->wasRecentlyCreated && + static::preventsAccessingMissingAttributes()) { + if (isset(static::$missingAttributeViolationCallback)) { + return call_user_func(static::$missingAttributeViolationCallback, $this, $key); + } + + throw new MissingAttributeException($this, $key); } - return $this->getRelationValue($key); + return null; } /** @@ -507,7 +545,7 @@ public function isRelation($key) } return method_exists($this, $key) || - (static::$relationResolvers[get_class($this)][$key] ?? null); + $this->relationResolver(static::class, $key); } /** @@ -807,7 +845,7 @@ protected function getEnumCastableAttributeValue($key, $value) return $value; } - return $castType::from($value); + return $this->getEnumCaseFromValue($castType, $value); } /** @@ -818,19 +856,23 @@ protected function getEnumCastableAttributeValue($key, $value) */ protected function getCastType($key) { - if ($this->isCustomDateTimeCast($this->getCasts()[$key])) { - return 'custom_datetime'; - } + $castType = $this->getCasts()[$key]; - if ($this->isImmutableCustomDateTimeCast($this->getCasts()[$key])) { - return 'immutable_custom_datetime'; + if (isset(static::$castTypeCache[$castType])) { + return static::$castTypeCache[$castType]; } - if ($this->isDecimalCast($this->getCasts()[$key])) { - return 'decimal'; + if ($this->isCustomDateTimeCast($castType)) { + $convertedCastType = 'custom_datetime'; + } elseif ($this->isImmutableCustomDateTimeCast($castType)) { + $convertedCastType = 'immutable_custom_datetime'; + } elseif ($this->isDecimalCast($castType)) { + $convertedCastType = 'decimal'; + } else { + $convertedCastType = trim(strtolower($castType)); } - return trim(strtolower($this->getCasts()[$key])); + return static::$castTypeCache[$castType] = $convertedCastType; } /** @@ -882,8 +924,8 @@ protected function isCustomDateTimeCast($cast) */ protected function isImmutableCustomDateTimeCast($cast) { - return strncmp($cast, 'immutable_date:', 15) === 0 || - strncmp($cast, 'immutable_datetime:', 19) === 0; + return str_starts_with($cast, 'immutable_date:') || + str_starts_with($cast, 'immutable_datetime:'); } /** @@ -918,7 +960,7 @@ public function setAttribute($key, $value) // If an attribute is listed as a "date", we'll convert it from a DateTime // instance into a form proper for storage on the database tables using // the connection grammar's date format. We will auto set the values. - elseif ($value && $this->isDateAttribute($key)) { + elseif (! is_null($value) && $this->isDateAttribute($key)) { $value = $this->fromDateTime($value); } @@ -1030,6 +1072,8 @@ protected function setAttributeMarkedMutatedAttributeValue($key, $value) } else { unset($this->attributeCastCache[$key]); } + + return $this; } /** @@ -1081,7 +1125,7 @@ protected function setClassCastableAttribute($key, $value) { $caster = $this->resolveCasterClass($key); - $this->attributes = array_merge( + $this->attributes = array_replace( $this->attributes, $this->normalizeCastClassResponse($key, $caster->set( $this, $key, $value, $this->attributes @@ -1099,7 +1143,7 @@ protected function setClassCastableAttribute($key, $value) * Set the value of an enum castable attribute. * * @param string $key - * @param \BackedEnum $value + * @param \UnitEnum|string|int $value * @return void */ protected function setEnumCastableAttribute($key, $value) @@ -1108,13 +1152,42 @@ protected function setEnumCastableAttribute($key, $value) if (! isset($value)) { $this->attributes[$key] = null; - } elseif ($value instanceof $enumClass) { - $this->attributes[$key] = $value->value; + } elseif (is_object($value)) { + $this->attributes[$key] = $this->getStorableEnumValue($value); } else { - $this->attributes[$key] = $enumClass::from($value)->value; + $this->attributes[$key] = $this->getStorableEnumValue( + $this->getEnumCaseFromValue($enumClass, $value) + ); } } + /** + * Get an enum case instance from a given class and value. + * + * @param string $enumClass + * @param string|int $value + * @return \UnitEnum|\BackedEnum + */ + protected function getEnumCaseFromValue($enumClass, $value) + { + return is_subclass_of($enumClass, BackedEnum::class) + ? $enumClass::from($value) + : constant($enumClass.'::'.$value); + } + + /** + * Get the storable value from the given enum. + * + * @param \UnitEnum|\BackedEnum $value + * @return string|int + */ + protected function getStorableEnumValue($value) + { + return $value instanceof BackedEnum + ? $value->value + : $value->name; + } + /** * Get an array attribute with the given key and value set. * @@ -1189,7 +1262,7 @@ protected function asJson($value) */ public function fromJson($value, $asObject = false) { - return json_decode($value, ! $asObject); + return json_decode($value ?? '', ! $asObject); } /** @@ -1218,7 +1291,7 @@ protected function castAttributeAsEncryptedString($key, $value) /** * Set the encrypter instance that will be used to encrypt attributes. * - * @param \Illuminate\Contracts\Encryption\Encrypter $encrypter + * @param \Illuminate\Contracts\Encryption\Encrypter|null $encrypter * @return void */ public static function encryptUsing($encrypter) @@ -1245,13 +1318,17 @@ public function fromFloat($value) /** * Return a decimal as string. * - * @param float $value + * @param float|string $value * @param int $decimals * @return string */ protected function asDecimal($value, $decimals) { - return number_format($value, $decimals, '.', ''); + try { + return (string) BigDecimal::of($value)->toScale($decimals, RoundingMode::HALF_UP); + } catch (BrickMathException $e) { + throw new MathException('Unable to cast value to a decimal.', previous: $e); + } } /** @@ -1491,11 +1568,13 @@ protected function isEncryptedCastable($key) */ protected function isClassCastable($key) { - if (! array_key_exists($key, $this->getCasts())) { + $casts = $this->getCasts(); + + if (! array_key_exists($key, $casts)) { return false; } - $castType = $this->parseCasterClass($this->getCasts()[$key]); + $castType = $this->parseCasterClass($casts[$key]); if (in_array($castType, static::$primitiveCastTypes)) { return false; @@ -1516,11 +1595,13 @@ protected function isClassCastable($key) */ protected function isEnumCastable($key) { - if (! array_key_exists($key, $this->getCasts())) { + $casts = $this->getCasts(); + + if (! array_key_exists($key, $casts)) { return false; } - $castType = $this->getCasts()[$key]; + $castType = $casts[$key]; if (in_array($castType, static::$primitiveCastTypes)) { return false; @@ -1541,9 +1622,13 @@ protected function isEnumCastable($key) */ protected function isClassDeviable($key) { - return $this->isClassCastable($key) && - method_exists($castType = $this->parseCasterClass($this->getCasts()[$key]), 'increment') && - method_exists($castType, 'decrement'); + if (! $this->isClassCastable($key)) { + return false; + } + + $castType = $this->resolveCasterClass($key); + + return method_exists($castType::class, 'increment') && method_exists($castType::class, 'decrement'); } /** @@ -1858,7 +1943,19 @@ public function isClean($attributes = null) } /** - * Determine if the model or any of the given attribute(s) have been modified. + * Discard attribute changes and reset the attributes to their original state. + * + * @return $this + */ + public function discardChanges() + { + [$this->attributes, $this->changes] = [$this->original, []]; + + return $this; + } + + /** + * Determine if the model or any of the given attribute(s) were changed when the model was last saved. * * @param array|string|null $attributes * @return bool @@ -1871,7 +1968,7 @@ public function wasChanged($attributes = null) } /** - * Determine if any of the given attributes were changed. + * Determine if any of the given attributes were changed when the model was last saved. * * @param array $changes * @param array|string|null $attributes @@ -1917,7 +2014,7 @@ public function getDirty() } /** - * Get the attributes that were changed. + * Get the attributes that were changed when the model was last saved. * * @return array */ @@ -2021,6 +2118,16 @@ public function append($attributes) return $this; } + /** + * Get the accessors that are being appended to model arrays. + * + * @return array + */ + public function getAppends() + { + return $this->appends; + } + /** * Set the accessors to append to model arrays. * @@ -2052,25 +2159,27 @@ public function hasAppended($attribute) */ public function getMutatedAttributes() { - $class = static::class; - - if (! isset(static::$mutatorCache[$class])) { - static::cacheMutatedAttributes($class); + if (! isset(static::$mutatorCache[static::class])) { + static::cacheMutatedAttributes($this); } - return static::$mutatorCache[$class]; + return static::$mutatorCache[static::class]; } /** * Extract and cache all the mutated attributes of a class. * - * @param string $class + * @param object|string $classOrInstance * @return void */ - public static function cacheMutatedAttributes($class) + public static function cacheMutatedAttributes($classOrInstance) { + $reflection = new ReflectionClass($classOrInstance); + + $class = $reflection->getName(); + static::$getAttributeMutatorCache[$class] = - collect($attributeMutatorMethods = static::getAttributeMarkedMutatorMethods($class)) + collect($attributeMutatorMethods = static::getAttributeMarkedMutatorMethods($classOrInstance)) ->mapWithKeys(function ($match) { return [lcfirst(static::$snakeAttributes ? Str::snake($match) : $match) => true]; })->all(); diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php b/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php index eb6a970985e6..37bc063aaa85 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php @@ -98,7 +98,7 @@ public function getObservableEvents() [ 'retrieved', 'creating', 'created', 'updating', 'updated', 'saving', 'saved', 'restoring', 'restored', 'replicating', - 'deleting', 'deleted', 'forceDeleted', + 'deleting', 'deleted', 'forceDeleting', 'forceDeleted', ], $this->observables ); @@ -147,7 +147,7 @@ public function removeObservableEvents($observables) * Register a model event with the dispatcher. * * @param string $event - * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback * @return void */ protected static function registerModelEvent($event, $callback) @@ -230,7 +230,7 @@ protected function filterModelEventResults($result) /** * Register a retrieved model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback * @return void */ public static function retrieved($callback) @@ -241,7 +241,7 @@ public static function retrieved($callback) /** * Register a saving model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback * @return void */ public static function saving($callback) @@ -252,7 +252,7 @@ public static function saving($callback) /** * Register a saved model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback * @return void */ public static function saved($callback) @@ -263,7 +263,7 @@ public static function saved($callback) /** * Register an updating model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback * @return void */ public static function updating($callback) @@ -274,7 +274,7 @@ public static function updating($callback) /** * Register an updated model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback * @return void */ public static function updated($callback) @@ -285,7 +285,7 @@ public static function updated($callback) /** * Register a creating model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback * @return void */ public static function creating($callback) @@ -296,7 +296,7 @@ public static function creating($callback) /** * Register a created model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback * @return void */ public static function created($callback) @@ -307,7 +307,7 @@ public static function created($callback) /** * Register a replicating model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback * @return void */ public static function replicating($callback) @@ -318,7 +318,7 @@ public static function replicating($callback) /** * Register a deleting model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback * @return void */ public static function deleting($callback) @@ -329,7 +329,7 @@ public static function deleting($callback) /** * Register a deleted model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback * @return void */ public static function deleted($callback) @@ -338,7 +338,7 @@ public static function deleted($callback) } /** - * Remove all of the event listeners for the model. + * Remove all the event listeners for the model. * * @return void */ diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php index 01c4608e2137..1c71fe15c539 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\PendingHasThroughRelationship; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -54,6 +55,26 @@ trait HasRelationships */ protected static $relationResolvers = []; + /** + * Get the dynamic relation resolver if defined or inherited, or return null. + * + * @param string $class + * @param string $key + * @return mixed + */ + public function relationResolver($class, $key) + { + if ($resolver = static::$relationResolvers[$class][$key] ?? null) { + return $resolver; + } + + if ($parent = get_parent_class($class)) { + return $this->relationResolver($parent, $key); + } + + return null; + } + /** * Define a dynamic relation resolver. * @@ -339,6 +360,21 @@ protected function guessBelongsToRelation() return $caller['function']; } + /** + * Create a pending has-many-through or has-one-through relationship. + * + * @param string|\Illuminate\Database\Eloquent\Relations\HasMany|\Illuminate\Database\Eloquent\Relations\HasOne $relationship + * @return \Illuminate\Database\Eloquent\PendingHasThroughRelationship + */ + public function through($relationship) + { + if (is_string($relationship)) { + $relationship = $this->{$relationship}(); + } + + return new PendingHasThroughRelationship($this, $relationship); + } + /** * Define a one-to-many relationship. * diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasTimestamps.php b/src/Illuminate/Database/Eloquent/Concerns/HasTimestamps.php index b0a762bf6ea2..2b6dfab6548e 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasTimestamps.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasTimestamps.php @@ -13,6 +13,13 @@ trait HasTimestamps */ public $timestamps = true; + /** + * The list of models classes that have timestamps temporarily disabled. + * + * @var array + */ + protected static $ignoreTimestampsOn = []; + /** * Update the model's update timestamp. * @@ -36,6 +43,17 @@ public function touch($attribute = null) return $this->save(); } + /** + * Update the model's update timestamp without raising any events. + * + * @param string|null $attribute + * @return bool + */ + public function touchQuietly($attribute = null) + { + return static::withoutEvents(fn () => $this->touch($attribute)); + } + /** * Update the creation and update timestamps. * @@ -113,7 +131,7 @@ public function freshTimestampString() */ public function usesTimestamps() { - return $this->timestamps; + return $this->timestamps && ! static::isIgnoringTimestamps($this::class); } /** @@ -155,4 +173,52 @@ public function getQualifiedUpdatedAtColumn() { return $this->qualifyColumn($this->getUpdatedAtColumn()); } + + /** + * Disable timestamps for the current class during the given callback scope. + * + * @param callable $callback + * @return mixed + */ + public static function withoutTimestamps(callable $callback) + { + return static::withoutTimestampsOn([static::class], $callback); + } + + /** + * Disable timestamps for the given model classes during the given callback scope. + * + * @param array $models + * @param callable $callback + * @return mixed + */ + public static function withoutTimestampsOn($models, $callback) + { + static::$ignoreTimestampsOn = array_values(array_merge(static::$ignoreTimestampsOn, $models)); + + try { + return $callback(); + } finally { + static::$ignoreTimestampsOn = array_values(array_diff(static::$ignoreTimestampsOn, $models)); + } + } + + /** + * Determine if the given model is ignoring timestamps / touches. + * + * @param string|null $class + * @return bool + */ + public static function isIgnoringTimestamps($class = null) + { + $class ??= static::class; + + foreach (static::$ignoreTimestampsOn as $ignoredClass) { + if ($class === $ignoredClass || is_subclass_of($class, $ignoredClass)) { + return true; + } + } + + return false; + } } diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasUlids.php b/src/Illuminate/Database/Eloquent/Concerns/HasUlids.php new file mode 100644 index 000000000000..b944c5d6b7b1 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Concerns/HasUlids.php @@ -0,0 +1,96 @@ +uniqueIds() as $column) { + if (empty($model->{$column})) { + $model->{$column} = $model->newUniqueId(); + } + } + }); + } + + /** + * Generate a new ULID for the model. + * + * @return string + */ + public function newUniqueId() + { + return strtolower((string) Str::ulid()); + } + + /** + * Retrieve the model for a bound value. + * + * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Relations\Relation $query + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Relations\Relation + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + */ + public function resolveRouteBindingQuery($query, $value, $field = null) + { + if ($field && in_array($field, $this->uniqueIds()) && ! Str::isUlid($value)) { + throw (new ModelNotFoundException)->setModel(get_class($this), $value); + } + + if (! $field && in_array($this->getRouteKeyName(), $this->uniqueIds()) && ! Str::isUlid($value)) { + throw (new ModelNotFoundException)->setModel(get_class($this), $value); + } + + return parent::resolveRouteBindingQuery($query, $value, $field); + } + + /** + * Get the columns that should receive a unique identifier. + * + * @return array + */ + public function uniqueIds() + { + return [$this->getKeyName()]; + } + + /** + * Get the auto-incrementing key type. + * + * @return string + */ + public function getKeyType() + { + if (in_array($this->getKeyName(), $this->uniqueIds())) { + return 'string'; + } + + return $this->keyType; + } + + /** + * Get the value indicating whether the IDs are incrementing. + * + * @return bool + */ + public function getIncrementing() + { + if (in_array($this->getKeyName(), $this->uniqueIds())) { + return false; + } + + return $this->incrementing; + } +} diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasUuids.php b/src/Illuminate/Database/Eloquent/Concerns/HasUuids.php new file mode 100644 index 000000000000..96a08b66c44d --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Concerns/HasUuids.php @@ -0,0 +1,96 @@ +uniqueIds() as $column) { + if (empty($model->{$column})) { + $model->{$column} = $model->newUniqueId(); + } + } + }); + } + + /** + * Generate a new UUID for the model. + * + * @return string + */ + public function newUniqueId() + { + return (string) Str::orderedUuid(); + } + + /** + * Get the columns that should receive a unique identifier. + * + * @return array + */ + public function uniqueIds() + { + return [$this->getKeyName()]; + } + + /** + * Retrieve the model for a bound value. + * + * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Relations\Relation $query + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Relations\Relation + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + */ + public function resolveRouteBindingQuery($query, $value, $field = null) + { + if ($field && in_array($field, $this->uniqueIds()) && ! Str::isUuid($value)) { + throw (new ModelNotFoundException)->setModel(get_class($this), $value); + } + + if (! $field && in_array($this->getRouteKeyName(), $this->uniqueIds()) && ! Str::isUuid($value)) { + throw (new ModelNotFoundException)->setModel(get_class($this), $value); + } + + return parent::resolveRouteBindingQuery($query, $value, $field); + } + + /** + * Get the auto-incrementing key type. + * + * @return string + */ + public function getKeyType() + { + if (in_array($this->getKeyName(), $this->uniqueIds())) { + return 'string'; + } + + return $this->keyType; + } + + /** + * Get the value indicating whether the IDs are incrementing. + * + * @return bool + */ + public function getIncrementing() + { + if (in_array($this->getKeyName(), $this->uniqueIds())) { + return false; + } + + return $this->incrementing; + } +} diff --git a/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php index f4a4bf09c253..2d5cc39549f4 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php @@ -29,7 +29,7 @@ trait QueriesRelationships * * @throws \RuntimeException */ - public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', Closure $callback = null) + public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null) { if (is_string($relation)) { if (str_contains($relation, '.')) { @@ -122,7 +122,7 @@ public function orHas($relation, $operator = '>=', $count = 1) * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Builder|static */ - public function doesntHave($relation, $boolean = 'and', Closure $callback = null) + public function doesntHave($relation, $boolean = 'and', ?Closure $callback = null) { return $this->has($relation, '<', 1, $boolean, $callback); } @@ -147,7 +147,7 @@ public function orDoesntHave($relation) * @param int $count * @return \Illuminate\Database\Eloquent\Builder|static */ - public function whereHas($relation, Closure $callback = null, $operator = '>=', $count = 1) + public function whereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) { return $this->has($relation, $operator, $count, 'and', $callback); } @@ -163,9 +163,9 @@ public function whereHas($relation, Closure $callback = null, $operator = '>=', * @param int $count * @return \Illuminate\Database\Eloquent\Builder|static */ - public function withWhereHas($relation, Closure $callback = null, $operator = '>=', $count = 1) + public function withWhereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) { - return $this->whereHas($relation, $callback, $operator, $count) + return $this->whereHas(Str::before($relation, ':'), $callback, $operator, $count) ->with($callback ? [$relation => fn ($query) => $callback($query)] : $relation); } @@ -178,7 +178,7 @@ public function withWhereHas($relation, Closure $callback = null, $operator = '> * @param int $count * @return \Illuminate\Database\Eloquent\Builder|static */ - public function orWhereHas($relation, Closure $callback = null, $operator = '>=', $count = 1) + public function orWhereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) { return $this->has($relation, $operator, $count, 'or', $callback); } @@ -190,7 +190,7 @@ public function orWhereHas($relation, Closure $callback = null, $operator = '>=' * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Builder|static */ - public function whereDoesntHave($relation, Closure $callback = null) + public function whereDoesntHave($relation, ?Closure $callback = null) { return $this->doesntHave($relation, 'and', $callback); } @@ -202,7 +202,7 @@ public function whereDoesntHave($relation, Closure $callback = null) * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Builder|static */ - public function orWhereDoesntHave($relation, Closure $callback = null) + public function orWhereDoesntHave($relation, ?Closure $callback = null) { return $this->doesntHave($relation, 'or', $callback); } @@ -218,7 +218,7 @@ public function orWhereDoesntHave($relation, Closure $callback = null) * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Builder|static */ - public function hasMorph($relation, $types, $operator = '>=', $count = 1, $boolean = 'and', Closure $callback = null) + public function hasMorph($relation, $types, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null) { if (is_string($relation)) { $relation = $this->getRelationWithoutConstraints($relation); @@ -297,7 +297,7 @@ public function orHasMorph($relation, $types, $operator = '>=', $count = 1) * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Builder|static */ - public function doesntHaveMorph($relation, $types, $boolean = 'and', Closure $callback = null) + public function doesntHaveMorph($relation, $types, $boolean = 'and', ?Closure $callback = null) { return $this->hasMorph($relation, $types, '<', 1, $boolean, $callback); } @@ -324,7 +324,7 @@ public function orDoesntHaveMorph($relation, $types) * @param int $count * @return \Illuminate\Database\Eloquent\Builder|static */ - public function whereHasMorph($relation, $types, Closure $callback = null, $operator = '>=', $count = 1) + public function whereHasMorph($relation, $types, ?Closure $callback = null, $operator = '>=', $count = 1) { return $this->hasMorph($relation, $types, $operator, $count, 'and', $callback); } @@ -339,7 +339,7 @@ public function whereHasMorph($relation, $types, Closure $callback = null, $oper * @param int $count * @return \Illuminate\Database\Eloquent\Builder|static */ - public function orWhereHasMorph($relation, $types, Closure $callback = null, $operator = '>=', $count = 1) + public function orWhereHasMorph($relation, $types, ?Closure $callback = null, $operator = '>=', $count = 1) { return $this->hasMorph($relation, $types, $operator, $count, 'or', $callback); } @@ -352,7 +352,7 @@ public function orWhereHasMorph($relation, $types, Closure $callback = null, $op * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Builder|static */ - public function whereDoesntHaveMorph($relation, $types, Closure $callback = null) + public function whereDoesntHaveMorph($relation, $types, ?Closure $callback = null) { return $this->doesntHaveMorph($relation, $types, 'and', $callback); } @@ -365,7 +365,7 @@ public function whereDoesntHaveMorph($relation, $types, Closure $callback = null * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Builder|static */ - public function orWhereDoesntHaveMorph($relation, $types, Closure $callback = null) + public function orWhereDoesntHaveMorph($relation, $types, ?Closure $callback = null) { return $this->doesntHaveMorph($relation, $types, 'or', $callback); } @@ -622,9 +622,7 @@ public function withAggregate($relations, $column, $function = null) $relation = $this->getRelationWithoutConstraints($name); if ($function) { - $hashedColumn = $this->getQuery()->from === $relation->getQuery()->getQuery()->from - ? "{$relation->getRelationCountHash(false)}.$column" - : $column; + $hashedColumn = $this->getRelationHashedColumn($column, $relation); $wrappedColumn = $this->getQuery()->getGrammar()->wrap( $column === '*' ? $column : $relation->getRelated()->qualifyColumn($hashedColumn) @@ -680,6 +678,24 @@ public function withAggregate($relations, $column, $function = null) return $this; } + /** + * Get the relation hashed column name for the given column and relation. + * + * @param string $column + * @param \Illuminate\Database\Eloquent\Relations\Relationship $relation + * @return string + */ + protected function getRelationHashedColumn($column, $relation) + { + if (str_contains($column, '.')) { + return $column; + } + + return $this->getQuery()->from === $relation->getQuery()->getQuery()->from + ? "{$relation->getRelationCountHash(false)}.$column" + : $column; + } + /** * Add subselect queries to count the relations. * diff --git a/src/Illuminate/Database/Eloquent/Factories/BelongsToManyRelationship.php b/src/Illuminate/Database/Eloquent/Factories/BelongsToManyRelationship.php index e0c42c4c642b..8e40261021ef 100644 --- a/src/Illuminate/Database/Eloquent/Factories/BelongsToManyRelationship.php +++ b/src/Illuminate/Database/Eloquent/Factories/BelongsToManyRelationship.php @@ -10,7 +10,7 @@ class BelongsToManyRelationship /** * The related factory instance. * - * @var \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model + * @var \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array */ protected $factory; @@ -31,7 +31,7 @@ class BelongsToManyRelationship /** * Create a new attached relationship definition. * - * @param \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model $factory + * @param \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $factory * @param callable|array $pivot * @param string $relationship * @return void @@ -58,4 +58,19 @@ public function createFor(Model $model) ); }); } + + /** + * Specify the model instances to always use when creating relationships. + * + * @param \Illuminate\Support\Collection $recycle + * @return $this + */ + public function recycle($recycle) + { + if ($this->factory instanceof Factory) { + $this->factory = $this->factory->recycle($recycle); + } + + return $this; + } } diff --git a/src/Illuminate/Database/Eloquent/Factories/BelongsToRelationship.php b/src/Illuminate/Database/Eloquent/Factories/BelongsToRelationship.php index 55747fdc6488..b2fb1b251a31 100644 --- a/src/Illuminate/Database/Eloquent/Factories/BelongsToRelationship.php +++ b/src/Illuminate/Database/Eloquent/Factories/BelongsToRelationship.php @@ -69,7 +69,9 @@ protected function resolver($key) { return function () use ($key) { if (! $this->resolved) { - $instance = $this->factory instanceof Factory ? $this->factory->create() : $this->factory; + $instance = $this->factory instanceof Factory + ? ($this->factory->getRandomRecycledModel($this->factory->modelName()) ?? $this->factory->create()) + : $this->factory; return $this->resolved = $key ? $instance->{$key} : $instance->getKey(); } @@ -77,4 +79,19 @@ protected function resolver($key) return $this->resolved; }; } + + /** + * Specify the model instances to always use when creating relationships. + * + * @param \Illuminate\Support\Collection $recycle + * @return $this + */ + public function recycle($recycle) + { + if ($this->factory instanceof Factory) { + $this->factory = $this->factory->recycle($recycle); + } + + return $this; + } } diff --git a/src/Illuminate/Database/Eloquent/Factories/CrossJoinSequence.php b/src/Illuminate/Database/Eloquent/Factories/CrossJoinSequence.php index b0efbd0c805b..3270b305cde9 100644 --- a/src/Illuminate/Database/Eloquent/Factories/CrossJoinSequence.php +++ b/src/Illuminate/Database/Eloquent/Factories/CrossJoinSequence.php @@ -9,7 +9,7 @@ class CrossJoinSequence extends Sequence /** * Create a new cross join sequence instance. * - * @param array $sequences + * @param array ...$sequences * @return void */ public function __construct(...$sequences) diff --git a/src/Illuminate/Database/Eloquent/Factories/Factory.php b/src/Illuminate/Database/Eloquent/Factories/Factory.php index 86ef0ca0c0f4..4a416b86b1d6 100644 --- a/src/Illuminate/Database/Eloquent/Factories/Factory.php +++ b/src/Illuminate/Database/Eloquent/Factories/Factory.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Enumerable; use Illuminate\Support\Str; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\ForwardsCalls; @@ -63,6 +64,13 @@ abstract class Factory */ protected $for; + /** + * The model instances to always use when creating relationships. + * + * @var \Illuminate\Support\Collection + */ + protected $recycle; + /** * The "after making" callbacks that will be applied to the model. * @@ -122,6 +130,7 @@ abstract class Factory * @param \Illuminate\Support\Collection|null $afterMaking * @param \Illuminate\Support\Collection|null $afterCreating * @param string|null $connection + * @param \Illuminate\Support\Collection|null $recycle * @return void */ public function __construct($count = null, @@ -130,7 +139,8 @@ public function __construct($count = null, ?Collection $for = null, ?Collection $afterMaking = null, ?Collection $afterCreating = null, - $connection = null) + $connection = null, + ?Collection $recycle = null) { $this->count = $count; $this->states = $states ?? new Collection; @@ -139,6 +149,7 @@ public function __construct($count = null, $this->afterMaking = $afterMaking ?? new Collection; $this->afterCreating = $afterCreating ?? new Collection; $this->connection = $connection; + $this->recycle = $recycle ?? new Collection; $this->faker = $this->withFaker(); } @@ -318,6 +329,12 @@ protected function store(Collection $results) $model->save(); + foreach ($model->getRelations() as $name => $items) { + if ($items instanceof Enumerable && $items->isEmpty()) { + $model->unsetRelation($name); + } + } + $this->createChildren($model); }); } @@ -332,7 +349,7 @@ protected function createChildren(Model $model) { Model::unguarded(function () use ($model) { $this->has->each(function ($has) use ($model) { - $has->createFor($model); + $has->recycle($this->recycle)->createFor($model); }); }); } @@ -439,7 +456,7 @@ protected function parentResolvers() $model = $this->newModel(); return $this->for->map(function (BelongsToRelationship $for) use ($model) { - return $for->attributesFor($model); + return $for->recycle($this->recycle)->attributesFor($model); })->collapse()->all(); } @@ -454,7 +471,8 @@ protected function expandAttributes(array $definition) return collect($definition) ->map($evaluateRelations = function ($attribute) { if ($attribute instanceof self) { - $attribute = $attribute->create()->getKey(); + $attribute = $this->getRandomRecycledModel($attribute->modelName()) + ?? $attribute->recycle($this->recycle)->create()->getKey(); } elseif ($attribute instanceof Model) { $attribute = $attribute->getKey(); } @@ -478,7 +496,7 @@ protected function expandAttributes(array $definition) /** * Add a new state transformation to the model definition. * - * @param (callable(array, \Illuminate\Database\Eloquent\Model|null=): array)|array $state + * @param (callable(array, \Illuminate\Database\Eloquent\Model|null): array)|array $state * @return static */ public function state($state) @@ -507,7 +525,7 @@ public function set($key, $value) /** * Add a new sequenced state transformation to the model definition. * - * @param array $sequence + * @param mixed ...$sequence * @return static */ public function sequence(...$sequence) @@ -518,7 +536,7 @@ public function sequence(...$sequence) /** * Add a new sequenced state transformation to the model definition and update the pending creation count to the size of the sequence. * - * @param array $sequence + * @param array ...$sequence * @return static */ public function forEachSequence(...$sequence) @@ -529,7 +547,7 @@ public function forEachSequence(...$sequence) /** * Add a new cross joined sequenced state transformation to the model definition. * - * @param array $sequence + * @param array ...$sequence * @return static */ public function crossJoinSequence(...$sequence) @@ -569,7 +587,7 @@ protected function guessRelationship(string $related) /** * Define an attached relationship for the model. * - * @param \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model $factory + * @param \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $factory * @param (callable(): array)|array $pivot * @param string|null $relationship * @return static @@ -606,6 +624,36 @@ public function for($factory, $relationship = null) )])]); } + /** + * Provide model instances to use instead of any nested factory calls when creating relationships. + * + * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Support\Collection|array $model + * @return static + */ + public function recycle($model) + { + // Group provided models by the type and merge them into existing recycle collection + return $this->newInstance([ + 'recycle' => $this->recycle + ->flatten() + ->merge( + Collection::wrap($model instanceof Model ? func_get_args() : $model) + ->flatten() + )->groupBy(fn ($model) => get_class($model)), + ]); + } + + /** + * Retrieve a random model of a given type from previously provided models to recycle. + * + * @param string $modelClassName + * @return \Illuminate\Database\Eloquent\Model|null + */ + public function getRandomRecycledModel($modelClassName) + { + return $this->recycle->get($modelClassName)?->random(); + } + /** * Add a new "after making" callback to the model definition. * @@ -697,6 +745,7 @@ protected function newInstance(array $arguments = []) 'afterMaking' => $this->afterMaking, 'afterCreating' => $this->afterCreating, 'connection' => $this->connection, + 'recycle' => $this->recycle, ], $arguments))); } diff --git a/src/Illuminate/Database/Eloquent/Factories/Relationship.php b/src/Illuminate/Database/Eloquent/Factories/Relationship.php index 788f6bc828e7..3eb62da38a6e 100644 --- a/src/Illuminate/Database/Eloquent/Factories/Relationship.php +++ b/src/Illuminate/Database/Eloquent/Factories/Relationship.php @@ -59,4 +59,17 @@ public function createFor(Model $parent) $relationship->attach($this->factory->create([], $parent)); } } + + /** + * Specify the model instances to always use when creating relationships. + * + * @param \Illuminate\Support\Collection $recycle + * @return $this + */ + public function recycle($recycle) + { + $this->factory = $this->factory->recycle($recycle); + + return $this; + } } diff --git a/src/Illuminate/Database/Eloquent/Factories/Sequence.php b/src/Illuminate/Database/Eloquent/Factories/Sequence.php index 064cc4a4e759..e523fb3eebd0 100644 --- a/src/Illuminate/Database/Eloquent/Factories/Sequence.php +++ b/src/Illuminate/Database/Eloquent/Factories/Sequence.php @@ -30,7 +30,7 @@ class Sequence implements Countable /** * Create a new sequence instance. * - * @param array $sequence + * @param mixed ...$sequence * @return void */ public function __construct(...$sequence) diff --git a/src/Illuminate/Database/Eloquent/MissingAttributeException.php b/src/Illuminate/Database/Eloquent/MissingAttributeException.php new file mode 100755 index 000000000000..87935c141dce --- /dev/null +++ b/src/Illuminate/Database/Eloquent/MissingAttributeException.php @@ -0,0 +1,23 @@ +totallyGuarded(); - foreach ($this->fillableFromArray($attributes) as $key => $value) { + $fillable = $this->fillableFromArray($attributes); + + foreach ($fillable as $key => $value) { // The developers may choose to place some attributes in the "fillable" array // which means only those attributes may be set through mass assignment to // the model, and all others will just get ignored for security reasons. if ($this->isFillable($key)) { $this->setAttribute($key, $value); - } elseif ($totallyGuarded) { + } elseif ($totallyGuarded || static::preventsSilentlyDiscardingAttributes()) { + if (isset(static::$discardedAttributeViolationCallback)) { + call_user_func(static::$discardedAttributeViolationCallback, $this, [$key]); + } else { + throw new MassAssignmentException(sprintf( + 'Add [%s] to fillable property to allow mass assignment on [%s].', + $key, get_class($this) + )); + } + } + } + + if (count($attributes) !== count($fillable) && + static::preventsSilentlyDiscardingAttributes()) { + $keys = array_diff(array_keys($attributes), array_keys($fillable)); + + if (isset(static::$discardedAttributeViolationCallback)) { + call_user_func(static::$discardedAttributeViolationCallback, $this, $keys); + } else { throw new MassAssignmentException(sprintf( - 'Add [%s] to fillable property to allow mass assignment on [%s].', - $key, get_class($this) + 'Add fillable property [%s] to allow mass assignment on [%s].', + implode(', ', $keys), + get_class($this) )); } } @@ -448,9 +554,7 @@ public function fill(array $attributes) */ public function forceFill(array $attributes) { - return static::unguarded(function () use ($attributes) { - return $this->fill($attributes); - }); + return static::unguarded(fn () => $this->fill($attributes)); } /** @@ -551,7 +655,7 @@ public static function on($connection = null) /** * Begin querying the model on the write connection. * - * @return \Illuminate\Database\Query\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public static function onWriteConnection() { @@ -967,7 +1071,7 @@ public function push() // us to recurse into all of these nested relations for the model instance. foreach ($this->relations as $models) { $models = $models instanceof Collection - ? $models->all() : [$models]; + ? $models->all() : [$models]; foreach (array_filter($models) as $model) { if (! $model->push()) { @@ -979,6 +1083,16 @@ public function push() return true; } + /** + * Save the model and all of its relationships without raising any events to the parent model. + * + * @return bool + */ + public function pushQuietly() + { + return static::withoutEvents(fn () => $this->push()); + } + /** * Save the model to the database without raising any events. * @@ -987,9 +1101,7 @@ public function push() */ public function saveQuietly(array $options = []) { - return static::withoutEvents(function () use ($options) { - return $this->save($options); - }); + return static::withoutEvents(fn () => $this->save($options)); } /** @@ -1016,7 +1128,7 @@ public function save(array $options = []) // clause to only update this model. Otherwise, we'll just insert them. if ($this->exists) { $saved = $this->isDirty() ? - $this->performUpdate($query) : true; + $this->performUpdate($query) : true; } // If the model is brand new, we'll insert it into our database and set the @@ -1051,9 +1163,7 @@ public function save(array $options = []) */ public function saveOrFail(array $options = []) { - return $this->getConnection()->transaction(function () use ($options) { - return $this->save($options); - }); + return $this->getConnection()->transaction(fn () => $this->save($options)); } /** @@ -1301,6 +1411,16 @@ public function delete() return true; } + /** + * Delete the model from the database without raising any events. + * + * @return bool + */ + public function deleteQuietly() + { + return static::withoutEvents(fn () => $this->delete()); + } + /** * Delete the model from the database within a transaction. * @@ -1314,9 +1434,7 @@ public function deleteOrFail() return false; } - return $this->getConnection()->transaction(function () { - return $this->delete(); - }); + return $this->getConnection()->transaction(fn () => $this->delete()); } /** @@ -1408,8 +1526,8 @@ public function registerGlobalScopes($builder) public function newQueryWithoutScopes() { return $this->newModelQuery() - ->with($this->with) - ->withCount($this->withCount); + ->with($this->with) + ->withCount($this->withCount); } /** @@ -1431,9 +1549,7 @@ public function newQueryWithoutScope($scope) */ public function newQueryForRestoration($ids) { - return is_array($ids) - ? $this->newQueryWithoutScopes()->whereIn($this->getQualifiedKeyName(), $ids) - : $this->newQueryWithoutScopes()->whereKey($ids); + return $this->newQueryWithoutScopes()->whereKey($ids); } /** @@ -1481,7 +1597,7 @@ public function newCollection(array $models = []) public function newPivot(self $parent, array $attributes, $table, $exists, $using = null) { return $using ? $using::fromRawAttributes($parent, $attributes, $table, $exists) - : Pivot::fromAttributes($parent, $attributes, $table, $exists); + : Pivot::fromAttributes($parent, $attributes, $table, $exists); } /** @@ -1559,9 +1675,9 @@ public function fresh($with = []) } return $this->setKeysForSelectQuery($this->newQueryWithoutScopes()) - ->useWritePdo() - ->with(is_string($with) ? func_get_args() : $with) - ->first(); + ->useWritePdo() + ->with(is_string($with) ? func_get_args() : $with) + ->first(); } /** @@ -1598,13 +1714,13 @@ public function refresh() * @param array|null $except * @return static */ - public function replicate(array $except = null) + public function replicate(?array $except = null) { - $defaults = [ + $defaults = array_values(array_filter([ $this->getKeyName(), $this->getCreatedAtColumn(), $this->getUpdatedAtColumn(), - ]; + ])); $attributes = Arr::except( $this->getAttributes(), $except ? array_unique(array_merge($except, $defaults)) : $defaults @@ -1619,6 +1735,17 @@ public function replicate(array $except = null) }); } + /** + * Clone the model into a new, non-existing instance without raising any events. + * + * @param array|null $except + * @return static + */ + public function replicateQuietly(?array $except = null) + { + return static::withoutEvents(fn () => $this->replicate($except)); + } + /** * Determine if two models have the same ID and belong to the same table. * @@ -1628,9 +1755,9 @@ public function replicate(array $except = null) public function is($model) { return ! is_null($model) && - $this->getKey() === $model->getKey() && - $this->getTable() === $model->getTable() && - $this->getConnectionName() === $model->getConnectionName(); + $this->getKey() === $model->getKey() && + $this->getTable() === $model->getTable() && + $this->getConnectionName() === $model->getConnectionName(); } /** @@ -1963,7 +2090,7 @@ public function resolveSoftDeletableChildRouteBinding($childType, $value, $field */ protected function resolveChildRouteBindingQuery($childType, $value, $field) { - $relationship = $this->{Str::plural(Str::camel($childType))}(); + $relationship = $this->{$this->childRouteBindingRelationshipName($childType)}(); $field = $field ?: $relationship->getRelated()->getRouteKeyName(); @@ -1973,8 +2100,19 @@ protected function resolveChildRouteBindingQuery($childType, $value, $field) } return $relationship instanceof Model - ? $relationship->resolveRouteBindingQuery($relationship, $value, $field) - : $relationship->getRelated()->resolveRouteBindingQuery($relationship, $value, $field); + ? $relationship->resolveRouteBindingQuery($relationship, $value, $field) + : $relationship->getRelated()->resolveRouteBindingQuery($relationship, $value, $field); + } + + /** + * Retrieve the child route model binding relationship name for the given child type. + * + * @param string $childType + * @return string + */ + protected function childRouteBindingRelationshipName($childType) + { + return Str::plural(Str::camel($childType)); } /** @@ -2033,6 +2171,26 @@ public static function preventsLazyLoading() return static::$modelsShouldPreventLazyLoading; } + /** + * Determine if discarding guarded attribute fills is disabled. + * + * @return bool + */ + public static function preventsSilentlyDiscardingAttributes() + { + return static::$modelsShouldPreventSilentlyDiscardingAttributes; + } + + /** + * Determine if accessing missing attributes is disabled. + * + * @return bool + */ + public static function preventsAccessingMissingAttributes() + { + return static::$modelsShouldPreventAccessingMissingAttributes; + } + /** * Get the broadcast channel route definition that is associated with the given entity. * @@ -2084,7 +2242,11 @@ public function __set($key, $value) */ public function offsetExists($offset): bool { - return ! is_null($this->getAttribute($offset)); + try { + return ! is_null($this->getAttribute($offset)); + } catch (MissingAttributeException) { + return false; + } } /** @@ -2156,10 +2318,15 @@ public function __call($method, $parameters) return $this->$method(...$parameters); } - if ($resolver = (static::$relationResolvers[get_class($this)][$method] ?? null)) { + if ($resolver = $this->relationResolver(static::class, $method)) { return $resolver($this); } + if (Str::startsWith($method, 'through') && + method_exists($this, $relationMethod = Str::of($method)->after('through')->lcfirst()->toString())) { + return $this->through($relationMethod); + } + return $this->forwardCallTo($this->newQuery(), $method, $parameters); } @@ -2183,8 +2350,8 @@ public static function __callStatic($method, $parameters) public function __toString() { return $this->escapeWhenCastingToString - ? e($this->toJson()) - : $this->toJson(); + ? e($this->toJson()) + : $this->toJson(); } /** diff --git a/src/Illuminate/Database/Eloquent/PendingHasThroughRelationship.php b/src/Illuminate/Database/Eloquent/PendingHasThroughRelationship.php new file mode 100644 index 000000000000..612c51e38863 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/PendingHasThroughRelationship.php @@ -0,0 +1,90 @@ +rootModel = $rootModel; + + $this->localRelationship = $localRelationship; + } + + /** + * Define the distant relationship that this model has. + * + * @param string|(callable(\Illuminate\Database\Eloquent\Model): (\Illuminate\Database\Eloquent\Relations\HasOne|\Illuminate\Database\Eloquent\Relations\HasMany)) $callback + * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough|\Illuminate\Database\Eloquent\Relations\HasOneThrough + */ + public function has($callback) + { + if (is_string($callback)) { + $callback = fn () => $this->localRelationship->getRelated()->{$callback}(); + } + + $distantRelation = $callback($this->localRelationship->getRelated()); + + if ($distantRelation instanceof HasMany) { + return $this->rootModel->hasManyThrough( + $distantRelation->getRelated()::class, + $this->localRelationship->getRelated()::class, + $this->localRelationship->getForeignKeyName(), + $distantRelation->getForeignKeyName(), + $this->localRelationship->getLocalKeyName(), + $distantRelation->getLocalKeyName(), + ); + } + + return $this->rootModel->hasOneThrough( + $distantRelation->getRelated()::class, + $this->localRelationship->getRelated()::class, + $this->localRelationship->getForeignKeyName(), + $distantRelation->getForeignKeyName(), + $this->localRelationship->getLocalKeyName(), + $distantRelation->getLocalKeyName(), + ); + } + + /** + * Handle dynamic method calls into the model. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + if (Str::startsWith($method, 'has')) { + return $this->has(Str::of($method)->after('has')->lcfirst()->toString()); + } + + throw new BadMethodCallException(sprintf( + 'Call to undefined method %s::%s()', static::class, $method + )); + } +} diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index d46b70c8cb24..f858507b2cf4 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -688,8 +688,8 @@ public function findMany($ids, $columns = ['*']) return $this->getRelated()->newCollection(); } - return $this->whereIn( - $this->getRelated()->getQualifiedKeyName(), $this->parseIds($ids) + return $this->whereKey( + $this->parseIds($ids) )->get($columns); } @@ -727,7 +727,7 @@ public function findOrFail($id, $columns = ['*']) * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|mixed */ - public function findOr($id, $columns = ['*'], Closure $callback = null) + public function findOr($id, $columns = ['*'], ?Closure $callback = null) { if ($columns instanceof Closure) { $callback = $columns; @@ -801,7 +801,7 @@ public function firstOrFail($columns = ['*']) * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Model|static|mixed */ - public function firstOr($columns = ['*'], Closure $callback = null) + public function firstOr($columns = ['*'], ?Closure $callback = null) { if ($columns instanceof Closure) { $callback = $columns; @@ -877,7 +877,7 @@ protected function shouldSelect(array $columns = ['*']) /** * Get the pivot columns for the relation. * - * "pivot_" is prefixed ot each column for easy removal later. + * "pivot_" is prefixed at each column for easy removal later. * * @return array */ @@ -1153,8 +1153,6 @@ protected function guessInverseRelation() */ public function touch() { - $key = $this->getRelated()->getKeyName(); - $columns = [ $this->related->getUpdatedAtColumn() => $this->related->freshTimestampString(), ]; @@ -1163,7 +1161,7 @@ public function touch() // the related model's timestamps, to make sure these all reflect the changes // to the parent models. This will help us keep any caching synced up here. if (count($ids = $this->allRelatedIds()) > 0) { - $this->getRelated()->newQueryWithoutRelationships()->whereIn($key, $ids)->update($columns); + $this->getRelated()->newQueryWithoutRelationships()->whereKey($ids)->update($columns); } } @@ -1227,6 +1225,20 @@ public function saveMany($models, array $pivotAttributes = []) return $models; } + /** + * Save an array of new models without raising any events and attach them to the parent model. + * + * @param \Illuminate\Support\Collection|array $models + * @param array $pivotAttributes + * @return array + */ + public function saveManyQuietly($models, array $pivotAttributes = []) + { + return Model::withoutEvents(function () use ($models, $pivotAttributes) { + return $this->saveMany($models, $pivotAttributes); + }); + } + /** * Create a new instance of the related model. * diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithDictionary.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithDictionary.php index ba4ae9aeb655..91b3bf5bd4e4 100644 --- a/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithDictionary.php +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithDictionary.php @@ -4,6 +4,7 @@ use BackedEnum; use Doctrine\Instantiator\Exception\InvalidArgumentException; +use UnitEnum; trait InteractsWithDictionary { @@ -23,8 +24,8 @@ protected function getDictionaryKey($attribute) } if (function_exists('enum_exists') && - $attribute instanceof BackedEnum) { - return $attribute->value; + $attribute instanceof UnitEnum) { + return $attribute instanceof BackedEnum ? $attribute->value : $attribute->name; } throw new InvalidArgumentException('Model attribute value is an object but does not have a __toString method.'); diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php index 342a5628138c..2241719b7b37 100644 --- a/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php @@ -92,17 +92,19 @@ public function sync($ids, $detaching = true) $current = $this->getCurrentlyAttachedPivots() ->pluck($this->relatedPivotKey)->all(); - $detach = array_diff($current, array_keys( - $records = $this->formatRecordsList($this->parseIds($ids)) - )); + $records = $this->formatRecordsList($this->parseIds($ids)); // Next, we will take the differences of the currents and given IDs and detach // all of the entities that exist in the "current" array but are not in the // array of the new IDs given to the method which will complete the sync. - if ($detaching && count($detach) > 0) { - $this->detach($detach); + if ($detaching) { + $detach = array_diff($current, array_keys($records)); - $changes['detached'] = $this->castKeys($detach); + if (count($detach) > 0) { + $this->detach($detach); + + $changes['detached'] = $this->castKeys($detach); + } } // Now we are finally ready to attach the new records. Note that we'll disable diff --git a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php index 893d5fab906b..b950c9d5b9a1 100644 --- a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php @@ -103,7 +103,7 @@ public function addConstraints() * @param \Illuminate\Database\Eloquent\Builder|null $query * @return void */ - protected function performJoin(Builder $query = null) + protected function performJoin(?Builder $query = null) { $query = $query ?: $this->query; @@ -309,7 +309,7 @@ public function firstOrFail($columns = ['*']) * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Model|static|mixed */ - public function firstOr($columns = ['*'], Closure $callback = null) + public function firstOr($columns = ['*'], ?Closure $callback = null) { if ($columns instanceof Closure) { $callback = $columns; @@ -396,7 +396,7 @@ public function findOrFail($id, $columns = ['*']) * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|mixed */ - public function findOr($id, $columns = ['*'], Closure $callback = null) + public function findOr($id, $columns = ['*'], ?Closure $callback = null) { if ($columns instanceof Closure) { $callback = $columns; @@ -549,7 +549,7 @@ public function chunkById($count, callable $callback, $column = null, $alias = n /** * Get a generator for the given query. * - * @return \Generator + * @return \Illuminate\Support\LazyCollection */ public function cursor() { diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index 2d42f88f0ed2..01f0c1e563fe 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -295,6 +295,19 @@ public function saveMany($models) return $models; } + /** + * Attach a collection of models to the parent instance without raising any events to the parent model. + * + * @param iterable $models + * @return iterable + */ + public function saveManyQuietly($models) + { + return Model::withoutEvents(function () use ($models) { + return $this->saveMany($models); + }); + } + /** * Create a new instance of the related model. * @@ -310,6 +323,17 @@ public function create(array $attributes = []) }); } + /** + * Create a new instance of the related model without raising any events to the parent model. + * + * @param array $attributes + * @return \Illuminate\Database\Eloquent\Model + */ + public function createQuietly(array $attributes = []) + { + return Model::withoutEvents(fn () => $this->create($attributes)); + } + /** * Create a new instance of the related model. Allow mass-assignment. * @@ -340,6 +364,17 @@ public function createMany(iterable $records) return $instances; } + /** + * Create a Collection of new instances of the related model without raising any events to the parent model. + * + * @param iterable $records + * @return \Illuminate\Database\Eloquent\Collection + */ + public function createManyQuietly(iterable $records) + { + return Model::withoutEvents(fn () => $this->createMany($records)); + } + /** * Set the foreign ID for creating a related model. * diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphMany.php index 07a68b8f52bc..282ba2e86053 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphMany.php @@ -57,6 +57,6 @@ public function forceCreate(array $attributes = []) { $attributes[$this->getMorphType()] = $this->morphClass; - parent::forceCreate($attributes); + return parent::forceCreate($attributes); } } diff --git a/src/Illuminate/Database/Eloquent/Relations/Relation.php b/src/Illuminate/Database/Eloquent/Relations/Relation.php index 6523ca0dca0d..7a9b880556d4 100755 --- a/src/Illuminate/Database/Eloquent/Relations/Relation.php +++ b/src/Illuminate/Database/Eloquent/Relations/Relation.php @@ -435,7 +435,7 @@ public static function enforceMorphMap(array $map, $merge = true) * @param bool $merge * @return array */ - public static function morphMap(array $map = null, $merge = true) + public static function morphMap(?array $map = null, $merge = true) { $map = static::buildMorphMapFromModels($map); @@ -453,7 +453,7 @@ public static function morphMap(array $map = null, $merge = true) * @param string[]|null $models * @return array|null */ - protected static function buildMorphMapFromModels(array $models = null) + protected static function buildMorphMapFromModels(?array $models = null) { if (is_null($models) || Arr::isAssoc($models)) { return $models; diff --git a/src/Illuminate/Database/Eloquent/SoftDeletes.php b/src/Illuminate/Database/Eloquent/SoftDeletes.php index b946c8f436be..da7a4a371479 100644 --- a/src/Illuminate/Database/Eloquent/SoftDeletes.php +++ b/src/Illuminate/Database/Eloquent/SoftDeletes.php @@ -45,6 +45,10 @@ public function initializeSoftDeletes() */ public function forceDelete() { + if ($this->fireModelEvent('forceDeleting') === false) { + return false; + } + $this->forceDeleting = true; return tap($this->delete(), function ($deleted) { @@ -56,6 +60,16 @@ public function forceDelete() }); } + /** + * Force a hard delete on a soft deleted model without raising any events. + * + * @return bool|null + */ + public function forceDeleteQuietly() + { + return static::withoutEvents(fn () => $this->forceDelete()); + } + /** * Perform the actual delete query on this model instance. * @@ -87,7 +101,7 @@ protected function runSoftDelete() $this->{$this->getDeletedAtColumn()} = $time; - if ($this->timestamps && ! is_null($this->getUpdatedAtColumn())) { + if ($this->usesTimestamps() && ! is_null($this->getUpdatedAtColumn())) { $this->{$this->getUpdatedAtColumn()} = $time; $columns[$this->getUpdatedAtColumn()] = $this->fromDateTime($time); @@ -103,7 +117,7 @@ protected function runSoftDelete() /** * Restore a soft-deleted model instance. * - * @return bool|null + * @return bool */ public function restore() { @@ -128,6 +142,16 @@ public function restore() return $result; } + /** + * Restore a soft-deleted model instance without raising any events. + * + * @return bool + */ + public function restoreQuietly() + { + return static::withoutEvents(fn () => $this->restore()); + } + /** * Determine if the model instance has been soft-deleted. * @@ -171,6 +195,17 @@ public static function restored($callback) static::registerModelEvent('restored', $callback); } + /** + * Register a "forceDeleting" model event callback with the dispatcher. + * + * @param \Closure|string $callback + * @return void + */ + public static function forceDeleting($callback) + { + static::registerModelEvent('forceDeleting', $callback); + } + /** * Register a "forceDeleted" model event callback with the dispatcher. * diff --git a/src/Illuminate/Database/Eloquent/SoftDeletingScope.php b/src/Illuminate/Database/Eloquent/SoftDeletingScope.php index 7528964c132a..e6d91d91786b 100644 --- a/src/Illuminate/Database/Eloquent/SoftDeletingScope.php +++ b/src/Illuminate/Database/Eloquent/SoftDeletingScope.php @@ -9,7 +9,7 @@ class SoftDeletingScope implements Scope * * @var string[] */ - protected $extensions = ['Restore', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed']; + protected $extensions = ['Restore', 'RestoreOrCreate', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed']; /** * Apply the scope to a given Eloquent query builder. @@ -74,6 +74,23 @@ protected function addRestore(Builder $builder) }); } + /** + * Add the restore-or-create extension to the builder. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return void + */ + protected function addRestoreOrCreate(Builder $builder) + { + $builder->macro('restoreOrCreate', function (Builder $builder, array $attributes = [], array $values = []) { + $builder->withTrashed(); + + return tap($builder->firstOrCreate($attributes, $values), function ($instance) { + $instance->restore(); + }); + }); + } + /** * Add the with-trashed extension to the builder. * diff --git a/src/Illuminate/Database/Events/ConnectionEstablished.php b/src/Illuminate/Database/Events/ConnectionEstablished.php new file mode 100644 index 000000000000..22a45b834a14 --- /dev/null +++ b/src/Illuminate/Database/Events/ConnectionEstablished.php @@ -0,0 +1,8 @@ +connectionName = $connectionName; + $this->connections = $connections; + } +} diff --git a/src/Illuminate/Database/Events/TransactionCommitting.php b/src/Illuminate/Database/Events/TransactionCommitting.php new file mode 100644 index 000000000000..9b8179d32d0b --- /dev/null +++ b/src/Illuminate/Database/Events/TransactionCommitting.php @@ -0,0 +1,8 @@ + + */ + protected static $requiredPathCache = []; + /** * The output interface implementation. * @@ -80,7 +100,7 @@ class Migrator public function __construct(MigrationRepositoryInterface $repository, Resolver $resolver, Filesystem $files, - Dispatcher $dispatcher = null) + ?Dispatcher $dispatcher = null) { $this->files = $files; $this->events = $dispatcher; @@ -144,7 +164,7 @@ public function runPending(array $migrations, array $options = []) if (count($migrations) === 0) { $this->fireMigrationEvent(new NoPendingMigrations('up')); - $this->note('Nothing to migrate.'); + $this->write(Info::class, 'Nothing to migrate'); return; } @@ -160,6 +180,8 @@ public function runPending(array $migrations, array $options = []) $this->fireMigrationEvent(new MigrationsStarted('up')); + $this->write(Info::class, 'Running migrations.'); + // Once we have the array of migrations, we will spin through them and run the // migrations "up" so the changes are made to the databases. We'll then log // that the migration was run so we don't repeat it next time we execute. @@ -172,6 +194,10 @@ public function runPending(array $migrations, array $options = []) } $this->fireMigrationEvent(new MigrationsEnded('up')); + + if ($this->output) { + $this->output->writeln(''); + } } /** @@ -195,20 +221,12 @@ protected function runUp($file, $batch, $pretend) return $this->pretendToRun($migration, 'up'); } - $this->note("Migrating: {$name}"); - - $startTime = microtime(true); - - $this->runMigration($migration, 'up'); - - $runTime = number_format((microtime(true) - $startTime) * 1000, 2); + $this->write(Task::class, $name, fn () => $this->runMigration($migration, 'up')); // Once we have run a migrations class, we will log that it was run in this // repository so that we don't try to run it next time we do a migration // in the application. A migration repository keeps the migrate order. $this->repository->log($name, $batch); - - $this->note("Migrated: {$name} ({$runTime}ms)"); } /** @@ -228,12 +246,16 @@ public function rollback($paths = [], array $options = []) if (count($migrations) === 0) { $this->fireMigrationEvent(new NoPendingMigrations('down')); - $this->note('Nothing to rollback.'); + $this->write(Info::class, 'Nothing to rollback.'); return []; } - return $this->rollbackMigrations($migrations, $paths, $options); + return tap($this->rollbackMigrations($migrations, $paths, $options), function () { + if ($this->output) { + $this->output->writeln(''); + } + }); } /** @@ -267,6 +289,8 @@ protected function rollbackMigrations(array $migrations, $paths, array $options) $this->fireMigrationEvent(new MigrationsStarted('down')); + $this->write(Info::class, 'Rolling back migrations.'); + // Next we will run through all of the migrations and call the "down" method // which will reverse each migration in order. This getLast method on the // repository already returns these migration's names in reverse order. @@ -274,7 +298,7 @@ protected function rollbackMigrations(array $migrations, $paths, array $options) $migration = (object) $migration; if (! $file = Arr::get($files, $migration->migration)) { - $this->note("Migration not found: {$migration->migration}"); + $this->write(TwoColumnDetail::class, $migration->migration, 'Migration not found'); continue; } @@ -307,12 +331,16 @@ public function reset($paths = [], $pretend = false) $migrations = array_reverse($this->repository->getRan()); if (count($migrations) === 0) { - $this->note('Nothing to rollback.'); + $this->write(Info::class, 'Nothing to rollback.'); return []; } - return $this->resetMigrations($migrations, $paths, $pretend); + return tap($this->resetMigrations($migrations, $paths, $pretend), function () { + if ($this->output) { + $this->output->writeln(''); + } + }); } /** @@ -354,24 +382,16 @@ protected function runDown($file, $migration, $pretend) $name = $this->getMigrationName($file); - $this->note("Rolling back: {$name}"); - if ($pretend) { return $this->pretendToRun($instance, 'down'); } - $startTime = microtime(true); - - $this->runMigration($instance, 'down'); - - $runTime = number_format((microtime(true) - $startTime) * 1000, 2); + $this->write(Task::class, $name, fn () => $this->runMigration($instance, 'down')); // Once we have successfully run the migration "down" we will remove it from // the migration repository so it will be considered to have not been run // by the application then will be able to fire by any later operation. $this->repository->delete($migration); - - $this->note("Rolled back: {$name} ({$runTime}ms)"); } /** @@ -413,21 +433,25 @@ protected function runMigration($migration, $method) protected function pretendToRun($migration, $method) { try { - foreach ($this->getQueries($migration, $method) as $query) { - $name = get_class($migration); - - $reflectionClass = new ReflectionClass($migration); + $name = get_class($migration); - if ($reflectionClass->isAnonymous()) { - $name = $this->getMigrationName($reflectionClass->getFileName()); - } + $reflectionClass = new ReflectionClass($migration); - $this->note("{$name}: {$query['query']}"); + if ($reflectionClass->isAnonymous()) { + $name = $this->getMigrationName($reflectionClass->getFileName()); } + + $this->write(TwoColumnDetail::class, $name); + $this->write(BulletList::class, collect($this->getQueries($migration, $method))->map(function ($query) { + return $query['query']; + })); } catch (SchemaException $e) { $name = get_class($migration); - $this->note("{$name}: failed to dump queries. This may be due to changing database columns using Doctrine, which is not supported while pretending to run migrations."); + $this->write(Error::class, sprintf( + '[%s] failed to dump queries. This may be due to changing database columns using Doctrine, which is not supported while pretending to run migrations.', + $name, + )); } } @@ -502,9 +526,15 @@ protected function resolvePath(string $path) return new $class; } - $migration = $this->files->getRequire($path); + $migration = static::$requiredPathCache[$path] ??= $this->files->getRequire($path); + + if (is_object($migration)) { + return method_exists($migration, '__construct') + ? $this->files->getRequire($path) + : clone $migration; + } - return is_object($migration) ? $migration : new $class; + return new $class; } /** @@ -633,7 +663,26 @@ public function setConnection($name) */ public function resolveConnection($connection) { - return $this->resolver->connection($connection ?: $this->connection); + if (static::$connectionResolverCallback) { + return call_user_func( + static::$connectionResolverCallback, + $this->resolver, + $connection ?: $this->connection + ); + } else { + return $this->resolver->connection($connection ?: $this->connection); + } + } + + /** + * Set a connection resolver callback. + * + * @param \Closure $callback + * @return void + */ + public static function resolveConnectionsUsing(Closure $callback) + { + static::$connectionResolverCallback = $callback; } /** @@ -717,15 +766,22 @@ public function setOutput(OutputInterface $output) } /** - * Write a note to the console's output. + * Write to the console's output. * - * @param string $message + * @param string $component + * @param array|string ...$arguments * @return void */ - protected function note($message) + protected function write($component, ...$arguments) { - if ($this->output) { - $this->output->writeln($message); + if ($this->output && class_exists($component)) { + (new $component($this->output))->render(...$arguments); + } else { + foreach ($arguments as $argument) { + if (is_callable($argument)) { + $argument(); + } + } } } diff --git a/src/Illuminate/Database/MySqlConnection.php b/src/Illuminate/Database/MySqlConnection.php index 54e3d473d580..4a0de7a3710a 100755 --- a/src/Illuminate/Database/MySqlConnection.php +++ b/src/Illuminate/Database/MySqlConnection.php @@ -64,7 +64,7 @@ protected function getDefaultSchemaGrammar() * @param callable|null $processFactory * @return \Illuminate\Database\Schema\MySqlSchemaState */ - public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { return new MySqlSchemaState($this, $files, $processFactory); } diff --git a/src/Illuminate/Database/PostgresConnection.php b/src/Illuminate/Database/PostgresConnection.php index f750f64e6d08..1364c36191d4 100755 --- a/src/Illuminate/Database/PostgresConnection.php +++ b/src/Illuminate/Database/PostgresConnection.php @@ -53,7 +53,7 @@ protected function getDefaultSchemaGrammar() * @param callable|null $processFactory * @return \Illuminate\Database\Schema\PostgresSchemaState */ - public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { return new PostgresSchemaState($this, $files, $processFactory); } diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index cb6d3a280849..de23e811f712 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -100,6 +100,13 @@ class Builder implements BuilderContract */ public $from; + /** + * The index hint for the query. + * + * @var \Illuminate\Database\Query\IndexHint + */ + public $indexHint; + /** * The table joins for the query. * @@ -230,8 +237,8 @@ class Builder implements BuilderContract * @return void */ public function __construct(ConnectionInterface $connection, - Grammar $grammar = null, - Processor $processor = null) + ?Grammar $grammar = null, + ?Processor $processor = null) { $this->connection = $connection; $this->grammar = $grammar ?: $connection->getQueryGrammar(); @@ -301,7 +308,7 @@ public function selectRaw($expression, array $bindings = []) /** * Makes "from" fetch from a subquery. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query * @param string $as * @return $this * @@ -333,7 +340,7 @@ public function fromRaw($expression, $bindings = []) /** * Creates a subquery and parse it. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query * @return array */ protected function createSub($query) @@ -411,6 +418,10 @@ public function addSelect($column) $this->selectSub($column, $as); } else { + if (is_array($this->columns) && in_array($column, $this->columns, true)) { + continue; + } + $this->columns[] = $column; } } @@ -439,7 +450,7 @@ public function distinct() /** * Set the table which the query is targeting. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $table + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $table * @param string|null $as * @return $this */ @@ -454,6 +465,45 @@ public function from($table, $as = null) return $this; } + /** + * Add an index hint to suggest a query index. + * + * @param string $index + * @return $this + */ + public function useIndex($index) + { + $this->indexHint = new IndexHint('hint', $index); + + return $this; + } + + /** + * Add an index hint to force a query index. + * + * @param string $index + * @return $this + */ + public function forceIndex($index) + { + $this->indexHint = new IndexHint('force', $index); + + return $this; + } + + /** + * Add an index hint to ignore a query index. + * + * @param string $index + * @return $this + */ + public function ignoreIndex($index) + { + $this->indexHint = new IndexHint('ignore', $index); + + return $this; + } + /** * Add a join clause to the query. * @@ -643,7 +693,7 @@ public function crossJoin($table, $first = null, $operator = null, $second = nul /** * Add a subquery cross join to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query * @param string $as * @return $this */ @@ -800,7 +850,7 @@ protected function addArrayOfWheres($column, $boolean, $method = 'where') if (is_numeric($key) && is_array($value)) { $query->{$method}(...array_values($value)); } else { - $query->$method($key, '=', $value, $boolean); + $query->{$method}($key, '=', $value, $boolean); } } }, $boolean); @@ -894,6 +944,12 @@ public function orWhere($column, $operator = null, $value = null) */ public function whereNot($column, $operator = null, $value = null, $boolean = 'and') { + if (is_array($column)) { + return $this->whereNested(function ($query) use ($column, $operator, $value, $boolean) { + $query->where($column, $operator, $value, $boolean); + }, $boolean.' not'); + } + return $this->where($column, $operator, $value, $boolean.' not'); } @@ -1022,6 +1078,10 @@ public function whereIn($column, $values, $boolean = 'and', $not = false) $this->wheres[] = compact('type', 'column', 'values', 'boolean'); + if (count($values) !== count(Arr::flatten($values, 1))) { + throw new InvalidArgumentException('Nested arrays may not be passed to whereIn method.'); + } + // Finally, we'll add a binding for each value unless that value is an expression // in which case we will just skip over it since it will be the query as a raw // string and not as a parameterized place-holder to be replaced by the PDO. @@ -1084,6 +1144,8 @@ public function whereIntegerInRaw($column, $values, $boolean = 'and', $not = fal $values = $values->toArray(); } + $values = Arr::flatten($values); + foreach ($values as &$value) { $value = (int) $value; } @@ -1386,7 +1448,7 @@ public function orWhereTime($column, $operator, $value = null) * * @param string $column * @param string $operator - * @param \DateTimeInterface|string|null $value + * @param \DateTimeInterface|string|int|null $value * @param string $boolean * @return $this */ @@ -1403,7 +1465,7 @@ public function whereDay($column, $operator, $value = null, $boolean = 'and') } if (! $value instanceof Expression) { - $value = str_pad($value, 2, '0', STR_PAD_LEFT); + $value = sprintf('%02d', $value); } return $this->addDateBasedWhere('Day', $column, $operator, $value, $boolean); @@ -1414,7 +1476,7 @@ public function whereDay($column, $operator, $value = null, $boolean = 'and') * * @param string $column * @param string $operator - * @param \DateTimeInterface|string|null $value + * @param \DateTimeInterface|string|int|null $value * @return $this */ public function orWhereDay($column, $operator, $value = null) @@ -1431,7 +1493,7 @@ public function orWhereDay($column, $operator, $value = null) * * @param string $column * @param string $operator - * @param \DateTimeInterface|string|null $value + * @param \DateTimeInterface|string|int|null $value * @param string $boolean * @return $this */ @@ -1448,7 +1510,7 @@ public function whereMonth($column, $operator, $value = null, $boolean = 'and') } if (! $value instanceof Expression) { - $value = str_pad($value, 2, '0', STR_PAD_LEFT); + $value = sprintf('%02d', $value); } return $this->addDateBasedWhere('Month', $column, $operator, $value, $boolean); @@ -1459,7 +1521,7 @@ public function whereMonth($column, $operator, $value = null, $boolean = 'and') * * @param string $column * @param string $operator - * @param \DateTimeInterface|string|null $value + * @param \DateTimeInterface|string|int|null $value * @return $this */ public function orWhereMonth($column, $operator, $value = null) @@ -2267,7 +2329,7 @@ public function oldest($column = 'created_at') /** * Put the query's results in random order. * - * @param string $seed + * @param string|int $seed * @return $this */ public function inRandomOrder($seed = '') @@ -2438,7 +2500,7 @@ protected function removeExistingOrdersFor($column) /** * Add a union statement to the query. * - * @param \Illuminate\Database\Query\Builder|\Closure $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $query * @param bool $all * @return $this */ @@ -2458,7 +2520,7 @@ public function union($query, $all = false) /** * Add a union all statement to the query. * - * @param \Illuminate\Database\Query\Builder|\Closure $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $query * @return $this */ public function unionAll($query) @@ -2486,7 +2548,7 @@ public function lock($value = true) /** * Lock the selected rows in the table for updating. * - * @return \Illuminate\Database\Query\Builder + * @return $this */ public function lockForUpdate() { @@ -2496,7 +2558,7 @@ public function lockForUpdate() /** * Share lock the selected rows in the table. * - * @return \Illuminate\Database\Query\Builder + * @return $this */ public function sharedLock() { @@ -2562,7 +2624,7 @@ public function find($id, $columns = ['*']) * @param \Closure|null $callback * @return mixed|static */ - public function findOr($id, $columns = ['*'], Closure $callback = null) + public function findOr($id, $columns = ['*'], ?Closure $callback = null) { if ($columns instanceof Closure) { $callback = $columns; @@ -2590,6 +2652,20 @@ public function value($column) return count($result) > 0 ? reset($result) : null; } + /** + * Get a single expression value from the first result of a query. + * + * @param string $expression + * @param array $bindings + * @return mixed + */ + public function rawValue(string $expression, array $bindings = []) + { + $result = (array) $this->selectRaw($expression, $bindings)->first(); + + return count($result) > 0 ? reset($result) : null; + } + /** * Get a single column's value from the first result of a query if it's the sole matching record. * @@ -3250,7 +3326,7 @@ public function insertGetId(array $values, $sequence = null) * Insert new records into the table using a subquery. * * @param array $columns - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query * @return int */ public function insertUsing(array $columns, $query) @@ -3384,11 +3460,31 @@ public function increment($column, $amount = 1, array $extra = []) throw new InvalidArgumentException('Non-numeric value passed to increment method.'); } - $wrapped = $this->grammar->wrap($column); + return $this->incrementEach([$column => $amount], $extra); + } - $columns = array_merge([$column => $this->raw("$wrapped + $amount")], $extra); + /** + * Increment the given column's values by the given amounts. + * + * @param array $columns + * @param array $extra + * @return int + * + * @throws \InvalidArgumentException + */ + public function incrementEach(array $columns, array $extra = []) + { + foreach ($columns as $column => $amount) { + if (! is_numeric($amount)) { + throw new InvalidArgumentException("Non-numeric value passed as increment amount for column: '$column'."); + } elseif (! is_string($column)) { + throw new InvalidArgumentException('Non-associative array passed to incrementEach method.'); + } + + $columns[$column] = $this->raw("{$this->grammar->wrap($column)} + $amount"); + } - return $this->update($columns); + return $this->update(array_merge($columns, $extra)); } /** @@ -3407,11 +3503,31 @@ public function decrement($column, $amount = 1, array $extra = []) throw new InvalidArgumentException('Non-numeric value passed to decrement method.'); } - $wrapped = $this->grammar->wrap($column); + return $this->decrementEach([$column => $amount], $extra); + } - $columns = array_merge([$column => $this->raw("$wrapped - $amount")], $extra); + /** + * Decrement the given column's values by the given amounts. + * + * @param array $columns + * @param array $extra + * @return int + * + * @throws \InvalidArgumentException + */ + public function decrementEach(array $columns, array $extra = []) + { + foreach ($columns as $column => $amount) { + if (! is_numeric($amount)) { + throw new InvalidArgumentException("Non-numeric value passed as decrement amount for column: '$column'."); + } elseif (! is_string($column)) { + throw new InvalidArgumentException('Non-associative array passed to decrementEach method.'); + } + + $columns[$column] = $this->raw("{$this->grammar->wrap($column)} - $amount"); + } - return $this->update($columns); + return $this->update(array_merge($columns, $extra)); } /** diff --git a/src/Illuminate/Database/Query/Grammars/Grammar.php b/src/Illuminate/Database/Query/Grammars/Grammar.php index 86c705fedd98..cd3a0c0fa5d0 100755 --- a/src/Illuminate/Database/Query/Grammars/Grammar.php +++ b/src/Illuminate/Database/Query/Grammars/Grammar.php @@ -36,6 +36,7 @@ class Grammar extends BaseGrammar 'aggregate', 'columns', 'from', + 'indexHint', 'joins', 'wheres', 'groups', @@ -619,7 +620,7 @@ protected function compileJsonContains($column, $value) */ public function prepareBindingForJsonContains($binding) { - return json_encode($binding); + return json_encode($binding, JSON_UNESCAPED_UNICODE); } /** @@ -682,6 +683,17 @@ protected function compileJsonLength($column, $operator, $value) throw new RuntimeException('This database engine does not support JSON length operations.'); } + /** + * Compile a "JSON value cast" statement into SQL. + * + * @param string $value + * @return string + */ + public function compileJsonValueCast($value) + { + return $value; + } + /** * Compile a "where fulltext" clause. * @@ -866,7 +878,7 @@ protected function compileOrdersToArray(Builder $query, $orders) /** * Compile the random statement into SQL. * - * @param string $seed + * @param string|int $seed * @return string */ public function compileRandom($seed) diff --git a/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php b/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php index 7586ce825bf4..131f8afb4767 100755 --- a/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php @@ -74,6 +74,22 @@ public function whereFullText(Builder $query, $where) return "match ({$columns}) against (".$value."{$mode}{$expanded})"; } + /** + * Compile the index hints for the query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param \Illuminate\Database\Query\IndexHint $indexHint + * @return string + */ + protected function compileIndexHint(Builder $query, $indexHint) + { + return match ($indexHint->type) { + 'hint' => "use index ({$indexHint->index})", + 'force' => "force index ({$indexHint->index})", + default => "ignore index ({$indexHint->index})", + }; + } + /** * Compile an insert ignore statement into SQL. * @@ -128,10 +144,21 @@ protected function compileJsonLength($column, $operator, $value) return 'json_length('.$field.$path.') '.$operator.' '.$value; } + /** + * Compile a "JSON value cast" statement into SQL. + * + * @param string $value + * @return string + */ + public function compileJsonValueCast($value) + { + return 'cast('.$value.' as json)'; + } + /** * Compile the random statement into SQL. * - * @param string $seed + * @param string|int $seed * @return string */ public function compileRandom($seed) diff --git a/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php b/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php index 81b890b8b717..8bf7d39f6935 100755 --- a/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php @@ -117,6 +117,20 @@ protected function dateBasedWhere($type, Builder $query, $where) return "strftime('{$type}', {$this->wrap($where['column'])}) {$where['operator']} cast({$value} as text)"; } + /** + * Compile the index hints for the query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param \Illuminate\Database\Query\IndexHint $indexHint + * @return string + */ + protected function compileIndexHint(Builder $query, $indexHint) + { + return $indexHint->type === 'force' + ? "indexed by {$indexHint->index}" + : ''; + } + /** * Compile a "JSON length" statement into SQL. * diff --git a/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php b/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php index a26157ea84a3..baebb93b1eda 100755 --- a/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php @@ -96,6 +96,20 @@ protected function compileFrom(Builder $query, $table) return $from; } + /** + * Compile the index hints for the query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param \Illuminate\Database\Query\IndexHint $indexHint + * @return string + */ + protected function compileIndexHint(Builder $query, $indexHint) + { + return $indexHint->type === 'force' + ? "with (index({$indexHint->index}))" + : ''; + } + /** * {@inheritdoc} * @@ -205,6 +219,17 @@ protected function compileJsonLength($column, $operator, $value) return '(select count(*) from openjson('.$field.$path.')) '.$operator.' '.$value; } + /** + * Compile a "JSON value cast" statement into SQL. + * + * @param string $value + * @return string + */ + public function compileJsonValueCast($value) + { + return 'json_query('.$value.')'; + } + /** * Compile a single having clause. * @@ -364,7 +389,7 @@ protected function compileDeleteWithoutJoins(Builder $query, $table, $where) /** * Compile the random statement into SQL. * - * @param string $seed + * @param string|int $seed * @return string */ public function compileRandom($seed) diff --git a/src/Illuminate/Database/Query/IndexHint.php b/src/Illuminate/Database/Query/IndexHint.php new file mode 100755 index 000000000000..2a720a2dee2b --- /dev/null +++ b/src/Illuminate/Database/Query/IndexHint.php @@ -0,0 +1,33 @@ +type = $type; + $this->index = $index; + } +} diff --git a/src/Illuminate/Database/SQLiteConnection.php b/src/Illuminate/Database/SQLiteConnection.php index 59b5edb210b2..b86997e0f8b3 100755 --- a/src/Illuminate/Database/SQLiteConnection.php +++ b/src/Illuminate/Database/SQLiteConnection.php @@ -78,7 +78,7 @@ protected function getDefaultSchemaGrammar() * * @throws \RuntimeException */ - public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { return new SqliteSchemaState($this, $files, $processFactory); } diff --git a/src/Illuminate/Database/SQLiteDatabaseDoesNotExistException.php b/src/Illuminate/Database/SQLiteDatabaseDoesNotExistException.php new file mode 100644 index 000000000000..f93cfe444bbb --- /dev/null +++ b/src/Illuminate/Database/SQLiteDatabaseDoesNotExistException.php @@ -0,0 +1,28 @@ +path = $path; + } +} diff --git a/src/Illuminate/Database/Schema/Blueprint.php b/src/Illuminate/Database/Schema/Blueprint.php index 2f8b5442ddbf..6282073a5f76 100755 --- a/src/Illuminate/Database/Schema/Blueprint.php +++ b/src/Illuminate/Database/Schema/Blueprint.php @@ -86,7 +86,7 @@ class Blueprint * @param string $prefix * @return void */ - public function __construct($table, Closure $callback = null, $prefix = '') + public function __construct($table, ?Closure $callback = null, $prefix = '') { $this->table = $table; $this->prefix = $prefix; @@ -152,7 +152,8 @@ public function toSql(Connection $connection, Grammar $grammar) protected function ensureCommandsAreValid(Connection $connection) { if ($connection instanceof SQLiteConnection) { - if ($this->commandsNamed(['dropColumn', 'renameColumn'])->count() > 1) { + if ($this->commandsNamed(['dropColumn', 'renameColumn'])->count() > 1 + && ! $connection->usingNativeSchemaOperations()) { throw new BadMethodCallException( "SQLite doesn't support multiple calls to dropColumn / renameColumn in a single modification." ); @@ -214,7 +215,17 @@ protected function addFluentIndexes() // index method can be called without a name and it will generate one. if ($column->{$index} === true) { $this->{$index}($column->name); - $column->{$index} = false; + $column->{$index} = null; + + continue 2; + } + + // If the index has been specified on the given column, but it equals false + // and the column is supposed to be changed, we will call the drop index + // method with an array of column to drop it by its conventional name. + elseif ($column->{$index} === false && $column->change) { + $this->{'drop'.ucfirst($index)}([$column->name]); + $column->{$index} = null; continue 2; } @@ -224,7 +235,7 @@ protected function addFluentIndexes() // the index since the developer specified the explicit name for this. elseif (isset($column->{$index})) { $this->{$index}($column->name, $column->{$index}); - $column->{$index} = false; + $column->{$index} = null; continue 2; } @@ -1243,7 +1254,7 @@ public function binary($column) } /** - * Create a new uuid column on the table. + * Create a new UUID column on the table. * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition @@ -1267,6 +1278,34 @@ public function foreignUuid($column) ])); } + /** + * Create a new ULID column on the table. + * + * @param string $column + * @param int|null $length + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function ulid($column = 'uuid', $length = 26) + { + return $this->char($column, $length); + } + + /** + * Create a new ULID column on the table with a foreign key constraint. + * + * @param string $column + * @param int|null $length + * @return \Illuminate\Database\Schema\ForeignIdColumnDefinition + */ + public function foreignUlid($column, $length = 26) + { + return $this->addColumnDefinition(new ForeignIdColumnDefinition($this, [ + 'type' => 'char', + 'name' => $column, + 'length' => $length, + ])); + } + /** * Create a new IP address column on the table. * @@ -1412,6 +1451,8 @@ public function morphs($name, $indexName = null) { if (Builder::$defaultMorphKeyType === 'uuid') { $this->uuidMorphs($name, $indexName); + } elseif (Builder::$defaultMorphKeyType === 'ulid') { + $this->ulidMorphs($name, $indexName); } else { $this->numericMorphs($name, $indexName); } @@ -1428,6 +1469,8 @@ public function nullableMorphs($name, $indexName = null) { if (Builder::$defaultMorphKeyType === 'uuid') { $this->nullableUuidMorphs($name, $indexName); + } elseif (Builder::$defaultMorphKeyType === 'ulid') { + $this->nullableUlidMorphs($name, $indexName); } else { $this->nullableNumericMorphs($name, $indexName); } @@ -1497,6 +1540,38 @@ public function nullableUuidMorphs($name, $indexName = null) $this->index(["{$name}_type", "{$name}_id"], $indexName); } + /** + * Add the proper columns for a polymorphic table using ULIDs. + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function ulidMorphs($name, $indexName = null) + { + $this->string("{$name}_type"); + + $this->ulid("{$name}_id"); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Add nullable columns for a polymorphic table using ULIDs. + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function nullableUlidMorphs($name, $indexName = null) + { + $this->string("{$name}_type")->nullable(); + + $this->ulid("{$name}_id")->nullable(); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + /** * Adds the `remember_token` column to the table. * diff --git a/src/Illuminate/Database/Schema/Builder.php b/src/Illuminate/Database/Schema/Builder.php index 8804c5985ccc..d2d9b4a40f83 100755 --- a/src/Illuminate/Database/Schema/Builder.php +++ b/src/Illuminate/Database/Schema/Builder.php @@ -45,6 +45,13 @@ class Builder */ public static $defaultMorphKeyType = 'int'; + /** + * Indicates whether Doctrine DBAL usage will be prevented if possible when dropping and renaming columns. + * + * @var bool + */ + public static $alwaysUsesNativeSchemaOperationsIfPossible = false; + /** * Create a new database Schema manager. * @@ -78,8 +85,8 @@ public static function defaultStringLength($length) */ public static function defaultMorphKeyType(string $type) { - if (! in_array($type, ['int', 'uuid'])) { - throw new InvalidArgumentException("Morph key type must be 'int' or 'uuid'."); + if (! in_array($type, ['int', 'uuid', 'ulid'])) { + throw new InvalidArgumentException("Morph key type must be 'int', 'uuid', or 'ulid'."); } static::$defaultMorphKeyType = $type; @@ -95,6 +102,27 @@ public static function morphUsingUuids() return static::defaultMorphKeyType('uuid'); } + /** + * Set the default morph key type for migrations to ULIDs. + * + * @return void + */ + public static function morphUsingUlids() + { + return static::defaultMorphKeyType('ulid'); + } + + /** + * Attempt to use native schema operations for dropping and renaming columns, even if Doctrine DBAL is installed. + * + * @param bool $value + * @return void + */ + public static function useNativeSchemaOperationsIfPossible(bool $value = true) + { + static::$alwaysUsesNativeSchemaOperationsIfPossible = $value; + } + /** * Create a database in the schema. * @@ -383,6 +411,23 @@ public function disableForeignKeyConstraints() ); } + /** + * Disable foreign key constraints during the execution of a callback. + * + * @param \Closure $callback + * @return mixed + */ + public function withoutForeignKeyConstraints(Closure $callback) + { + $this->disableForeignKeyConstraints(); + + $result = $callback(); + + $this->enableForeignKeyConstraints(); + + return $result; + } + /** * Execute the blueprint to build / modify the table. * @@ -401,7 +446,7 @@ protected function build(Blueprint $blueprint) * @param \Closure|null $callback * @return \Illuminate\Database\Schema\Blueprint */ - protected function createBlueprint($table, Closure $callback = null) + protected function createBlueprint($table, ?Closure $callback = null) { $prefix = $this->connection->getConfig('prefix_indexes') ? $this->connection->getConfig('prefix') diff --git a/src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php b/src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php index 1a2059eee3bb..354b248d2973 100644 --- a/src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php +++ b/src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php @@ -36,7 +36,7 @@ public function __construct(Blueprint $blueprint, $attributes = []) */ public function constrained($table = null, $column = 'id') { - return $this->references($column)->on($table ?? Str::plural(Str::beforeLast($this->name, '_'.$column))); + return $this->references($column)->on($table ?? Str::of($this->name)->beforeLast('_'.$column)->plural()); } /** diff --git a/src/Illuminate/Database/Schema/ForeignKeyDefinition.php b/src/Illuminate/Database/Schema/ForeignKeyDefinition.php index a03fcff77753..3bb8b719ea59 100644 --- a/src/Illuminate/Database/Schema/ForeignKeyDefinition.php +++ b/src/Illuminate/Database/Schema/ForeignKeyDefinition.php @@ -63,4 +63,14 @@ public function nullOnDelete() { return $this->onDelete('set null'); } + + /** + * Indicate that deletes should have "no action". + * + * @return $this + */ + public function noActionOnDelete() + { + return $this->onDelete('no action'); + } } diff --git a/src/Illuminate/Database/Schema/Grammars/ChangeColumn.php b/src/Illuminate/Database/Schema/Grammars/ChangeColumn.php index 70ec66652eb9..9579222991b7 100644 --- a/src/Illuminate/Database/Schema/Grammars/ChangeColumn.php +++ b/src/Illuminate/Database/Schema/Grammars/ChangeColumn.php @@ -121,7 +121,7 @@ protected static function getDoctrineColumnChangeOptions(Fluent $fluent) { $options = ['type' => static::getDoctrineColumnType($fluent['type'])]; - if (in_array($fluent['type'], ['text', 'mediumText', 'longText'])) { + if (in_array($fluent['type'], ['tinyText', 'text', 'mediumText', 'longText'])) { $options['length'] = static::calculateDoctrineTextLength($fluent['type']); } @@ -152,10 +152,11 @@ protected static function getDoctrineColumnType($type) return Type::getType(match ($type) { 'biginteger' => 'bigint', 'smallinteger' => 'smallint', - 'mediumtext', 'longtext' => 'text', + 'tinytext', 'mediumtext', 'longtext' => 'text', 'binary' => 'blob', 'uuid' => 'guid', 'char' => 'string', + 'double' => 'float', default => $type, }); } @@ -169,6 +170,7 @@ protected static function getDoctrineColumnType($type) protected static function calculateDoctrineTextLength($type) { return match ($type) { + 'tinyText' => 1, 'mediumText' => 65535 + 1, 'longText' => 16777215 + 1, default => 255 + 1, @@ -197,6 +199,7 @@ protected static function doesntNeedCharacterOptions($type) 'mediumInteger', 'smallInteger', 'time', + 'timestamp', 'tinyInteger', ]); } diff --git a/src/Illuminate/Database/Schema/Grammars/Grammar.php b/src/Illuminate/Database/Schema/Grammars/Grammar.php index d446dd7dfbc7..ea8333e40436 100755 --- a/src/Illuminate/Database/Schema/Grammars/Grammar.php +++ b/src/Illuminate/Database/Schema/Grammars/Grammar.php @@ -64,7 +64,7 @@ public function compileDropDatabaseIfExists($name) * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command * @param \Illuminate\Database\Connection $connection - * @return array + * @return array|string */ public function compileRenameColumn(Blueprint $blueprint, Fluent $command, Connection $connection) { diff --git a/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php b/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php index c5259e1005f0..f87acfd63ff3 100755 --- a/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php @@ -203,6 +203,25 @@ public function compileAutoIncrementStartingValues(Blueprint $blueprint) })->all(); } + /** + * Compile a rename column command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @param \Illuminate\Database\Connection $connection + * @return array|string + */ + public function compileRenameColumn(Blueprint $blueprint, Fluent $command, Connection $connection) + { + return $connection->usingNativeSchemaOperations() + ? sprintf('alter table %s rename column %s to %s', + $this->wrapTable($blueprint), + $this->wrap($command->from), + $this->wrap($command->to) + ) + : parent::compileRenameColumn($blueprint, $command, $connection); + } + /** * Compile a primary key command. * @@ -212,9 +231,11 @@ public function compileAutoIncrementStartingValues(Blueprint $blueprint) */ public function compilePrimary(Blueprint $blueprint, Fluent $command) { - $command->name(null); - - return $this->compileKey($blueprint, $command, 'primary key'); + return sprintf('alter table %s add primary key %s(%s)', + $this->wrapTable($blueprint), + $command->algorithm ? 'using '.$command->algorithm : '', + $this->columnize($command->columns) + ); } /** diff --git a/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php b/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php index 662b9ece8697..ef60d0ff820f 100755 --- a/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php @@ -2,6 +2,7 @@ namespace Illuminate\Database\Schema\Grammars; +use Illuminate\Database\Connection; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Fluent; @@ -129,6 +130,25 @@ public function compileAutoIncrementStartingValues(Blueprint $blueprint) })->all(); } + /** + * Compile a rename column command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @param \Illuminate\Database\Connection $connection + * @return array|string + */ + public function compileRenameColumn(Blueprint $blueprint, Fluent $command, Connection $connection) + { + return $connection->usingNativeSchemaOperations() + ? sprintf('alter table %s rename column %s to %s', + $this->wrapTable($blueprint), + $this->wrap($command->from), + $this->wrap($command->to) + ) + : parent::compileRenameColumn($blueprint, $command, $connection); + } + /** * Compile a primary key command. * @@ -152,11 +172,21 @@ public function compilePrimary(Blueprint $blueprint, Fluent $command) */ public function compileUnique(Blueprint $blueprint, Fluent $command) { - return sprintf('alter table %s add constraint %s unique (%s)', + $sql = sprintf('alter table %s add constraint %s unique (%s)', $this->wrapTable($blueprint), $this->wrap($command->index), $this->columnize($command->columns) ); + + if (! is_null($command->deferrable)) { + $sql .= $command->deferrable ? ' deferrable' : ' not deferrable'; + } + + if ($command->deferrable && ! is_null($command->initiallyImmediate)) { + $sql .= $command->initiallyImmediate ? ' initially immediate' : ' initially deferred'; + } + + return $sql; } /** diff --git a/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php b/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php index 9bb6fd8fa0f4..c9d1c5503aaa 100755 --- a/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php @@ -144,6 +144,25 @@ public function compileAdd(Blueprint $blueprint, Fluent $command) })->all(); } + /** + * Compile a rename column command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @param \Illuminate\Database\Connection $connection + * @return array|string + */ + public function compileRenameColumn(Blueprint $blueprint, Fluent $command, Connection $connection) + { + return $connection->usingNativeSchemaOperations() + ? sprintf('alter table %s rename column %s to %s', + $this->wrapTable($blueprint), + $this->wrap($command->from), + $this->wrap($command->to) + ) + : parent::compileRenameColumn($blueprint, $command, $connection); + } + /** * Compile a unique key command. * @@ -286,17 +305,26 @@ public function compileRebuild() */ public function compileDropColumn(Blueprint $blueprint, Fluent $command, Connection $connection) { - $tableDiff = $this->getDoctrineTableDiff( - $blueprint, $schema = $connection->getDoctrineSchemaManager() - ); + if ($connection->usingNativeSchemaOperations()) { + $table = $this->wrapTable($blueprint); - foreach ($command->columns as $name) { - $tableDiff->removedColumns[$name] = $connection->getDoctrineColumn( - $this->getTablePrefix().$blueprint->getTable(), $name + $columns = $this->prefixArray('drop column', $this->wrapArray($command->columns)); + + return collect($columns)->map(fn ($column) => 'alter table '.$table.' '.$column + )->all(); + } else { + $tableDiff = $this->getDoctrineTableDiff( + $blueprint, $schema = $connection->getDoctrineSchemaManager() ); - } - return (array) $schema->getDatabasePlatform()->getAlterTableSQL($tableDiff); + foreach ($command->columns as $name) { + $tableDiff->removedColumns[$name] = $connection->getDoctrineColumn( + $this->getTablePrefix().$blueprint->getTable(), $name + ); + } + + return (array) $schema->getDatabasePlatform()->getAlterTableSQL($tableDiff); + } } /** diff --git a/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php b/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php index 5121d782efe3..4d7271ca3308 100755 --- a/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php @@ -2,6 +2,7 @@ namespace Illuminate\Database\Schema\Grammars; +use Illuminate\Database\Connection; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Fluent; @@ -107,6 +108,24 @@ public function compileAdd(Blueprint $blueprint, Fluent $command) ); } + /** + * Compile a rename column command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @param \Illuminate\Database\Connection $connection + * @return array|string + */ + public function compileRenameColumn(Blueprint $blueprint, Fluent $command, Connection $connection) + { + return $connection->usingNativeSchemaOperations() + ? sprintf("sp_rename '%s', %s, 'COLUMN'", + $this->wrap($blueprint->getTable().'.'.$command->from), + $this->wrap($command->to) + ) + : parent::compileRenameColumn($blueprint, $command, $connection); + } + /** * Compile a primary key command. * diff --git a/src/Illuminate/Database/Schema/IndexDefinition.php b/src/Illuminate/Database/Schema/IndexDefinition.php index d81d87125b56..fc5d78e5b92f 100644 --- a/src/Illuminate/Database/Schema/IndexDefinition.php +++ b/src/Illuminate/Database/Schema/IndexDefinition.php @@ -7,6 +7,8 @@ /** * @method $this algorithm(string $algorithm) Specify an algorithm for the index (MySQL/PostgreSQL) * @method $this language(string $language) Specify a language for the full text index (PostgreSQL) + * @method $this deferrable(bool $value = true) Specify that the unique index is deferrable (PostgreSQL) + * @method $this initiallyImmediate(bool $value = true) Specify the default time to check the unique index constraint (PostgreSQL) */ class IndexDefinition extends Fluent { diff --git a/src/Illuminate/Database/Schema/MySqlBuilder.php b/src/Illuminate/Database/Schema/MySqlBuilder.php index 699b41d5f227..bbb0763a721d 100755 --- a/src/Illuminate/Database/Schema/MySqlBuilder.php +++ b/src/Illuminate/Database/Schema/MySqlBuilder.php @@ -40,7 +40,7 @@ public function hasTable($table) { $table = $this->connection->getTablePrefix().$table; - return count($this->connection->select( + return count($this->connection->selectFromWriteConnection( $this->grammar->compileTableExists(), [$this->connection->getDatabaseName(), $table] )) > 0; } @@ -55,7 +55,7 @@ public function getColumnListing($table) { $table = $this->connection->getTablePrefix().$table; - $results = $this->connection->select( + $results = $this->connection->selectFromWriteConnection( $this->grammar->compileColumnListing(), [$this->connection->getDatabaseName(), $table] ); diff --git a/src/Illuminate/Database/Schema/MySqlSchemaState.php b/src/Illuminate/Database/Schema/MySqlSchemaState.php index d28ab10ad0f2..0cd3486e6045 100644 --- a/src/Illuminate/Database/Schema/MySqlSchemaState.php +++ b/src/Illuminate/Database/Schema/MySqlSchemaState.php @@ -85,10 +85,10 @@ public function load($path) */ protected function baseDumpCommand() { - $command = 'mysqldump '.$this->connectionString().' --no-tablespaces --skip-add-locks --skip-comments --skip-set-charset --tz-utc'; + $command = 'mysqldump '.$this->connectionString().' --no-tablespaces --skip-add-locks --skip-comments --skip-set-charset --tz-utc --column-statistics=0'; if (! $this->connection->isMaria()) { - $command .= ' --column-statistics=0 --set-gtid-purged=OFF'; + $command .= ' --set-gtid-purged=OFF'; } return $command.' "${:LARAVEL_LOAD_DATABASE}"'; diff --git a/src/Illuminate/Database/Schema/PostgresBuilder.php b/src/Illuminate/Database/Schema/PostgresBuilder.php index 976e09dc2091..adfbd688ee28 100755 --- a/src/Illuminate/Database/Schema/PostgresBuilder.php +++ b/src/Illuminate/Database/Schema/PostgresBuilder.php @@ -48,7 +48,7 @@ public function hasTable($table) $table = $this->connection->getTablePrefix().$table; - return count($this->connection->select( + return count($this->connection->selectFromWriteConnection( $this->grammar->compileTableExists(), [$database, $schema, $table] )) > 0; } @@ -187,7 +187,7 @@ public function getColumnListing($table) $table = $this->connection->getTablePrefix().$table; - $results = $this->connection->select( + $results = $this->connection->selectFromWriteConnection( $this->grammar->compileColumnListing(), [$database, $schema, $table] ); diff --git a/src/Illuminate/Database/Schema/PostgresSchemaState.php b/src/Illuminate/Database/Schema/PostgresSchemaState.php index b6d4273ffafa..cfb100d0cabf 100644 --- a/src/Illuminate/Database/Schema/PostgresSchemaState.php +++ b/src/Illuminate/Database/Schema/PostgresSchemaState.php @@ -15,19 +15,16 @@ class PostgresSchemaState extends SchemaState */ public function dump(Connection $connection, $path) { - $excludedTables = collect($connection->getSchemaBuilder()->getAllTables()) - ->map->tablename - ->reject(function ($table) { - return $table === $this->migrationTable; - })->map(function ($table) { - return '--exclude-table-data="*.'.$table.'"'; - })->implode(' '); + $commands = collect([ + $this->baseDumpCommand().' --schema-only > '.$path, + $this->baseDumpCommand().' -t '.$this->migrationTable.' --data-only >> '.$path, + ]); - $this->makeProcess( - $this->baseDumpCommand().' --file="${:LARAVEL_LOAD_PATH}" '.$excludedTables - )->mustRun($this->output, array_merge($this->baseVariables($this->connection->getConfig()), [ - 'LARAVEL_LOAD_PATH' => $path, - ])); + $commands->map(function ($command, $path) { + $this->makeProcess($command)->mustRun($this->output, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + }); } /** @@ -58,7 +55,7 @@ public function load($path) */ protected function baseDumpCommand() { - return 'pg_dump --no-owner --no-acl -Fc --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}"'; + return 'pg_dump --no-owner --no-acl --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}"'; } /** diff --git a/src/Illuminate/Database/Schema/SchemaState.php b/src/Illuminate/Database/Schema/SchemaState.php index e6f35ab91fe9..c21f4ba762e4 100644 --- a/src/Illuminate/Database/Schema/SchemaState.php +++ b/src/Illuminate/Database/Schema/SchemaState.php @@ -51,7 +51,7 @@ abstract class SchemaState * @param callable|null $processFactory * @return void */ - public function __construct(Connection $connection, Filesystem $files = null, callable $processFactory = null) + public function __construct(Connection $connection, ?Filesystem $files = null, ?callable $processFactory = null) { $this->connection = $connection; @@ -86,7 +86,7 @@ abstract public function load($path); /** * Create a new process instance. * - * @param array $arguments + * @param mixed ...$arguments * @return \Symfony\Component\Process\Process */ public function makeProcess(...$arguments) diff --git a/src/Illuminate/Database/Schema/SqliteSchemaState.php b/src/Illuminate/Database/Schema/SqliteSchemaState.php index 9a98b6331cba..10efc7c0aba9 100644 --- a/src/Illuminate/Database/Schema/SqliteSchemaState.php +++ b/src/Illuminate/Database/Schema/SqliteSchemaState.php @@ -61,6 +61,12 @@ protected function appendMigrationData(string $path) */ public function load($path) { + if ($this->connection->getDatabaseName() === ':memory:') { + $this->connection->getPdo()->exec($this->files->get($path)); + + return; + } + $process = $this->makeProcess($this->baseCommand().' < "${:LARAVEL_LOAD_PATH}"'); $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ diff --git a/src/Illuminate/Database/Seeder.php b/src/Illuminate/Database/Seeder.php index 1a7a12e1914d..ba4cd4ae3826 100755 --- a/src/Illuminate/Database/Seeder.php +++ b/src/Illuminate/Database/Seeder.php @@ -3,7 +3,8 @@ namespace Illuminate\Database; use Illuminate\Console\Command; -use Illuminate\Container\Container; +use Illuminate\Console\View\Components\TwoColumnDetail; +use Illuminate\Contracts\Container\Container; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Support\Arr; use InvalidArgumentException; @@ -13,7 +14,7 @@ abstract class Seeder /** * The container instance. * - * @var \Illuminate\Container\Container + * @var \Illuminate\Contracts\Container\Container */ protected $container; @@ -49,17 +50,25 @@ public function call($class, $silent = false, array $parameters = []) $name = get_class($seeder); if ($silent === false && isset($this->command)) { - $this->command->getOutput()->writeln("Seeding: {$name}"); + with(new TwoColumnDetail($this->command->getOutput()))->render( + $name, + 'RUNNING' + ); } $startTime = microtime(true); $seeder->__invoke($parameters); - $runTime = number_format((microtime(true) - $startTime) * 1000, 2); - if ($silent === false && isset($this->command)) { - $this->command->getOutput()->writeln("Seeded: {$name} ({$runTime}ms)"); + $runTime = number_format((microtime(true) - $startTime) * 1000, 2); + + with(new TwoColumnDetail($this->command->getOutput()))->render( + $name, + "$runTime ms DONE" + ); + + $this->command->getOutput()->writeln(''); } static::$called[] = $class; @@ -134,7 +143,7 @@ protected function resolve($class) /** * Set the IoC container instance. * - * @param \Illuminate\Container\Container $container + * @param \Illuminate\Contracts\Container\Container $container * @return $this */ public function setContainer(Container $container) diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index ab8983d54672..1c98d230e64f 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -27,7 +27,7 @@ public function transaction(Closure $callback, $attempts = 1) { for ($a = 1; $a <= $attempts; $a++) { if ($this->getDriverName() === 'sqlsrv') { - return parent::transaction($callback); + return parent::transaction($callback, $attempts); } $this->getPdo()->exec('BEGIN TRAN'); @@ -96,7 +96,7 @@ protected function getDefaultSchemaGrammar() * * @throws \RuntimeException */ - public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { throw new RuntimeException('Schema dumping is not supported when using SQL Server.'); } diff --git a/src/Illuminate/Database/composer.json b/src/Illuminate/Database/composer.json index df3d06cbac66..ba2dc30406df 100644 --- a/src/Illuminate/Database/composer.json +++ b/src/Illuminate/Database/composer.json @@ -16,13 +16,14 @@ ], "require": { "php": "^8.0.2", - "ext-json": "*", + "ext-pdo": "*", + "brick/math": "^0.9.3|^0.10.2|^0.11", "illuminate/collections": "^9.0", "illuminate/container": "^9.0", "illuminate/contracts": "^9.0", "illuminate/macroable": "^9.0", "illuminate/support": "^9.0", - "symfony/console": "^6.0" + "symfony/console": "^6.0.9" }, "autoload": { "psr-4": { @@ -35,8 +36,9 @@ } }, "suggest": { + "ext-filter": "Required to use the Postgres database driver.", "doctrine/dbal": "Required to rename columns and drop SQLite columns (^2.13.3|^3.1.4).", - "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", + "fakerphp/faker": "Required to use the eloquent factory builder (^1.21).", "illuminate/console": "Required to use the database commands (^9.0).", "illuminate/events": "Required to use the observers with Eloquent (^9.0).", "illuminate/filesystem": "Required to use the migrations (^9.0).", diff --git a/src/Illuminate/Encryption/Encrypter.php b/src/Illuminate/Encryption/Encrypter.php index ff9d88f8ca41..5a8d82ec9ec6 100755 --- a/src/Illuminate/Encryption/Encrypter.php +++ b/src/Illuminate/Encryption/Encrypter.php @@ -112,7 +112,7 @@ public function encrypt($value, $serialize = true) $tag = base64_encode($tag ?? ''); $mac = self::$supportedCiphers[strtolower($this->cipher)]['aead'] - ? '' // For AEAD-algoritms, the tag / MAC is returned by openssl_encrypt... + ? '' // For AEAD-algorithms, the tag / MAC is returned by openssl_encrypt... : $this->hash($iv, $value); $json = json_encode(compact('iv', 'value', 'mac', 'tag'), JSON_UNESCAPED_SLASHES); @@ -229,8 +229,21 @@ protected function getJsonPayload($payload) */ protected function validPayload($payload) { - return is_array($payload) && isset($payload['iv'], $payload['value'], $payload['mac']) && - strlen(base64_decode($payload['iv'], true)) === openssl_cipher_iv_length(strtolower($this->cipher)); + if (! is_array($payload)) { + return false; + } + + foreach (['iv', 'value', 'mac'] as $item) { + if (! isset($payload[$item]) || ! is_string($payload[$item])) { + return false; + } + } + + if (isset($payload['tag']) && ! is_string($payload['tag'])) { + return false; + } + + return strlen(base64_decode($payload['iv'], true)) === openssl_cipher_iv_length(strtolower($this->cipher)); } /** diff --git a/src/Illuminate/Encryption/composer.json b/src/Illuminate/Encryption/composer.json index 333e57dfdfd0..d43d876f282e 100644 --- a/src/Illuminate/Encryption/composer.json +++ b/src/Illuminate/Encryption/composer.json @@ -15,7 +15,7 @@ ], "require": { "php": "^8.0.2", - "ext-json": "*", + "ext-hash": "*", "ext-mbstring": "*", "ext-openssl": "*", "illuminate/contracts": "^9.0", diff --git a/src/Illuminate/Events/Dispatcher.php b/src/Illuminate/Events/Dispatcher.php index 47081d1af65d..a07893fb7919 100755 --- a/src/Illuminate/Events/Dispatcher.php +++ b/src/Illuminate/Events/Dispatcher.php @@ -62,7 +62,7 @@ class Dispatcher implements DispatcherContract * @param \Illuminate\Contracts\Container\Container|null $container * @return void */ - public function __construct(ContainerContract $container = null) + public function __construct(?ContainerContract $container = null) { $this->container = $container ?: new Container; } @@ -579,11 +579,11 @@ protected function queueHandler($class, $method, $arguments) [$listener, $job] = $this->createListenerAndJob($class, $method, $arguments); $connection = $this->resolveQueue()->connection(method_exists($listener, 'viaConnection') - ? $listener->viaConnection() + ? (isset($arguments[0]) ? $listener->viaConnection($arguments[0]) : $listener->viaConnection()) : $listener->connection ?? null); $queue = method_exists($listener, 'viaQueue') - ? $listener->viaQueue() + ? (isset($arguments[0]) ? $listener->viaQueue($arguments[0]) : $listener->viaQueue()) : $listener->queue ?? null; isset($listener->delay) diff --git a/src/Illuminate/Filesystem/AwsS3V3Adapter.php b/src/Illuminate/Filesystem/AwsS3V3Adapter.php index 45d4f545360d..8e908e81aa59 100644 --- a/src/Illuminate/Filesystem/AwsS3V3Adapter.php +++ b/src/Illuminate/Filesystem/AwsS3V3Adapter.php @@ -3,11 +3,14 @@ namespace Illuminate\Filesystem; use Aws\S3\S3Client; +use Illuminate\Support\Traits\Conditionable; use League\Flysystem\AwsS3V3\AwsS3V3Adapter as S3Adapter; use League\Flysystem\FilesystemOperator; class AwsS3V3Adapter extends FilesystemAdapter { + use Conditionable; + /** * The AWS S3 client. * @@ -53,6 +56,16 @@ public function url($path) ); } + /** + * Determine if temporary URLs can be generated. + * + * @return bool + */ + public function providesTemporaryUrls() + { + return true; + } + /** * Get a temporary URL for the file at the given path. * @@ -82,6 +95,40 @@ public function temporaryUrl($path, $expiration, array $options = []) return (string) $uri; } + /** + * Get a temporary upload URL for the file at the given path. + * + * @param string $path + * @param \DateTimeInterface $expiration + * @param array $options + * @return array + */ + public function temporaryUploadUrl($path, $expiration, array $options = []) + { + $command = $this->client->getCommand('PutObject', array_merge([ + 'Bucket' => $this->config['bucket'], + 'Key' => $this->prefixer->prefixPath($path), + ], $options)); + + $signedRequest = $this->client->createPresignedRequest( + $command, $expiration, $options + ); + + $uri = $signedRequest->getUri(); + + // If an explicit base URL has been set on the disk configuration then we will use + // it as the base URL instead of the default path. This allows the developer to + // have full control over the base path for this filesystem's generated URLs. + if (isset($this->config['temporary_url'])) { + $uri = $this->replaceBaseUrl($uri, $this->config['temporary_url']); + } + + return [ + 'url' => (string) $uri, + 'headers' => $signedRequest->getHeaders(), + ]; + } + /** * Get the underlying S3 client. * diff --git a/src/Illuminate/Filesystem/Filesystem.php b/src/Illuminate/Filesystem/Filesystem.php index e68bbf730d8c..41095fcca052 100644 --- a/src/Illuminate/Filesystem/Filesystem.php +++ b/src/Illuminate/Filesystem/Filesystem.php @@ -6,6 +6,7 @@ use FilesystemIterator; use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Support\LazyCollection; +use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; use RuntimeException; use SplFileObject; @@ -15,6 +16,7 @@ class Filesystem { + use Conditionable; use Macroable; /** @@ -164,14 +166,15 @@ public function lines($path) } /** - * Get the MD5 hash of the file at the given path. + * Get the hash of the file at the given path. * * @param string $path + * @param string $algorithm * @return string */ - public function hash($path) + public function hash($path, $algorithm = 'md5') { - return md5_file($path); + return hash_file($algorithm, $path); } /** @@ -192,9 +195,10 @@ public function put($path, $contents, $lock = false) * * @param string $path * @param string $content + * @param int|null $mode * @return void */ - public function replace($path, $content) + public function replace($path, $content, $mode = null) { // If the path already exists and is a symlink, get the real path... clearstatcache(true, $path); @@ -204,7 +208,11 @@ public function replace($path, $content) $tempPath = tempnam(dirname($path), basename($path)); // Fix permissions of tempPath because `tempnam()` creates it with permissions set to 0600... - chmod($tempPath, 0777 - umask()); + if (! is_null($mode)) { + chmod($tempPath, $mode); + } else { + chmod($tempPath, 0777 - umask()); + } file_put_contents($tempPath, $content); @@ -356,7 +364,7 @@ public function relativeLink($target, $link) $relativeTarget = (new SymfonyFilesystem)->makePathRelative($target, dirname($link)); - $this->link($relativeTarget, $link); + $this->link($this->isFile($target) ? rtrim($relativeTarget, '/') : $relativeTarget, $link); } /** diff --git a/src/Illuminate/Filesystem/FilesystemAdapter.php b/src/Illuminate/Filesystem/FilesystemAdapter.php index a162488d4236..55d8be1eb9a1 100644 --- a/src/Illuminate/Filesystem/FilesystemAdapter.php +++ b/src/Illuminate/Filesystem/FilesystemAdapter.php @@ -9,6 +9,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; use InvalidArgumentException; use League\Flysystem\FilesystemAdapter as FlysystemAdapter; @@ -23,6 +24,7 @@ use League\Flysystem\UnableToDeleteDirectory; use League\Flysystem\UnableToDeleteFile; use League\Flysystem\UnableToMoveFile; +use League\Flysystem\UnableToProvideChecksum; use League\Flysystem\UnableToReadFile; use League\Flysystem\UnableToRetrieveMetadata; use League\Flysystem\UnableToSetVisibility; @@ -38,6 +40,7 @@ */ class FilesystemAdapter implements CloudFilesystemContract { + use Conditionable; use Macroable { __call as macroCall; } @@ -90,10 +93,13 @@ public function __construct(FilesystemOperator $driver, FlysystemAdapter $adapte $this->driver = $driver; $this->adapter = $adapter; $this->config = $config; + $separator = $config['directory_separator'] ?? DIRECTORY_SEPARATOR; - $this->prefixer = new PathPrefixer( - $config['root'] ?? '', $config['directory_separator'] ?? DIRECTORY_SEPARATOR - ); + $this->prefixer = new PathPrefixer($config['root'] ?? '', $separator); + + if (isset($config['prefix'])) { + $this->prefixer = new PathPrefixer($this->prefixer->prefixPath($config['prefix']), $separator); + } } /** @@ -549,6 +555,24 @@ public function size($path) return $this->driver->fileSize($path); } + /** + * Get the checksum for a file. + * + * @return string|false + * + * @throws UnableToProvideChecksum + */ + public function checksum(string $path, array $options = []) + { + try { + return $this->driver->checksum($path, $options); + } catch (UnableToProvideChecksum $e) { + throw_if($this->throwsExceptions(), $e); + + return false; + } + } + /** * Get the mime-type of a given file. * @@ -615,6 +639,10 @@ public function writeStream($path, $resource, array $options = []) */ public function url($path) { + if (isset($this->config['prefix'])) { + $path = $this->concatPathToUrl($this->config['prefix'], $path); + } + $adapter = $this->adapter; if (method_exists($adapter, 'getUrl')) { @@ -670,6 +698,16 @@ protected function getLocalUrl($path) return $path; } + /** + * Determine if temporary URLs can be generated. + * + * @return bool + */ + public function providesTemporaryUrls() + { + return method_exists($this->adapter, 'getTemporaryUrl') || isset($this->temporaryUrlCallback); + } + /** * Get a temporary URL for the file at the given path. * @@ -695,6 +733,25 @@ public function temporaryUrl($path, $expiration, array $options = []) throw new RuntimeException('This driver does not support creating temporary URLs.'); } + /** + * Get a temporary upload URL for the file at the given path. + * + * @param string $path + * @param \DateTimeInterface $expiration + * @param array $options + * @return array + * + * @throws \RuntimeException + */ + public function temporaryUploadUrl($path, $expiration, array $options = []) + { + if (method_exists($this->adapter, 'temporaryUploadUrl')) { + return $this->adapter->temporaryUploadUrl($path, $expiration, $options); + } + + throw new RuntimeException('This driver does not support creating temporary upload URLs.'); + } + /** * Concatenate a path to a URL. * diff --git a/src/Illuminate/Filesystem/FilesystemManager.php b/src/Illuminate/Filesystem/FilesystemManager.php index cd2e1dd5532f..0475c40e0363 100644 --- a/src/Illuminate/Filesystem/FilesystemManager.php +++ b/src/Illuminate/Filesystem/FilesystemManager.php @@ -14,13 +14,16 @@ use League\Flysystem\Ftp\FtpAdapter; use League\Flysystem\Ftp\FtpConnectionOptions; use League\Flysystem\Local\LocalFilesystemAdapter as LocalAdapter; +use League\Flysystem\PathPrefixing\PathPrefixedAdapter; use League\Flysystem\PhpseclibV3\SftpAdapter; use League\Flysystem\PhpseclibV3\SftpConnectionProvider; +use League\Flysystem\ReadOnly\ReadOnlyFilesystemAdapter; use League\Flysystem\UnixVisibility\PortableVisibilityConverter; use League\Flysystem\Visibility; /** * @mixin \Illuminate\Contracts\Filesystem\Filesystem + * @mixin \Illuminate\Filesystem\FilesystemAdapter */ class FilesystemManager implements FactoryContract { @@ -263,7 +266,27 @@ protected function formatS3Config(array $config) $config['credentials'] = Arr::only($config, ['key', 'secret', 'token']); } - return $config; + return Arr::except($config, ['token']); + } + + /** + * Create a scoped driver. + * + * @param array $config + * @return \Illuminate\Contracts\Filesystem\Filesystem + */ + public function createScopedDriver(array $config) + { + if (empty($config['disk'])) { + throw new InvalidArgumentException('Scoped disk is missing "disk" configuration option.'); + } elseif (empty($config['prefix'])) { + throw new InvalidArgumentException('Scoped disk is missing "prefix" configuration option.'); + } + + return $this->build(tap( + $this->getConfig($config['disk']), + fn (&$parent) => $parent['prefix'] = $config['prefix'] + )); } /** @@ -275,6 +298,14 @@ protected function formatS3Config(array $config) */ protected function createFlysystem(FlysystemAdapter $adapter, array $config) { + if ($config['read-only'] ?? false === true) { + $adapter = new ReadOnlyFilesystemAdapter($adapter); + } + + if (! empty($config['prefix'])) { + $adapter = new PathPrefixedAdapter($adapter, $config['prefix']); + } + return new Flysystem($adapter, Arr::only($config, [ 'directory_visibility', 'disable_asserts', diff --git a/src/Illuminate/Filesystem/LockableFile.php b/src/Illuminate/Filesystem/LockableFile.php index 58bd934f3234..8b2de765eaad 100644 --- a/src/Illuminate/Filesystem/LockableFile.php +++ b/src/Illuminate/Filesystem/LockableFile.php @@ -101,7 +101,7 @@ public function size() * Write to the file. * * @param string $contents - * @return string + * @return $this */ public function write($contents) { diff --git a/src/Illuminate/Filesystem/composer.json b/src/Illuminate/Filesystem/composer.json index 13d52d8d4dd9..dc5b92acc52a 100644 --- a/src/Illuminate/Filesystem/composer.json +++ b/src/Illuminate/Filesystem/composer.json @@ -32,7 +32,9 @@ } }, "suggest": { + "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-hash": "Required to use the Filesystem class.", "illuminate/http": "Required for handling uploaded files (^7.0).", "league/flysystem": "Required to use the Flysystem local driver (^3.0.16).", "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.0).", diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index fde8e91af68d..8c28974ab5ea 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -21,6 +21,7 @@ use Illuminate\Support\Env; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; +use Illuminate\Support\Traits\Macroable; use RuntimeException; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -30,12 +31,14 @@ class Application extends Container implements ApplicationContract, CachesConfiguration, CachesRoutes, HttpKernelInterface { + use Macroable; + /** * The Laravel framework version. * * @var string */ - const VERSION = '9.19.0'; + const VERSION = '9.52.20'; /** * The base path for the Laravel installation. @@ -178,6 +181,7 @@ public function __construct($basePath = null) $this->registerBaseBindings(); $this->registerBaseServiceProviders(); $this->registerCoreContainerAliases(); + $this->registerLaravelCloudServices(); } /** @@ -223,6 +227,28 @@ protected function registerBaseServiceProviders() $this->register(new RoutingServiceProvider($this)); } + /** + * Register any services needed for Laravel Cloud. + * + * @return void + */ + protected function registerLaravelCloudServices() + { + if (! laravel_cloud()) { + return; + } + + $this['events']->listen( + 'bootstrapping: *', + fn ($bootstrapper) => Cloud::bootstrapperBootstrapping($this, Str::after($bootstrapper, 'bootstrapping: ')) + ); + + $this['events']->listen( + 'bootstrapped: *', + fn ($bootstrapper) => Cloud::bootstrapperBootstrapped($this, Str::after($bootstrapper, 'bootstrapped: ')) + ); + } + /** * Run the given array of bootstrap classes. * @@ -564,7 +590,7 @@ public function environmentFilePath() /** * Get or check the current application environment. * - * @param string|array $environments + * @param string|array ...$environments * @return string|bool */ public function environment(...$environments) @@ -606,7 +632,9 @@ public function isProduction() */ public function detectEnvironment(Closure $callback) { - $args = $_SERVER['argv'] ?? null; + $args = $this->runningInConsole() && isset($_SERVER['argv']) + ? $_SERVER['argv'] + : null; return $this['env'] = (new EnvironmentDetector)->detect($callback, $args); } @@ -653,9 +681,7 @@ public function hasDebugModeEnabled() public function registerConfiguredProviders() { $providers = Collection::make($this->make('config')->get('app.providers')) - ->partition(function ($provider) { - return str_starts_with($provider, 'Illuminate\\'); - }); + ->partition(fn ($provider) => str_starts_with($provider, 'Illuminate\\')); $providers->splice(1, 0, [$this->make(PackageManifest::class)->providers()]); @@ -696,6 +722,8 @@ public function register($provider, $force = false) if (property_exists($provider, 'singletons')) { foreach ($provider->singletons as $key => $value) { + $key = is_int($key) ? $value : $key; + $this->singleton($key, $value); } } @@ -733,9 +761,7 @@ public function getProviders($provider) { $name = is_string($provider) ? $provider : get_class($provider); - return Arr::where($this->serviceProviders, function ($value) use ($name) { - return $value instanceof $name; - }); + return Arr::where($this->serviceProviders, fn ($value) => $value instanceof $name); } /** diff --git a/src/Illuminate/Foundation/Auth/EmailVerificationRequest.php b/src/Illuminate/Foundation/Auth/EmailVerificationRequest.php index c9c43046ed2c..9cbe9b511827 100644 --- a/src/Illuminate/Foundation/Auth/EmailVerificationRequest.php +++ b/src/Illuminate/Foundation/Auth/EmailVerificationRequest.php @@ -14,13 +14,11 @@ class EmailVerificationRequest extends FormRequest */ public function authorize() { - if (! hash_equals((string) $this->route('id'), - (string) $this->user()->getKey())) { + if (! hash_equals((string) $this->user()->getKey(), (string) $this->route('id'))) { return false; } - if (! hash_equals((string) $this->route('hash'), - sha1($this->user()->getEmailForVerification()))) { + if (! hash_equals(sha1($this->user()->getEmailForVerification()), (string) $this->route('hash'))) { return false; } diff --git a/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php b/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php index 8667f39b23e4..393be5c17866 100644 --- a/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php +++ b/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php @@ -17,7 +17,7 @@ class HandleExceptions /** * Reserved memory so that errors can be displayed properly on memory exhaustion. * - * @var string + * @var string|null */ public static $reservedMemory; @@ -68,10 +68,8 @@ public function bootstrap(Application $app) public function handleError($level, $message, $file = '', $line = 0, $context = []) { if ($this->isDeprecation($level)) { - return $this->handleDeprecationError($message, $file, $line, $level); - } - - if (error_reporting() & $level) { + $this->handleDeprecationError($message, $file, $line, $level); + } elseif (error_reporting() & $level) { throw new ErrorException($message, 0, $level, $file, $line); } } @@ -102,10 +100,7 @@ public function handleDeprecation($message, $file, $line) */ public function handleDeprecationError($message, $file, $line, $level = E_DEPRECATED) { - if (! class_exists(LogManager::class) - || ! static::$app->hasBeenBootstrapped() - || static::$app->runningUnitTests() - ) { + if ($this->shouldIgnoreDeprecationErrors()) { return; } @@ -130,6 +125,18 @@ public function handleDeprecationError($message, $file, $line, $level = E_DEPREC }); } + /** + * Determine if deprecation errors should be ignored. + * + * @return bool + */ + protected function shouldIgnoreDeprecationErrors() + { + return ! class_exists(LogManager::class) + || ! static::$app->hasBeenBootstrapped() + || static::$app->runningUnitTests(); + } + /** * Ensure the "deprecations" logger is configured. * @@ -144,9 +151,11 @@ protected function ensureDeprecationLoggerIsConfigured() $this->ensureNullLogDriverIsConfigured(); - $options = $config->get('logging.deprecations'); - - $driver = is_array($options) ? $options['channel'] : ($options ?? 'null'); + if (is_array($options = $config->get('logging.deprecations'))) { + $driver = $options['channel'] ?? 'null'; + } else { + $driver = $options ?? 'null'; + } $config->set('logging.channels.deprecations', $config->get("logging.channels.{$driver}")); }); @@ -188,11 +197,15 @@ public function handleException(Throwable $e) try { $this->getExceptionHandler()->report($e); } catch (Exception $e) { - // + $exceptionHandlerFailed = true; } if (static::$app->runningInConsole()) { $this->renderForConsole($e); + + if ($exceptionHandlerFailed ?? false) { + exit(1); + } } else { $this->renderHttpResponse($e); } diff --git a/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php b/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php index 501a1eea45f5..ae3f73881e5d 100644 --- a/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php +++ b/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php @@ -42,9 +42,7 @@ public function bootstrap(Application $app) // Finally, we will set the application's environment based on the configuration // values that were loaded. We will pass a callback which will be used to get // the environment in a web context where an "--env" switch is not present. - $app->detectEnvironment(function () use ($config) { - return $config->get('app.env', 'production'); - }); + $app->detectEnvironment(fn () => $config->get('app.env', 'production')); date_default_timezone_set($config->get('app.timezone', 'UTC')); diff --git a/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php b/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php index 64f32ebf41b6..3f0be6c0a605 100644 --- a/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php +++ b/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php @@ -103,6 +103,8 @@ protected function writeErrorAndDie(InvalidFileException $e) $output->writeln('The environment file is invalid!'); $output->writeln($e->getMessage()); + http_response_code(500); + exit(1); } } diff --git a/src/Illuminate/Foundation/Bus/Dispatchable.php b/src/Illuminate/Foundation/Bus/Dispatchable.php index 3e90e412026d..ad471bf87fec 100644 --- a/src/Illuminate/Foundation/Bus/Dispatchable.php +++ b/src/Illuminate/Foundation/Bus/Dispatchable.php @@ -2,6 +2,7 @@ namespace Illuminate\Foundation\Bus; +use Closure; use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Support\Fluent; @@ -10,6 +11,7 @@ trait Dispatchable /** * Dispatch the job with the given arguments. * + * @param mixed ...$arguments * @return \Illuminate\Foundation\Bus\PendingDispatch */ public static function dispatch(...$arguments) @@ -20,13 +22,21 @@ public static function dispatch(...$arguments) /** * Dispatch the job with the given arguments if the given truth test passes. * - * @param bool $boolean + * @param bool|\Closure $boolean * @param mixed ...$arguments * @return \Illuminate\Foundation\Bus\PendingDispatch|\Illuminate\Support\Fluent */ public static function dispatchIf($boolean, ...$arguments) { - return $boolean + if ($boolean instanceof Closure) { + $dispatchable = new static(...$arguments); + + return value($boolean, $dispatchable) + ? new PendingDispatch($dispatchable) + : new Fluent; + } + + return value($boolean) ? new PendingDispatch(new static(...$arguments)) : new Fluent; } @@ -34,13 +44,21 @@ public static function dispatchIf($boolean, ...$arguments) /** * Dispatch the job with the given arguments unless the given truth test passes. * - * @param bool $boolean + * @param bool|\Closure $boolean * @param mixed ...$arguments * @return \Illuminate\Foundation\Bus\PendingDispatch|\Illuminate\Support\Fluent */ public static function dispatchUnless($boolean, ...$arguments) { - return ! $boolean + if ($boolean instanceof Closure) { + $dispatchable = new static(...$arguments); + + return ! value($boolean, $dispatchable) + ? new PendingDispatch($dispatchable) + : new Fluent; + } + + return ! value($boolean) ? new PendingDispatch(new static(...$arguments)) : new Fluent; } @@ -50,6 +68,7 @@ public static function dispatchUnless($boolean, ...$arguments) * * Queueable jobs will be dispatched to the "sync" queue. * + * @param mixed ...$arguments * @return mixed */ public static function dispatchSync(...$arguments) @@ -72,11 +91,12 @@ public static function dispatchNow(...$arguments) /** * Dispatch a command to its appropriate handler after the current process. * + * @param mixed ...$arguments * @return mixed */ public static function dispatchAfterResponse(...$arguments) { - return app(Dispatcher::class)->dispatchAfterResponse(new static(...$arguments)); + return self::dispatch(...$arguments)->afterResponse(); } /** diff --git a/src/Illuminate/Foundation/Cloud.php b/src/Illuminate/Foundation/Cloud.php new file mode 100644 index 000000000000..046025fabe22 --- /dev/null +++ b/src/Illuminate/Foundation/Cloud.php @@ -0,0 +1,139 @@ + function () use ($app) { + static::configureDisks($app); + static::configureUnpooledPostgresConnection($app); + static::ensureMigrationsUseUnpooledConnection($app); + }, + HandleExceptions::class => function () use ($app) { + static::configureCloudLogging($app); + }, + default => fn () => true, + })(); + } + + /** + * Configure the Laravel Cloud disks if applicable. + */ + public static function configureDisks(Application $app): void + { + if (! isset($_SERVER['LARAVEL_CLOUD_DISK_CONFIG'])) { + return; + } + + $disks = json_decode($_SERVER['LARAVEL_CLOUD_DISK_CONFIG'], true); + + foreach ($disks as $disk) { + $app['config']->set('filesystems.disks.'.$disk['disk'], [ + 'driver' => 's3', + 'key' => $disk['access_key_id'], + 'secret' => $disk['access_key_secret'], + 'bucket' => $disk['bucket'], + 'url' => $disk['url'], + 'endpoint' => $disk['endpoint'], + 'region' => 'auto', + 'use_path_style_endpoint' => false, + 'throw' => false, + 'report' => false, + ]); + + if ($disk['is_default'] ?? false) { + $app['config']->set('filesystems.default', $disk['disk']); + } + } + } + + /** + * Configure the unpooled Laravel Postgres connection if applicable. + */ + public static function configureUnpooledPostgresConnection(Application $app): void + { + $host = $app['config']->get('database.connections.pgsql.host', ''); + + if (str_contains($host, 'pg.laravel.cloud') && + str_contains($host, '-pooler')) { + $app['config']->set( + 'database.connections.pgsql-unpooled', + array_merge($app['config']->get('database.connections.pgsql'), [ + 'host' => str_replace('-pooler', '', $host), + ]) + ); + + $app['config']->set( + 'database.connections.pgsql.options', + array_merge( + $app['config']->get('database.connections.pgsql.options', []), + [PDO::ATTR_EMULATE_PREPARES => true], + ), + ); + } + } + + /** + * Ensure that migrations use the unpooled Postgres connection if applicable. + */ + public static function ensureMigrationsUseUnpooledConnection(Application $app): void + { + if (! is_array($app['config']->get('database.connections.pgsql-unpooled'))) { + return; + } + + Migrator::resolveConnectionsUsing(function ($resolver, $connection) use ($app) { + $connection = $connection ?? $app['config']->get('database.default'); + + return $resolver->connection( + $connection === 'pgsql' ? 'pgsql-unpooled' : $connection + ); + }); + } + + /** + * Configure the Laravel Cloud log channels. + */ + public static function configureCloudLogging(Application $app): void + { + $app['config']->set('logging.channels.stderr.formatter_with', [ + 'includeStacktraces' => true, + ]); + + $app['config']->set('logging.channels.laravel-cloud-socket', [ + 'driver' => 'monolog', + 'handler' => SocketHandler::class, + 'formatter' => JsonFormatter::class, + 'formatter_with' => [ + 'includeStacktraces' => true, + ], + 'with' => [ + 'connectionString' => $_ENV['LARAVEL_CLOUD_LOG_SOCKET'] ?? + $_SERVER['LARAVEL_CLOUD_LOG_SOCKET'] ?? + 'unix:///tmp/cloud-init.sock', + 'persistent' => true, + ], + ]); + } +} diff --git a/src/Illuminate/Foundation/Concerns/ResolvesDumpSource.php b/src/Illuminate/Foundation/Concerns/ResolvesDumpSource.php new file mode 100644 index 000000000000..7e0dfffa7556 --- /dev/null +++ b/src/Illuminate/Foundation/Concerns/ResolvesDumpSource.php @@ -0,0 +1,197 @@ + + */ + protected $editorHrefs = [ + 'atom' => 'atom://core/open/file?filename={file}&line={line}', + 'emacs' => 'emacs://open?url=file://{file}&line={line}', + 'idea' => 'idea://open?file={file}&line={line}', + 'macvim' => 'mvim://open/?url=file://{file}&line={line}', + 'netbeans' => 'netbeans://open/?f={file}:{line}', + 'nova' => 'nova://core/open/file?filename={file}&line={line}', + 'phpstorm' => 'phpstorm://open?file={file}&line={line}', + 'sublime' => 'subl://open?url=file://{file}&line={line}', + 'textmate' => 'txmt://open?url=file://{file}&line={line}', + 'vscode' => 'vscode://file/{file}:{line}', + 'vscode-insiders' => 'vscode-insiders://file/{file}:{line}', + 'vscode-insiders-remote' => 'vscode-insiders://vscode-remote/{file}:{line}', + 'vscode-remote' => 'vscode://vscode-remote/{file}:{line}', + 'vscodium' => 'vscodium://file/{file}:{line}', + 'xdebug' => 'xdebug://{file}@{line}', + ]; + + /** + * Files that require special trace handling and their levels. + * + * @var array + */ + protected static $adjustableTraces = [ + 'symfony/var-dumper/Resources/functions/dump.php' => 1, + 'Illuminate/Collections/Traits/EnumeratesValues.php' => 4, + ]; + + /** + * The source resolver. + * + * @var (callable(): (array{0: string, 1: string, 2: int|null}|null))|null|false + */ + protected static $dumpSourceResolver; + + /** + * Resolve the source of the dump call. + * + * @return array{0: string, 1: string, 2: int|null}|null + */ + public function resolveDumpSource() + { + if (static::$dumpSourceResolver === false) { + return null; + } + + if (static::$dumpSourceResolver) { + return call_user_func(static::$dumpSourceResolver); + } + + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 20); + + $sourceKey = null; + + foreach ($trace as $traceKey => $traceFile) { + if (! isset($traceFile['file'])) { + continue; + } + + foreach (self::$adjustableTraces as $name => $key) { + if (str_ends_with( + $traceFile['file'], + str_replace('/', DIRECTORY_SEPARATOR, $name) + )) { + $sourceKey = $traceKey + $key; + break; + } + } + + if (! is_null($sourceKey)) { + break; + } + } + + if (is_null($sourceKey)) { + return; + } + + $file = $trace[$sourceKey]['file'] ?? null; + $line = $trace[$sourceKey]['line'] ?? null; + + if (is_null($file) || is_null($line)) { + return; + } + + $relativeFile = $file; + + if ($this->isCompiledViewFile($file)) { + $file = $this->getOriginalFileForCompiledView($file); + $line = null; + } + + if (str_starts_with($file, $this->basePath)) { + $relativeFile = substr($file, strlen($this->basePath) + 1); + } + + return [$file, $relativeFile, $line]; + } + + /** + * Determine if the given file is a view compiled. + * + * @param string $file + * @return bool + */ + protected function isCompiledViewFile($file) + { + return str_starts_with($file, $this->compiledViewPath); + } + + /** + * Get the original view compiled file by the given compiled file. + * + * @param string $file + * @return string + */ + protected function getOriginalFileForCompiledView($file) + { + preg_match('/\/\*\*PATH\s(.*)\sENDPATH/', file_get_contents($file), $matches); + + if (isset($matches[1])) { + $file = $matches[1]; + } + + return $file; + } + + /** + * Resolve the source href, if possible. + * + * @param string $file + * @param int|null $line + * @return string|null + */ + protected function resolveSourceHref($file, $line) + { + try { + $editor = config('app.editor'); + } catch (Throwable $e) { + // .. + } + + if (! isset($editor)) { + return; + } + + $href = is_array($editor) && isset($editor['href']) + ? $editor['href'] + : ($this->editorHrefs[$editor['name'] ?? $editor] ?? sprintf('%s://open?file={file}&line={line}', $editor['name'] ?? $editor)); + + if ($basePath = $editor['base_path'] ?? false) { + $file = str_replace($this->basePath, $basePath, $file); + } + + $href = str_replace( + ['{file}', '{line}'], + [$file, is_null($line) ? 1 : $line], + $href, + ); + + return $href; + } + + /** + * Set the resolver that resolves the source of the dump call. + * + * @param (callable(): (array{0: string, 1: string, 2: int|null}|null))|null $callable + * @return void + */ + public static function resolveDumpSourceUsing($callable) + { + static::$dumpSourceResolver = $callable; + } + + /** + * Don't include the location / file of the dump in dumps. + * + * @return void + */ + public static function dontIncludeSource() + { + static::$dumpSourceResolver = false; + } +} diff --git a/src/Illuminate/Foundation/Console/AboutCommand.php b/src/Illuminate/Foundation/Console/AboutCommand.php new file mode 100644 index 000000000000..c0d37e9df646 --- /dev/null +++ b/src/Illuminate/Foundation/Console/AboutCommand.php @@ -0,0 +1,266 @@ +composer = $composer; + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + $this->gatherApplicationInformation(); + + collect(static::$data) + ->map(fn ($items) => collect($items) + ->map(function ($value) { + if (is_array($value)) { + return [$value]; + } + + if (is_string($value)) { + $value = $this->laravel->make($value); + } + + return collect($this->laravel->call($value)) + ->map(fn ($value, $key) => [$key, $value]) + ->values() + ->all(); + })->flatten(1) + ) + ->sortBy(function ($data, $key) { + $index = array_search($key, ['Environment', 'Cache', 'Drivers']); + + return $index === false ? 99 : $index; + }) + ->filter(function ($data, $key) { + return $this->option('only') ? in_array(Str::of($key)->lower()->snake(), $this->sections()) : true; + }) + ->pipe(fn ($data) => $this->display($data)); + + $this->newLine(); + + return 0; + } + + /** + * Display the application information. + * + * @param \Illuminate\Support\Collection $data + * @return void + */ + protected function display($data) + { + $this->option('json') ? $this->displayJson($data) : $this->displayDetail($data); + } + + /** + * Display the application information as a detail view. + * + * @param \Illuminate\Support\Collection $data + * @return void + */ + protected function displayDetail($data) + { + $data->each(function ($data, $section) { + $this->newLine(); + + $this->components->twoColumnDetail(' '.$section.''); + + $data->pipe(fn ($data) => $section !== 'Environment' ? $data->sort() : $data)->each(function ($detail) { + [$label, $value] = $detail; + + $this->components->twoColumnDetail($label, value($value)); + }); + }); + } + + /** + * Display the application information as JSON. + * + * @param \Illuminate\Support\Collection $data + * @return void + */ + protected function displayJson($data) + { + $output = $data->flatMap(function ($data, $section) { + return [(string) Str::of($section)->snake() => $data->mapWithKeys(fn ($item, $key) => [(string) Str::of($item[0])->lower()->snake() => value($item[1])])]; + }); + + $this->output->writeln(strip_tags(json_encode($output))); + } + + /** + * Gather information about the application. + * + * @return void + */ + protected function gatherApplicationInformation() + { + static::addToSection('Environment', fn () => [ + 'Application Name' => config('app.name'), + 'Laravel Version' => $this->laravel->version(), + 'PHP Version' => phpversion(), + 'Composer Version' => $this->composer->getVersion() ?? '-', + 'Environment' => $this->laravel->environment(), + 'Debug Mode' => config('app.debug') ? 'ENABLED' : 'OFF', + 'URL' => Str::of(config('app.url'))->replace(['http://', 'https://'], ''), + 'Maintenance Mode' => $this->laravel->isDownForMaintenance() ? 'ENABLED' : 'OFF', + ]); + + static::addToSection('Cache', fn () => [ + 'Config' => $this->laravel->configurationIsCached() ? 'CACHED' : 'NOT CACHED', + 'Events' => $this->laravel->eventsAreCached() ? 'CACHED' : 'NOT CACHED', + 'Routes' => $this->laravel->routesAreCached() ? 'CACHED' : 'NOT CACHED', + 'Views' => $this->hasPhpFiles($this->laravel->storagePath('framework/views')) ? 'CACHED' : 'NOT CACHED', + ]); + + $logChannel = config('logging.default'); + + if (config('logging.channels.'.$logChannel.'.driver') === 'stack') { + $secondary = collect(config('logging.channels.'.$logChannel.'.channels')) + ->implode(', '); + + $logs = ''.$logChannel.' / '.$secondary; + } else { + $logs = $logChannel; + } + + static::addToSection('Drivers', fn () => array_filter([ + 'Broadcasting' => config('broadcasting.default'), + 'Cache' => config('cache.default'), + 'Database' => config('database.default'), + 'Logs' => $logs, + 'Mail' => config('mail.default'), + 'Octane' => config('octane.server'), + 'Queue' => config('queue.default'), + 'Scout' => config('scout.driver'), + 'Session' => config('session.driver'), + ])); + + collect(static::$customDataResolvers)->each->__invoke(); + } + + /** + * Determine whether the given directory has PHP files. + * + * @param string $path + * @return bool + */ + protected function hasPhpFiles(string $path): bool + { + return count(glob($path.'/*.php')) > 0; + } + + /** + * Add additional data to the output of the "about" command. + * + * @param string $section + * @param callable|string|array $data + * @param string|null $value + * @return void + */ + public static function add(string $section, $data, ?string $value = null) + { + static::$customDataResolvers[] = fn () => static::addToSection($section, $data, $value); + } + + /** + * Add additional data to the output of the "about" command. + * + * @param string $section + * @param callable|string|array $data + * @param string|null $value + * @return void + */ + protected static function addToSection(string $section, $data, ?string $value = null) + { + if (is_array($data)) { + foreach ($data as $key => $value) { + self::$data[$section][] = [$key, $value]; + } + } elseif (is_callable($data) || ($value === null && class_exists($data))) { + self::$data[$section][] = $data; + } else { + self::$data[$section][] = [$data, $value]; + } + } + + /** + * Get the sections provided to the command. + * + * @return array + */ + protected function sections() + { + return array_filter(explode(',', $this->option('only') ?? '')); + } +} diff --git a/src/Illuminate/Foundation/Console/CastMakeCommand.php b/src/Illuminate/Foundation/Console/CastMakeCommand.php index d5b5ad69ed26..2704360611d7 100644 --- a/src/Illuminate/Foundation/Console/CastMakeCommand.php +++ b/src/Illuminate/Foundation/Console/CastMakeCommand.php @@ -85,7 +85,8 @@ protected function getDefaultNamespace($rootNamespace) protected function getOptions() { return [ - ['inbound', null, InputOption::VALUE_OPTIONAL, 'Generate an inbound cast class'], + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the cast already exists'], + ['inbound', null, InputOption::VALUE_NONE, 'Generate an inbound cast class'], ]; } } diff --git a/src/Illuminate/Foundation/Console/ChannelMakeCommand.php b/src/Illuminate/Foundation/Console/ChannelMakeCommand.php index e847585257ee..1bc26e85da95 100644 --- a/src/Illuminate/Foundation/Console/ChannelMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ChannelMakeCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:channel')] class ChannelMakeCommand extends GeneratorCommand @@ -75,4 +76,16 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Broadcasting'; } + + /** + * Get the console command arguments. + * + * @return array + */ + protected function getOptions() + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the channel already exists'], + ]; + } } diff --git a/src/Illuminate/Foundation/Console/ClearCompiledCommand.php b/src/Illuminate/Foundation/Console/ClearCompiledCommand.php index ec355e859d09..20375ded17a6 100644 --- a/src/Illuminate/Foundation/Console/ClearCompiledCommand.php +++ b/src/Illuminate/Foundation/Console/ClearCompiledCommand.php @@ -48,6 +48,6 @@ public function handle() @unlink($packagesPath); } - $this->info('Compiled services and packages files removed successfully.'); + $this->components->info('Compiled services and packages files removed successfully.'); } } diff --git a/src/Illuminate/Foundation/Console/CliDumper.php b/src/Illuminate/Foundation/Console/CliDumper.php new file mode 100644 index 000000000000..beed2f2af9f4 --- /dev/null +++ b/src/Illuminate/Foundation/Console/CliDumper.php @@ -0,0 +1,134 @@ +basePath = $basePath; + $this->output = $output; + $this->compiledViewPath = $compiledViewPath; + } + + /** + * Create a new CLI dumper instance and register it as the default dumper. + * + * @param string $basePath + * @param string $compiledViewPath + * @return void + */ + public static function register($basePath, $compiledViewPath) + { + $cloner = tap(new VarCloner())->addCasters(ReflectionCaster::UNSET_CLOSURE_FILE_INFO); + + $dumper = new static(new ConsoleOutput(), $basePath, $compiledViewPath); + + VarDumper::setHandler(fn ($value) => $dumper->dumpWithSource($cloner->cloneVar($value))); + } + + /** + * Dump a variable with its source file / line. + * + * @param \Symfony\Component\VarDumper\Cloner\Data $data + * @return void + */ + public function dumpWithSource(Data $data) + { + if ($this->dumping) { + $this->dump($data); + + return; + } + + $this->dumping = true; + + $output = (string) $this->dump($data, true); + $lines = explode("\n", $output); + + $lines[0] .= $this->getDumpSourceContent(); + + $this->output->write(implode("\n", $lines)); + + $this->dumping = false; + } + + /** + * Get the dump's source console content. + * + * @return string + */ + protected function getDumpSourceContent() + { + if (is_null($dumpSource = $this->resolveDumpSource())) { + return ''; + } + + [$file, $relativeFile, $line] = $dumpSource; + + $href = $this->resolveSourceHref($file, $line); + + return sprintf( + ' // %s%s', + is_null($href) ? '' : ";href=$href", + $relativeFile, + is_null($line) ? '' : ":$line" + ); + } + + /** + * {@inheritDoc} + */ + protected function supportsColors(): bool + { + return $this->output->isDecorated(); + } +} diff --git a/src/Illuminate/Foundation/Console/ComponentMakeCommand.php b/src/Illuminate/Foundation/Console/ComponentMakeCommand.php index 00bb517d1eeb..e11838c2fb40 100644 --- a/src/Illuminate/Foundation/Console/ComponentMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ComponentMakeCommand.php @@ -52,7 +52,7 @@ public function handle() { if ($this->option('view')) { $this->writeView(function () { - $this->info($this->type.' created successfully.'); + $this->components->info($this->type.' created successfully.'); }); return; @@ -84,7 +84,7 @@ protected function writeView($onSuccess = null) } if ($this->files->exists($path) && ! $this->option('force')) { - $this->error('View already exists!'); + $this->components->error('View already exists.'); return; } @@ -92,7 +92,7 @@ protected function writeView($onSuccess = null) file_put_contents( $path, '
- +
' ); @@ -112,7 +112,7 @@ protected function buildClass($name) if ($this->option('inline')) { return str_replace( ['DummyView', '{{ view }}'], - "<<<'blade'\n
\n \n
\nblade", + "<<<'blade'\n
\n \n
\nblade", parent::buildClass($name) ); } @@ -182,7 +182,7 @@ protected function getDefaultNamespace($rootNamespace) protected function getOptions() { return [ - ['force', null, InputOption::VALUE_NONE, 'Create the class even if the component already exists'], + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the component already exists'], ['inline', null, InputOption::VALUE_NONE, 'Create a component that renders an inline view'], ['view', null, InputOption::VALUE_NONE, 'Create an anonymous component with only a view'], ]; diff --git a/src/Illuminate/Foundation/Console/ConfigCacheCommand.php b/src/Illuminate/Foundation/Console/ConfigCacheCommand.php index 17545b41448e..18eec46c0f5c 100644 --- a/src/Illuminate/Foundation/Console/ConfigCacheCommand.php +++ b/src/Illuminate/Foundation/Console/ConfigCacheCommand.php @@ -66,7 +66,7 @@ public function __construct(Filesystem $files) */ public function handle() { - $this->call('config:clear'); + $this->callSilent('config:clear'); $config = $this->getFreshConfiguration(); @@ -84,7 +84,7 @@ public function handle() throw new LogicException('Your configuration files are not serializable.', 0, $e); } - $this->info('Configuration cached successfully.'); + $this->components->info('Configuration cached successfully.'); } /** diff --git a/src/Illuminate/Foundation/Console/ConfigClearCommand.php b/src/Illuminate/Foundation/Console/ConfigClearCommand.php index cb24bac671c8..3e07e9be9a4a 100644 --- a/src/Illuminate/Foundation/Console/ConfigClearCommand.php +++ b/src/Illuminate/Foundation/Console/ConfigClearCommand.php @@ -63,6 +63,6 @@ public function handle() { $this->files->delete($this->laravel->getCachedConfigPath()); - $this->info('Configuration cache cleared successfully.'); + $this->components->info('Configuration cache cleared successfully.'); } } diff --git a/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php b/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php index 600db5aba5ee..18f6dd1b7d94 100644 --- a/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php @@ -104,6 +104,7 @@ protected function getArguments() protected function getOptions() { return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the console command already exists'], ['command', null, InputOption::VALUE_OPTIONAL, 'The terminal command that should be assigned', 'command:name'], ]; } diff --git a/src/Illuminate/Foundation/Console/DocsCommand.php b/src/Illuminate/Foundation/Console/DocsCommand.php new file mode 100644 index 000000000000..c9651acd8f0e --- /dev/null +++ b/src/Illuminate/Foundation/Console/DocsCommand.php @@ -0,0 +1,536 @@ +php artisan docs -- search query here'; + + /** + * The HTTP client instance. + * + * @var \Illuminate\Http\Client\Factory + */ + protected $http; + + /** + * The cache repository implementation. + * + * @var \Illuminate\Contracts\Cache\Repository + */ + protected $cache; + + /** + * The custom URL opener. + * + * @var callable|null + */ + protected $urlOpener; + + /** + * The custom documentation version to open. + * + * @var string|null + */ + protected $version; + + /** + * The operating system family. + * + * @var string + */ + protected $systemOsFamily = PHP_OS_FAMILY; + + /** + * Configure the current command. + * + * @return void + */ + protected function configure() + { + parent::configure(); + + if ($this->isSearching()) { + $this->ignoreValidationErrors(); + } + } + + /** + * Execute the console command. + * + * @param \Illuminate\Http\Client\Factory $http + * @param \Illuminate\Contracts\Cache\Repository $cache + * @return int + */ + public function handle(Http $http, Cache $cache) + { + $this->http = $http; + $this->cache = $cache; + + try { + $this->openUrl(); + } catch (ProcessFailedException $e) { + if ($e->getProcess()->getExitCodeText() === 'Interrupt') { + return $e->getProcess()->getExitCode(); + } + + throw $e; + } + + $this->refreshDocs(); + + return Command::SUCCESS; + } + + /** + * Open the documentation URL. + * + * @return void + */ + protected function openUrl() + { + with($this->url(), function ($url) { + $this->components->info("Opening the docs to: {$url}"); + + $this->open($url); + }); + } + + /** + * The URL to the documentation page. + * + * @return string + */ + protected function url() + { + if ($this->isSearching()) { + return "https://laravel.com/docs/{$this->version()}?".Arr::query([ + 'q' => $this->searchQuery(), + ]); + } + + return with($this->page(), function ($page) { + return trim("https://laravel.com/docs/{$this->version()}/{$page}#{$this->section($page)}", '#/'); + }); + } + + /** + * The page the user is opening. + * + * @return string + */ + protected function page() + { + return with($this->resolvePage(), function ($page) { + if ($page === null) { + $this->components->warn('Unable to determine the page you are trying to visit.'); + + return '/'; + } + + return $page; + }); + } + + /** + * Determine the page to open. + * + * @return string|null + */ + protected function resolvePage() + { + if ($this->option('no-interaction') && $this->didNotRequestPage()) { + return '/'; + } + + return $this->didNotRequestPage() + ? $this->askForPage() + : $this->guessPage(); + } + + /** + * Determine if the user requested a specific page when calling the command. + * + * @return bool + */ + protected function didNotRequestPage() + { + return $this->argument('page') === null; + } + + /** + * Ask the user which page they would like to open. + * + * @return string|null + */ + protected function askForPage() + { + return $this->askForPageViaCustomStrategy() ?? $this->askForPageViaAutocomplete(); + } + + /** + * Ask the user which page they would like to open via a custom strategy. + * + * @return string|null + */ + protected function askForPageViaCustomStrategy() + { + try { + $strategy = require Env::get('ARTISAN_DOCS_ASK_STRATEGY'); + } catch (Throwable $e) { + return null; + } + + if (! is_callable($strategy)) { + return null; + } + + return $strategy($this) ?? '/'; + } + + /** + * Ask the user which page they would like to open using autocomplete. + * + * @return string|null + */ + protected function askForPageViaAutocomplete() + { + $choice = $this->components->choice( + 'Which page would you like to open?', + $this->pages()->mapWithKeys(fn ($option) => [ + Str::lower($option['title']) => $option['title'], + ])->all(), + 'installation', + 3 + ); + + return $this->pages()->filter( + fn ($page) => $page['title'] === $choice || Str::lower($page['title']) === $choice + )->keys()->first() ?: null; + } + + /** + * Guess the page the user is attempting to open. + * + * @return string|null + */ + protected function guessPage() + { + return $this->pages() + ->filter(fn ($page) => str_starts_with( + Str::slug($page['title'], ' '), + Str::slug($this->argument('page'), ' ') + ))->keys()->first() ?? $this->pages()->map(fn ($page) => similar_text( + Str::slug($page['title'], ' '), + Str::slug($this->argument('page'), ' '), + )) + ->filter(fn ($score) => $score >= min(3, Str::length($this->argument('page')))) + ->sortDesc() + ->keys() + ->sortByDesc(fn ($slug) => Str::contains( + Str::slug($this->pages()[$slug]['title'], ' '), + Str::slug($this->argument('page'), ' ') + ) ? 1 : 0) + ->first(); + } + + /** + * The section the user specifically asked to open. + * + * @param string $page + * @return string|null + */ + protected function section($page) + { + return $this->didNotRequestSection() + ? null + : $this->guessSection($page); + } + + /** + * Determine if the user requested a specific section when calling the command. + * + * @return bool + */ + protected function didNotRequestSection() + { + return $this->argument('section') === null; + } + + /** + * Guess the section the user is attempting to open. + * + * @param string $page + * @return string|null + */ + protected function guessSection($page) + { + return $this->sectionsFor($page) + ->filter(fn ($section) => str_starts_with( + Str::slug($section['title'], ' '), + Str::slug($this->argument('section'), ' ') + ))->keys()->first() ?? $this->sectionsFor($page)->map(fn ($section) => similar_text( + Str::slug($section['title'], ' '), + Str::slug($this->argument('section'), ' '), + )) + ->filter(fn ($score) => $score >= min(3, Str::length($this->argument('section')))) + ->sortDesc() + ->keys() + ->sortByDesc(fn ($slug) => Str::contains( + Str::slug($this->sectionsFor($page)[$slug]['title'], ' '), + Str::slug($this->argument('section'), ' ') + ) ? 1 : 0) + ->first(); + } + + /** + * Open the URL in the user's browser. + * + * @param string $url + * @return void + */ + protected function open($url) + { + ($this->urlOpener ?? function ($url) { + if (Env::get('ARTISAN_DOCS_OPEN_STRATEGY')) { + $this->openViaCustomStrategy($url); + } elseif (in_array($this->systemOsFamily, ['Darwin', 'Windows', 'Linux'])) { + $this->openViaBuiltInStrategy($url); + } else { + $this->components->warn('Unable to open the URL on your system. You will need to open it yourself or create a custom opener for your system.'); + } + })($url); + } + + /** + * Open the URL via a custom strategy. + * + * @param string $url + * @return void + */ + protected function openViaCustomStrategy($url) + { + try { + $command = require Env::get('ARTISAN_DOCS_OPEN_STRATEGY'); + } catch (Throwable $e) { + $command = null; + } + + if (! is_callable($command)) { + $this->components->warn('Unable to open the URL with your custom strategy. You will need to open it yourself.'); + + return; + } + + $command($url); + } + + /** + * Open the URL via the built in strategy. + * + * @param string $url + * @return void + */ + protected function openViaBuiltInStrategy($url) + { + if ($this->systemOsFamily === 'Windows') { + $process = tap(Process::fromShellCommandline(escapeshellcmd("start {$url}")))->run(); + + if (! $process->isSuccessful()) { + throw new ProcessFailedException($process); + } + + return; + } + + $binary = Collection::make(match ($this->systemOsFamily) { + 'Darwin' => ['open'], + 'Linux' => ['xdg-open', 'wslview'], + })->first(fn ($binary) => (new ExecutableFinder)->find($binary) !== null); + + if ($binary === null) { + $this->components->warn('Unable to open the URL on your system. You will need to open it yourself or create a custom opener for your system.'); + + return; + } + + $process = tap(Process::fromShellCommandline(escapeshellcmd("{$binary} {$url}")))->run(); + + if (! $process->isSuccessful()) { + throw new ProcessFailedException($process); + } + } + + /** + * The available sections for the page. + * + * @param string $page + * @return \Illuminate\Support\Collection + */ + public function sectionsFor($page) + { + return new Collection($this->pages()[$page]['sections']); + } + + /** + * The pages available to open. + * + * @return \Illuminate\Support\Collection + */ + public function pages() + { + return new Collection($this->docs()['pages']); + } + + /** + * Get the documentation index as a collection. + * + * @return \Illuminate\Support\Collection + */ + public function docs() + { + return $this->cache->remember( + "artisan.docs.{{$this->version()}}.index", + CarbonInterval::months(2), + fn () => $this->fetchDocs()->throw()->collect() + ); + } + + /** + * Refresh the cached copy of the documentation index. + * + * @return void + */ + protected function refreshDocs() + { + with($this->fetchDocs(), function ($response) { + if ($response->successful()) { + $this->cache->put("artisan.docs.{{$this->version()}}.index", $response->collect(), CarbonInterval::months(2)); + } + }); + } + + /** + * Fetch the documentation index from the Laravel website. + * + * @return \Illuminate\Http\Client\Response + */ + protected function fetchDocs() + { + return $this->http->get("https://laravel.com/docs/{$this->version()}/index.json"); + } + + /** + * Determine the version of the docs to open. + * + * @return string + */ + protected function version() + { + return Str::before($this->version ?? $this->laravel->version(), '.').'.x'; + } + + /** + * The search query the user provided. + * + * @return string + */ + protected function searchQuery() + { + return Collection::make($_SERVER['argv'])->skip(3)->implode(' '); + } + + /** + * Determine if the command is intended to perform a search. + * + * @return bool + */ + protected function isSearching() + { + return ($_SERVER['argv'][2] ?? null) === '--'; + } + + /** + * Set the documentation version. + * + * @param string $version + * @return $this + */ + public function setVersion($version) + { + $this->version = $version; + + return $this; + } + + /** + * Set a custom URL opener. + * + * @param callable|null $opener + * @return $this + */ + public function setUrlOpener($opener) + { + $this->urlOpener = $opener; + + return $this; + } + + /** + * Set the system operating system family. + * + * @param string $family + * @return $this + */ + public function setSystemOsFamily($family) + { + $this->systemOsFamily = $family; + + return $this; + } +} diff --git a/src/Illuminate/Foundation/Console/DownCommand.php b/src/Illuminate/Foundation/Console/DownCommand.php index 3777ae6a6131..42aa6b6487c6 100644 --- a/src/Illuminate/Foundation/Console/DownCommand.php +++ b/src/Illuminate/Foundation/Console/DownCommand.php @@ -52,7 +52,7 @@ public function handle() { try { if ($this->laravel->maintenanceMode()->active()) { - $this->comment('Application is already down.'); + $this->components->info('Application is already down.'); return 0; } @@ -64,13 +64,14 @@ public function handle() file_get_contents(__DIR__.'/stubs/maintenance-mode.stub') ); - $this->laravel->get('events')->dispatch(MaintenanceModeEnabled::class); + $this->laravel->get('events')->dispatch(new MaintenanceModeEnabled()); - $this->comment('Application is now in maintenance mode.'); + $this->components->info('Application is now in maintenance mode.'); } catch (Exception $e) { - $this->error('Failed to enter maintenance mode.'); - - $this->error($e->getMessage()); + $this->components->error(sprintf( + 'Failed to enter maintenance mode: %s.', + $e->getMessage(), + )); return 1; } diff --git a/src/Illuminate/Foundation/Console/EnvironmentCommand.php b/src/Illuminate/Foundation/Console/EnvironmentCommand.php index 32e99ad8a74d..ba74ed987efc 100644 --- a/src/Illuminate/Foundation/Console/EnvironmentCommand.php +++ b/src/Illuminate/Foundation/Console/EnvironmentCommand.php @@ -40,6 +40,9 @@ class EnvironmentCommand extends Command */ public function handle() { - $this->line('Current application environment: '.$this->laravel['env'].''); + $this->components->info(sprintf( + 'The application environment is [%s].', + $this->laravel['env'], + )); } } diff --git a/src/Illuminate/Foundation/Console/EnvironmentDecryptCommand.php b/src/Illuminate/Foundation/Console/EnvironmentDecryptCommand.php new file mode 100644 index 000000000000..1e11e1100f6d --- /dev/null +++ b/src/Illuminate/Foundation/Console/EnvironmentDecryptCommand.php @@ -0,0 +1,159 @@ +files = $files; + } + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $key = $this->option('key') ?: Env::get('LARAVEL_ENV_ENCRYPTION_KEY'); + + if (! $key) { + $this->components->error('A decryption key is required.'); + + return Command::FAILURE; + } + + $cipher = $this->option('cipher') ?: 'AES-256-CBC'; + + $key = $this->parseKey($key); + + $encryptedFile = ($this->option('env') + ? base_path('.env').'.'.$this->option('env') + : $this->laravel->environmentFilePath()).'.encrypted'; + + $outputFile = $this->outputFilePath(); + + if (Str::endsWith($outputFile, '.encrypted')) { + $this->components->error('Invalid filename.'); + + return Command::FAILURE; + } + + if (! $this->files->exists($encryptedFile)) { + $this->components->error('Encrypted environment file not found.'); + + return Command::FAILURE; + } + + if ($this->files->exists($outputFile) && ! $this->option('force')) { + $this->components->error('Environment file already exists.'); + + return Command::FAILURE; + } + + try { + $encrypter = new Encrypter($key, $cipher); + + $this->files->put( + $outputFile, + $encrypter->decrypt($this->files->get($encryptedFile)) + ); + } catch (Exception $e) { + $this->components->error($e->getMessage()); + + return Command::FAILURE; + } + + $this->components->info('Environment successfully decrypted.'); + + $this->components->twoColumnDetail('Decrypted file', $outputFile); + + $this->newLine(); + } + + /** + * Parse the encryption key. + * + * @param string $key + * @return string + */ + protected function parseKey(string $key) + { + if (Str::startsWith($key, $prefix = 'base64:')) { + $key = base64_decode(Str::after($key, $prefix)); + } + + return $key; + } + + /** + * Get the output file path that should be used for the command. + * + * @return string + */ + protected function outputFilePath() + { + $path = Str::finish($this->option('path') ?: base_path(), DIRECTORY_SEPARATOR); + + $outputFile = $this->option('filename') ?: ('.env'.($this->option('env') ? '.'.$this->option('env') : '')); + $outputFile = ltrim($outputFile, DIRECTORY_SEPARATOR); + + return $path.$outputFile; + } +} diff --git a/src/Illuminate/Foundation/Console/EnvironmentEncryptCommand.php b/src/Illuminate/Foundation/Console/EnvironmentEncryptCommand.php new file mode 100644 index 000000000000..e0e9f12822e1 --- /dev/null +++ b/src/Illuminate/Foundation/Console/EnvironmentEncryptCommand.php @@ -0,0 +1,135 @@ +files = $files; + } + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $cipher = $this->option('cipher') ?: 'AES-256-CBC'; + + $key = $this->option('key'); + + $keyPassed = $key !== null; + + $environmentFile = $this->option('env') + ? base_path('.env').'.'.$this->option('env') + : $this->laravel->environmentFilePath(); + + $encryptedFile = $environmentFile.'.encrypted'; + + if (! $keyPassed) { + $key = Encrypter::generateKey($cipher); + } + + if (! $this->files->exists($environmentFile)) { + $this->components->error('Environment file not found.'); + + return Command::FAILURE; + } + + if ($this->files->exists($encryptedFile) && ! $this->option('force')) { + $this->components->error('Encrypted environment file already exists.'); + + return Command::FAILURE; + } + + try { + $encrypter = new Encrypter($this->parseKey($key), $cipher); + + $this->files->put( + $encryptedFile, + $encrypter->encrypt($this->files->get($environmentFile)) + ); + } catch (Exception $e) { + $this->components->error($e->getMessage()); + + return Command::FAILURE; + } + + $this->components->info('Environment successfully encrypted.'); + + $this->components->twoColumnDetail('Key', $keyPassed ? $key : 'base64:'.base64_encode($key)); + $this->components->twoColumnDetail('Cipher', $cipher); + $this->components->twoColumnDetail('Encrypted file', $encryptedFile); + + $this->newLine(); + } + + /** + * Parse the encryption key. + * + * @param string $key + * @return string + */ + protected function parseKey(string $key) + { + if (Str::startsWith($key, $prefix = 'base64:')) { + $key = base64_decode(Str::after($key, $prefix)); + } + + return $key; + } +} diff --git a/src/Illuminate/Foundation/Console/EventCacheCommand.php b/src/Illuminate/Foundation/Console/EventCacheCommand.php index 9590e5b57155..df42fbfd1d65 100644 --- a/src/Illuminate/Foundation/Console/EventCacheCommand.php +++ b/src/Illuminate/Foundation/Console/EventCacheCommand.php @@ -41,14 +41,14 @@ class EventCacheCommand extends Command */ public function handle() { - $this->call('event:clear'); + $this->callSilent('event:clear'); file_put_contents( $this->laravel->getCachedEventsPath(), 'getEvents(), true).';' ); - $this->info('Events cached successfully.'); + $this->components->info('Events cached successfully.'); } /** diff --git a/src/Illuminate/Foundation/Console/EventClearCommand.php b/src/Illuminate/Foundation/Console/EventClearCommand.php index 83de371b9362..a5c8ed1937bb 100644 --- a/src/Illuminate/Foundation/Console/EventClearCommand.php +++ b/src/Illuminate/Foundation/Console/EventClearCommand.php @@ -65,6 +65,6 @@ public function handle() { $this->files->delete($this->laravel->getCachedEventsPath()); - $this->info('Cached events cleared successfully.'); + $this->components->info('Cached events cleared successfully.'); } } diff --git a/src/Illuminate/Foundation/Console/EventGenerateCommand.php b/src/Illuminate/Foundation/Console/EventGenerateCommand.php index 307141f7d366..b27e9dbb0c82 100644 --- a/src/Illuminate/Foundation/Console/EventGenerateCommand.php +++ b/src/Illuminate/Foundation/Console/EventGenerateCommand.php @@ -49,7 +49,7 @@ public function handle() } } - $this->info('Events and listeners generated successfully.'); + $this->components->info('Events and listeners generated successfully.'); } /** diff --git a/src/Illuminate/Foundation/Console/EventListCommand.php b/src/Illuminate/Foundation/Console/EventListCommand.php index 8fb109bb1b54..1e0e11b877c8 100644 --- a/src/Illuminate/Foundation/Console/EventListCommand.php +++ b/src/Illuminate/Foundation/Console/EventListCommand.php @@ -54,17 +54,19 @@ public function handle() $events = $this->getEvents()->sortKeys(); if ($events->isEmpty()) { - $this->comment("Your application doesn't have any events matching the given criteria."); + $this->components->info("Your application doesn't have any events matching the given criteria."); return; } - $this->line( - $events->map(fn ($listeners, $event) => [ - sprintf(' %s', $this->appendEventInterfaces($event)), - collect($listeners)->map(fn ($listener) => sprintf(' ⇂ %s', $listener)), - ])->flatten()->filter()->prepend('')->push('')->toArray() - ); + $this->newLine(); + + $events->each(function ($listeners, $event) { + $this->components->twoColumnDetail($this->appendEventInterfaces($event)); + $this->components->bulletList($listeners); + }); + + $this->newLine(); } /** diff --git a/src/Illuminate/Foundation/Console/EventMakeCommand.php b/src/Illuminate/Foundation/Console/EventMakeCommand.php index 05059f4cf1ca..94f96b5ef347 100644 --- a/src/Illuminate/Foundation/Console/EventMakeCommand.php +++ b/src/Illuminate/Foundation/Console/EventMakeCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:event')] class EventMakeCommand extends GeneratorCommand @@ -85,4 +86,16 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Events'; } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the event already exists'], + ]; + } } diff --git a/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php b/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php index 23e5f414bf20..54b24d6f2eb7 100644 --- a/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php @@ -89,8 +89,8 @@ protected function getDefaultNamespace($rootNamespace) protected function getOptions() { return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the exception already exists'], ['render', null, InputOption::VALUE_NONE, 'Create the exception with an empty render method'], - ['report', null, InputOption::VALUE_NONE, 'Create the exception with an empty report method'], ]; } diff --git a/src/Illuminate/Foundation/Console/JobMakeCommand.php b/src/Illuminate/Foundation/Console/JobMakeCommand.php index d4a7a578552b..901fff210b1a 100644 --- a/src/Illuminate/Foundation/Console/JobMakeCommand.php +++ b/src/Illuminate/Foundation/Console/JobMakeCommand.php @@ -88,6 +88,7 @@ protected function getDefaultNamespace($rootNamespace) protected function getOptions() { return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the job already exists'], ['sync', null, InputOption::VALUE_NONE, 'Indicates that job should be synchronous'], ]; } diff --git a/src/Illuminate/Foundation/Console/Kernel.php b/src/Illuminate/Foundation/Console/Kernel.php index 52b25b31fdc0..e9c12616fe60 100644 --- a/src/Illuminate/Foundation/Console/Kernel.php +++ b/src/Illuminate/Foundation/Console/Kernel.php @@ -2,7 +2,9 @@ namespace Illuminate\Foundation\Console; +use Carbon\CarbonInterval; use Closure; +use DateTimeInterface; use Illuminate\Console\Application as Artisan; use Illuminate\Console\Command; use Illuminate\Console\Scheduling\Schedule; @@ -11,7 +13,9 @@ use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Arr; +use Illuminate\Support\Carbon; use Illuminate\Support\Env; +use Illuminate\Support\InteractsWithTime; use Illuminate\Support\Str; use ReflectionClass; use Symfony\Component\Finder\Finder; @@ -19,6 +23,8 @@ class Kernel implements KernelContract { + use InteractsWithTime; + /** * The application implementation. * @@ -54,6 +60,20 @@ class Kernel implements KernelContract */ protected $commandsLoaded = false; + /** + * All of the registered command duration handlers. + * + * @var array + */ + protected $commandLifecycleDurationHandlers = []; + + /** + * When the currently handled command started. + * + * @var \Illuminate\Support\Carbon|null + */ + protected $commandStartedAt; + /** * The bootstrap classes for the application. * @@ -123,7 +143,13 @@ protected function scheduleCache() */ public function handle($input, $output = null) { + $this->commandStartedAt = Carbon::now(); + try { + if (in_array($input->getFirstArgument(), ['env:encrypt', 'env:decrypt'], true)) { + $this->bootstrapWithoutBootingProviders(); + } + $this->bootstrap(); return $this->getArtisan()->run($input, $output); @@ -146,6 +172,49 @@ public function handle($input, $output = null) public function terminate($input, $status) { $this->app->terminate(); + + foreach ($this->commandLifecycleDurationHandlers as ['threshold' => $threshold, 'handler' => $handler]) { + $end ??= Carbon::now(); + + if ($this->commandStartedAt->diffInMilliseconds($end) > $threshold) { + $handler($this->commandStartedAt, $input, $status); + } + } + + $this->commandStartedAt = null; + } + + /** + * Register a callback to be invoked when the command lifecycle duration exceeds a given amount of time. + * + * @param \DateTimeInterface|\Carbon\CarbonInterval|float|int $threshold + * @param callable $handler + * @return void + */ + public function whenCommandLifecycleIsLongerThan($threshold, $handler) + { + $threshold = $threshold instanceof DateTimeInterface + ? $this->secondsUntil($threshold) * 1000 + : $threshold; + + $threshold = $threshold instanceof CarbonInterval + ? $threshold->totalMilliseconds + : $threshold; + + $this->commandLifecycleDurationHandlers[] = [ + 'threshold' => $threshold, + 'handler' => $handler, + ]; + } + + /** + * When the command being handled started. + * + * @return \Illuminate\Support\Carbon|null + */ + public function commandStartedAt() + { + return $this->commandStartedAt; } /** @@ -258,6 +327,10 @@ public function registerCommand($command) */ public function call($command, array $parameters = [], $outputBuffer = null) { + if (in_array($command, ['env:encrypt', 'env:decrypt'], true)) { + $this->bootstrapWithoutBootingProviders(); + } + $this->bootstrap(); return $this->getArtisan()->call($command, $parameters, $outputBuffer); @@ -319,6 +392,20 @@ public function bootstrap() } } + /** + * Bootstrap the application without booting service providers. + * + * @return void + */ + public function bootstrapWithoutBootingProviders() + { + $this->app->bootstrapWith( + collect($this->bootstrappers())->reject(function ($bootstrapper) { + return $bootstrapper === \Illuminate\Foundation\Bootstrap\BootProviders::class; + })->all() + ); + } + /** * Get the Artisan application instance. * diff --git a/src/Illuminate/Foundation/Console/KeyGenerateCommand.php b/src/Illuminate/Foundation/Console/KeyGenerateCommand.php index 48241c4042f1..f047bc39aed6 100644 --- a/src/Illuminate/Foundation/Console/KeyGenerateCommand.php +++ b/src/Illuminate/Foundation/Console/KeyGenerateCommand.php @@ -61,7 +61,7 @@ public function handle() $this->laravel['config']['app.key'] = $key; - $this->info('Application key set successfully.'); + $this->components->info('Application key set successfully.'); } /** @@ -90,7 +90,9 @@ protected function setKeyInEnvironmentFile($key) return false; } - $this->writeNewEnvironmentFileWith($key); + if (! $this->writeNewEnvironmentFileWith($key)) { + return false; + } return true; } @@ -99,15 +101,25 @@ protected function setKeyInEnvironmentFile($key) * Write a new environment file with the given key. * * @param string $key - * @return void + * @return bool */ protected function writeNewEnvironmentFileWith($key) { - file_put_contents($this->laravel->environmentFilePath(), preg_replace( + $replaced = preg_replace( $this->keyReplacementPattern(), 'APP_KEY='.$key, - file_get_contents($this->laravel->environmentFilePath()) - )); + $input = file_get_contents($this->laravel->environmentFilePath()) + ); + + if ($replaced === $input || $replaced === null) { + $this->error('Unable to set application key. No APP_KEY variable was found in the .env file.'); + + return false; + } + + file_put_contents($this->laravel->environmentFilePath(), $replaced); + + return true; } /** diff --git a/src/Illuminate/Foundation/Console/ListenerMakeCommand.php b/src/Illuminate/Foundation/Console/ListenerMakeCommand.php index d96b10af280f..62e82ccd1ea6 100644 --- a/src/Illuminate/Foundation/Console/ListenerMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ListenerMakeCommand.php @@ -6,7 +6,9 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'make:listener')] class ListenerMakeCommand extends GeneratorCommand @@ -121,8 +123,32 @@ protected function getOptions() { return [ ['event', 'e', InputOption::VALUE_OPTIONAL, 'The event class being listened for'], - + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the listener already exists'], ['queued', null, InputOption::VALUE_NONE, 'Indicates the event listener should be queued'], ]; } + + /** + * Interact further with the user if they were prompted for missing arguments. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + { + if ($this->isReservedName($this->getNameInput()) || $this->didReceiveOptions($input)) { + return; + } + + $event = $this->components->askWithCompletion( + 'What event should be listened for?', + $this->possibleEvents(), + 'none' + ); + + if ($event && $event !== 'none') { + $input->setOption('event', $event); + } + } } diff --git a/src/Illuminate/Foundation/Console/MailMakeCommand.php b/src/Illuminate/Foundation/Console/MailMakeCommand.php index e061f2fe44b9..e998da5189f0 100644 --- a/src/Illuminate/Foundation/Console/MailMakeCommand.php +++ b/src/Illuminate/Foundation/Console/MailMakeCommand.php @@ -43,7 +43,7 @@ class MailMakeCommand extends GeneratorCommand * * @var string */ - protected $type = 'Mail'; + protected $type = 'Mailable'; /** * Execute the console command. @@ -87,7 +87,11 @@ protected function writeMarkdownTemplate() */ protected function buildClass($name) { - $class = parent::buildClass($name); + $class = str_replace( + '{{ subject }}', + Str::headline(str_replace($this->getNamespace($name).'\\', '', $name)), + parent::buildClass($name) + ); if ($this->option('markdown') !== false) { $class = str_replace(['DummyView', '{{ view }}'], $this->getView(), $class); @@ -162,7 +166,6 @@ protected function getOptions() { return [ ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the mailable already exists'], - ['markdown', 'm', InputOption::VALUE_OPTIONAL, 'Create a new Markdown template for the mailable', false], ]; } diff --git a/src/Illuminate/Foundation/Console/ModelMakeCommand.php b/src/Illuminate/Foundation/Console/ModelMakeCommand.php index e2560f650731..dbc32855e279 100644 --- a/src/Illuminate/Foundation/Console/ModelMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ModelMakeCommand.php @@ -6,7 +6,9 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'make:model')] class ModelMakeCommand extends GeneratorCommand @@ -117,6 +119,7 @@ protected function createMigration() $this->call('make:migration', [ 'name' => "create_{$table}_table", '--create' => $table, + '--fullpath' => true, ]); } @@ -218,7 +221,7 @@ protected function getDefaultNamespace($rootNamespace) protected function getOptions() { return [ - ['all', 'a', InputOption::VALUE_NONE, 'Generate a migration, seeder, factory, policy, and resource controller for the model'], + ['all', 'a', InputOption::VALUE_NONE, 'Generate a migration, seeder, factory, policy, resource controller, and form request classes for the model'], ['controller', 'c', InputOption::VALUE_NONE, 'Create a new controller for the model'], ['factory', 'f', InputOption::VALUE_NONE, 'Create a new factory for the model'], ['force', null, InputOption::VALUE_NONE, 'Create the class even if the model already exists'], @@ -228,8 +231,40 @@ protected function getOptions() ['seed', 's', InputOption::VALUE_NONE, 'Create a new seeder for the model'], ['pivot', 'p', InputOption::VALUE_NONE, 'Indicates if the generated model should be a custom intermediate table model'], ['resource', 'r', InputOption::VALUE_NONE, 'Indicates if the generated controller should be a resource controller'], - ['api', null, InputOption::VALUE_NONE, 'Indicates if the generated controller should be an API controller'], + ['api', null, InputOption::VALUE_NONE, 'Indicates if the generated controller should be an API resource controller'], ['requests', 'R', InputOption::VALUE_NONE, 'Create new form request classes and use them in the resource controller'], ]; } + + /** + * Interact further with the user if they were prompted for missing arguments. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + { + if ($this->isReservedName($this->getNameInput()) || $this->didReceiveOptions($input)) { + return; + } + + collect($this->components->choice('Would you like any of the following?', [ + 'none', + 'all', + 'factory', + 'form requests', + 'migration', + 'policy', + 'resource controller', + 'seed', + ], default: 0, multiple: true)) + ->reject('none') + ->map(fn ($option) => match ($option) { + 'resource controller' => 'resource', + 'form requests' => 'requests', + default => $option, + }) + ->each(fn ($option) => $input->setOption($option, true)); + } } diff --git a/src/Illuminate/Foundation/Console/NotificationMakeCommand.php b/src/Illuminate/Foundation/Console/NotificationMakeCommand.php index 134cffc0fc97..4bcf5c840faa 100644 --- a/src/Illuminate/Foundation/Console/NotificationMakeCommand.php +++ b/src/Illuminate/Foundation/Console/NotificationMakeCommand.php @@ -140,7 +140,6 @@ protected function getOptions() { return [ ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the notification already exists'], - ['markdown', 'm', InputOption::VALUE_OPTIONAL, 'Create a new Markdown template for the notification'], ]; } diff --git a/src/Illuminate/Foundation/Console/ObserverMakeCommand.php b/src/Illuminate/Foundation/Console/ObserverMakeCommand.php index d60734a01c25..039cb4e27646 100644 --- a/src/Illuminate/Foundation/Console/ObserverMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ObserverMakeCommand.php @@ -5,7 +5,9 @@ use Illuminate\Console\GeneratorCommand; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'make:observer')] class ObserverMakeCommand extends GeneratorCommand @@ -146,7 +148,32 @@ protected function getDefaultNamespace($rootNamespace) protected function getOptions() { return [ - ['model', 'm', InputOption::VALUE_OPTIONAL, 'The model that the observer applies to.'], + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the observer already exists'], + ['model', 'm', InputOption::VALUE_OPTIONAL, 'The model that the observer applies to'], ]; } + + /** + * Interact further with the user if they were prompted for missing arguments. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + { + if ($this->isReservedName($this->getNameInput()) || $this->didReceiveOptions($input)) { + return; + } + + $model = $this->components->askWithCompletion( + 'What model should this observer apply to?', + $this->possibleModels(), + 'none' + ); + + if ($model && $model !== 'none') { + $input->setOption('model', $model); + } + } } diff --git a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php index 1e12960c10d6..d3b039ebb36b 100644 --- a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php @@ -40,13 +40,17 @@ class OptimizeClearCommand extends Command */ public function handle() { - $this->call('event:clear'); - $this->call('view:clear'); - $this->call('cache:clear'); - $this->call('route:clear'); - $this->call('config:clear'); - $this->call('clear-compiled'); - - $this->info('Caches cleared successfully.'); + $this->components->info('Clearing cached bootstrap files.'); + + collect([ + 'events' => fn () => $this->callSilent('event:clear') == 0, + 'views' => fn () => $this->callSilent('view:clear') == 0, + 'cache' => fn () => $this->callSilent('cache:clear') == 0, + 'route' => fn () => $this->callSilent('route:clear') == 0, + 'config' => fn () => $this->callSilent('config:clear') == 0, + 'compiled' => fn () => $this->callSilent('clear-compiled') == 0, + ])->each(fn ($task, $description) => $this->components->task($description, $task)); + + $this->newLine(); } } diff --git a/src/Illuminate/Foundation/Console/OptimizeCommand.php b/src/Illuminate/Foundation/Console/OptimizeCommand.php index 619c2733bc8b..b487928d43ba 100644 --- a/src/Illuminate/Foundation/Console/OptimizeCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeCommand.php @@ -40,9 +40,13 @@ class OptimizeCommand extends Command */ public function handle() { - $this->call('config:cache'); - $this->call('route:cache'); + $this->components->info('Caching the framework bootstrap files'); - $this->info('Files cached successfully.'); + collect([ + 'config' => fn () => $this->callSilent('config:cache') == 0, + 'routes' => fn () => $this->callSilent('route:cache') == 0, + ])->each(fn ($task, $description) => $this->components->task($description, $task)); + + $this->newLine(); } } diff --git a/src/Illuminate/Foundation/Console/PackageDiscoverCommand.php b/src/Illuminate/Foundation/Console/PackageDiscoverCommand.php index 674c579aa5a2..d9b928f4ad4a 100644 --- a/src/Illuminate/Foundation/Console/PackageDiscoverCommand.php +++ b/src/Illuminate/Foundation/Console/PackageDiscoverCommand.php @@ -42,12 +42,13 @@ class PackageDiscoverCommand extends Command */ public function handle(PackageManifest $manifest) { - $manifest->build(); + $this->components->info('Discovering packages'); - foreach (array_keys($manifest->manifest) as $package) { - $this->line("Discovered Package: {$package}"); - } + $manifest->build(); - $this->info('Package manifest generated successfully.'); + collect($manifest->manifest) + ->keys() + ->each(fn ($description) => $this->components->task($description)) + ->whenNotEmpty(fn () => $this->newLine()); } } diff --git a/src/Illuminate/Foundation/Console/PolicyMakeCommand.php b/src/Illuminate/Foundation/Console/PolicyMakeCommand.php index 285701a5aafd..7bc95b0e38eb 100644 --- a/src/Illuminate/Foundation/Console/PolicyMakeCommand.php +++ b/src/Illuminate/Foundation/Console/PolicyMakeCommand.php @@ -6,7 +6,9 @@ use Illuminate\Support\Str; use LogicException; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'make:policy')] class PolicyMakeCommand extends GeneratorCommand @@ -204,8 +206,33 @@ protected function getDefaultNamespace($rootNamespace) protected function getOptions() { return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the policy already exists'], ['model', 'm', InputOption::VALUE_OPTIONAL, 'The model that the policy applies to'], ['guard', 'g', InputOption::VALUE_OPTIONAL, 'The guard that the policy relies on'], ]; } + + /** + * Interact further with the user if they were prompted for missing arguments. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + { + if ($this->isReservedName($this->getNameInput()) || $this->didReceiveOptions($input)) { + return; + } + + $model = $this->components->askWithCompletion( + 'What model should this policy apply to?', + $this->possibleModels(), + 'none' + ); + + if ($model && $model !== 'none') { + $input->setOption('model', $model); + } + } } diff --git a/src/Illuminate/Foundation/Console/ProviderMakeCommand.php b/src/Illuminate/Foundation/Console/ProviderMakeCommand.php index 95608687a12c..a3b99d975fa0 100644 --- a/src/Illuminate/Foundation/Console/ProviderMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ProviderMakeCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:provider')] class ProviderMakeCommand extends GeneratorCommand @@ -73,4 +74,16 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Providers'; } + + /** + * Get the console command arguments. + * + * @return array + */ + protected function getOptions() + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the provider already exists'], + ]; + } } diff --git a/src/Illuminate/Foundation/Console/RequestMakeCommand.php b/src/Illuminate/Foundation/Console/RequestMakeCommand.php index 60c309313be5..f181ff0c8fe7 100644 --- a/src/Illuminate/Foundation/Console/RequestMakeCommand.php +++ b/src/Illuminate/Foundation/Console/RequestMakeCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:request')] class RequestMakeCommand extends GeneratorCommand @@ -73,4 +74,16 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Http\Requests'; } + + /** + * Get the console command arguments. + * + * @return array + */ + protected function getOptions() + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the request already exists'], + ]; + } } diff --git a/src/Illuminate/Foundation/Console/ResourceMakeCommand.php b/src/Illuminate/Foundation/Console/ResourceMakeCommand.php index f6f4b0299025..9fe4fddbcb6d 100644 --- a/src/Illuminate/Foundation/Console/ResourceMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ResourceMakeCommand.php @@ -110,6 +110,7 @@ protected function getDefaultNamespace($rootNamespace) protected function getOptions() { return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the resource already exists'], ['collection', 'c', InputOption::VALUE_NONE, 'Create a resource collection'], ]; } diff --git a/src/Illuminate/Foundation/Console/RouteCacheCommand.php b/src/Illuminate/Foundation/Console/RouteCacheCommand.php index 67a1dbde4b8d..00f4050c4572 100644 --- a/src/Illuminate/Foundation/Console/RouteCacheCommand.php +++ b/src/Illuminate/Foundation/Console/RouteCacheCommand.php @@ -63,12 +63,12 @@ public function __construct(Filesystem $files) */ public function handle() { - $this->call('route:clear'); + $this->callSilent('route:clear'); $routes = $this->getFreshApplicationRoutes(); if (count($routes) === 0) { - return $this->error("Your application doesn't have any routes."); + return $this->components->error("Your application doesn't have any routes."); } foreach ($routes as $route) { @@ -79,7 +79,7 @@ public function handle() $this->laravel->getCachedRoutesPath(), $this->buildRouteCacheFile($routes) ); - $this->info('Routes cached successfully.'); + $this->components->info('Routes cached successfully.'); } /** diff --git a/src/Illuminate/Foundation/Console/RouteClearCommand.php b/src/Illuminate/Foundation/Console/RouteClearCommand.php index 0892a6686f6f..da45e6d80f1c 100644 --- a/src/Illuminate/Foundation/Console/RouteClearCommand.php +++ b/src/Illuminate/Foundation/Console/RouteClearCommand.php @@ -63,6 +63,6 @@ public function handle() { $this->files->delete($this->laravel->getCachedRoutesPath()); - $this->info('Route cache cleared successfully.'); + $this->components->info('Route cache cleared successfully.'); } } diff --git a/src/Illuminate/Foundation/Console/RouteListCommand.php b/src/Illuminate/Foundation/Console/RouteListCommand.php index e89cc6645896..caa81652cea9 100644 --- a/src/Illuminate/Foundation/Console/RouteListCommand.php +++ b/src/Illuminate/Foundation/Console/RouteListCommand.php @@ -104,11 +104,11 @@ public function handle() $this->router->flushMiddlewareGroups(); if (! $this->router->getRoutes()->count()) { - return $this->error("Your application doesn't have any routes."); + return $this->components->error("Your application doesn't have any routes."); } if (empty($routes = $this->getRoutes())) { - return $this->error("Your application doesn't have any routes matching the given criteria."); + return $this->components->error("Your application doesn't have any routes matching the given criteria."); } $this->displayRoutes($routes); diff --git a/src/Illuminate/Foundation/Console/RuleMakeCommand.php b/src/Illuminate/Foundation/Console/RuleMakeCommand.php index 125d077040bd..5cefd1fa1564 100644 --- a/src/Illuminate/Foundation/Console/RuleMakeCommand.php +++ b/src/Illuminate/Foundation/Console/RuleMakeCommand.php @@ -99,8 +99,9 @@ protected function getDefaultNamespace($rootNamespace) protected function getOptions() { return [ - ['implicit', 'i', InputOption::VALUE_NONE, 'Generate an implicit rule.'], - ['invokable', null, InputOption::VALUE_NONE, 'Generate a single method, invokable rule class.'], + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the rule already exists'], + ['implicit', 'i', InputOption::VALUE_NONE, 'Generate an implicit rule'], + ['invokable', null, InputOption::VALUE_NONE, 'Generate a single method, invokable rule class'], ]; } } diff --git a/src/Illuminate/Foundation/Console/ScopeMakeCommand.php b/src/Illuminate/Foundation/Console/ScopeMakeCommand.php index 048aee33de2c..d36742c83c34 100644 --- a/src/Illuminate/Foundation/Console/ScopeMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ScopeMakeCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:scope')] class ScopeMakeCommand extends GeneratorCommand @@ -73,4 +74,16 @@ protected function getDefaultNamespace($rootNamespace) { return is_dir(app_path('Models')) ? $rootNamespace.'\\Models\\Scopes' : $rootNamespace.'\Scopes'; } + + /** + * Get the console command arguments. + * + * @return array + */ + protected function getOptions() + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the scope already exists'], + ]; + } } diff --git a/src/Illuminate/Foundation/Console/ServeCommand.php b/src/Illuminate/Foundation/Console/ServeCommand.php index 4fc4c8836a5e..bdb2eae8d6b8 100644 --- a/src/Illuminate/Foundation/Console/ServeCommand.php +++ b/src/Illuminate/Foundation/Console/ServeCommand.php @@ -3,12 +3,15 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; +use Illuminate\Support\Carbon; use Illuminate\Support\Env; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; +use function Termwind\terminal; + #[AsCommand(name: 'serve')] class ServeCommand extends Command { @@ -44,6 +47,20 @@ class ServeCommand extends Command */ protected $portOffset = 0; + /** + * The list of requests being handled and their start time. + * + * @var array + */ + protected $requestsPool; + + /** + * Indicates if the "Server running on..." output message has been displayed. + * + * @var bool + */ + protected $serverRunningHasBeenDisplayed = false; + /** * The environment variables that should be passed from host machine to the PHP server process. * @@ -52,6 +69,7 @@ class ServeCommand extends Command public static $passthroughVariables = [ 'APP_ENV', 'LARAVEL_SAIL', + 'PATH', 'PHP_CLI_SERVER_WORKERS', 'PHP_IDE_CONFIG', 'SYSTEMROOT', @@ -69,8 +87,6 @@ class ServeCommand extends Command */ public function handle() { - $this->line("Starting Laravel development server: http://{$this->host()}:{$this->port()}"); - $environmentFile = $this->option('env') ? base_path('.env').'.'.$this->option('env') : base_path('.env'); @@ -93,10 +109,14 @@ public function handle() filemtime($environmentFile) > $environmentLastModified) { $environmentLastModified = filemtime($environmentFile); - $this->comment('Environment modified. Restarting server...'); + $this->newLine(); + + $this->components->info('Environment modified. Restarting server...'); $process->stop(5); + $this->serverRunningHasBeenDisplayed = false; + $process = $this->startProcess($hasEnvironment); } @@ -130,9 +150,7 @@ protected function startProcess($hasEnvironment) return in_array($key, static::$passthroughVariables) ? [$key => $value] : [$key => false]; })->all()); - $process->start(function ($type, $buffer) { - $this->output->write($buffer); - }); + $process->start($this->handleProcessOutput()); return $process; } @@ -163,7 +181,7 @@ protected function serverCommand() */ protected function host() { - [$host, ] = $this->getHostAndPort(); + [$host] = $this->getHostAndPort(); return $host; } @@ -212,6 +230,101 @@ protected function canTryAnotherPort() ($this->input->getOption('tries') > $this->portOffset); } + /** + * Returns a "callable" to handle the process output. + * + * @return callable(string, string): void + */ + protected function handleProcessOutput() + { + return fn ($type, $buffer) => str($buffer)->explode("\n")->each(function ($line) { + if (str($line)->contains('Development Server (http')) { + if ($this->serverRunningHasBeenDisplayed) { + return; + } + + $this->components->info("Server running on [http://{$this->host()}:{$this->port()}]."); + $this->comment(' Press Ctrl+C to stop the server'); + + $this->newLine(); + + $this->serverRunningHasBeenDisplayed = true; + } elseif (str($line)->contains(' Accepted')) { + $requestPort = $this->getRequestPortFromLine($line); + + $this->requestsPool[$requestPort] = [ + $this->getDateFromLine($line), + false, + ]; + } elseif (str($line)->contains([' [200]: GET '])) { + $requestPort = $this->getRequestPortFromLine($line); + + $this->requestsPool[$requestPort][1] = trim(explode('[200]: GET', $line)[1]); + } elseif (str($line)->contains(' Closing')) { + $requestPort = $this->getRequestPortFromLine($line); + $request = $this->requestsPool[$requestPort]; + + [$startDate, $file] = $request; + + $formattedStartedAt = $startDate->format('Y-m-d H:i:s'); + + unset($this->requestsPool[$requestPort]); + + [$date, $time] = explode(' ', $formattedStartedAt); + + $this->output->write(" $date $time"); + + $runTime = $this->getDateFromLine($line)->diffInSeconds($startDate); + + if ($file) { + $this->output->write($file = " $file"); + } + + $dots = max(terminal()->width() - mb_strlen($formattedStartedAt) - mb_strlen($file) - mb_strlen($runTime) - 9, 0); + + $this->output->write(' '.str_repeat('.', $dots)); + $this->output->writeln(" ~ {$runTime}s"); + } elseif (str($line)->contains(['Closed without sending a request'])) { + // ... + } elseif (! empty($line)) { + $warning = explode('] ', $line); + $this->components->warn(count($warning) > 1 ? $warning[1] : $warning[0]); + } + }); + } + + /** + * Get the date from the given PHP server output. + * + * @param string $line + * @return \Illuminate\Support\Carbon + */ + protected function getDateFromLine($line) + { + $regex = env('PHP_CLI_SERVER_WORKERS', 1) > 1 + ? '/^\[\d+]\s\[([a-zA-Z0-9: ]+)\]/' + : '/^\[([^\]]+)\]/'; + + $line = str_replace(' ', ' ', $line); + + preg_match($regex, $line, $matches); + + return Carbon::createFromFormat('D M d H:i:s Y', $matches[1]); + } + + /** + * Get the request port from the given PHP server output. + * + * @param string $line + * @return int + */ + protected function getRequestPortFromLine($line) + { + preg_match('/:(\d+)\s(?:(?:\w+$)|(?:\[.*))/', $line, $matches); + + return (int) $matches[1]; + } + /** * Get the console command options. * diff --git a/src/Illuminate/Foundation/Console/ShowModelCommand.php b/src/Illuminate/Foundation/Console/ShowModelCommand.php new file mode 100644 index 000000000000..f9ed00a3dea6 --- /dev/null +++ b/src/Illuminate/Foundation/Console/ShowModelCommand.php @@ -0,0 +1,546 @@ +ensureDependenciesExist()) { + return 1; + } + + $class = $this->qualifyModel($this->argument('model')); + + try { + $model = $this->laravel->make($class); + + $class = get_class($model); + } catch (BindingResolutionException $e) { + return $this->components->error($e->getMessage()); + } + + if ($this->option('database')) { + $model->setConnection($this->option('database')); + } + + $this->display( + $class, + $model->getConnection()->getName(), + $model->getConnection()->getTablePrefix().$model->getTable(), + $this->getPolicy($model), + $this->getAttributes($model), + $this->getRelations($model), + $this->getObservers($model), + ); + } + + /** + * Get the first policy associated with this model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return Illuminate\Support\Collection + */ + protected function getPolicy($model) + { + return collect(Gate::policies()) + ->filter(fn ($policy, $modelClass) => $modelClass === get_class($model)) + ->values() + ->first(); + } + + /** + * Get the column attributes for the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\Collection + */ + protected function getAttributes($model) + { + $schema = $model->getConnection()->getDoctrineSchemaManager(); + $this->registerTypeMappings($schema->getDatabasePlatform()); + $table = $model->getConnection()->getTablePrefix().$model->getTable(); + $columns = $schema->listTableColumns($table); + $indexes = $schema->listTableIndexes($table); + + return collect($columns) + ->values() + ->map(fn (Column $column) => [ + 'name' => $column->getName(), + 'type' => $this->getColumnType($column), + 'increments' => $column->getAutoincrement(), + 'nullable' => ! $column->getNotnull(), + 'default' => $this->getColumnDefault($column, $model), + 'unique' => $this->columnIsUnique($column->getName(), $indexes), + 'fillable' => $model->isFillable($column->getName()), + 'hidden' => $this->attributeIsHidden($column->getName(), $model), + 'appended' => null, + 'cast' => $this->getCastType($column->getName(), $model), + ]) + ->merge($this->getVirtualAttributes($model, $columns)); + } + + /** + * Get the virtual (non-column) attributes for the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param \Doctrine\DBAL\Schema\Column[] $columns + * @return \Illuminate\Support\Collection + */ + protected function getVirtualAttributes($model, $columns) + { + $class = new ReflectionClass($model); + + return collect($class->getMethods()) + ->reject( + fn (ReflectionMethod $method) => $method->isStatic() + || $method->isAbstract() + || $method->getDeclaringClass()->getName() !== get_class($model) + ) + ->mapWithKeys(function (ReflectionMethod $method) use ($model) { + if (preg_match('/^get(.+)Attribute$/', $method->getName(), $matches) === 1) { + return [Str::snake($matches[1]) => 'accessor']; + } elseif ($model->hasAttributeMutator($method->getName())) { + return [Str::snake($method->getName()) => 'attribute']; + } else { + return []; + } + }) + ->reject(fn ($cast, $name) => collect($columns)->has($name)) + ->map(fn ($cast, $name) => [ + 'name' => $name, + 'type' => null, + 'increments' => false, + 'nullable' => null, + 'default' => null, + 'unique' => null, + 'fillable' => $model->isFillable($name), + 'hidden' => $this->attributeIsHidden($name, $model), + 'appended' => $model->hasAppended($name), + 'cast' => $cast, + ]) + ->values(); + } + + /** + * Get the relations from the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\Collection + */ + protected function getRelations($model) + { + return collect(get_class_methods($model)) + ->map(fn ($method) => new ReflectionMethod($model, $method)) + ->reject( + fn (ReflectionMethod $method) => $method->isStatic() + || $method->isAbstract() + || $method->getDeclaringClass()->getName() !== get_class($model) + ) + ->filter(function (ReflectionMethod $method) { + $file = new SplFileObject($method->getFileName()); + $file->seek($method->getStartLine() - 1); + $code = ''; + while ($file->key() < $method->getEndLine()) { + $code .= trim($file->current()); + $file->next(); + } + + return collect($this->relationMethods) + ->contains(fn ($relationMethod) => str_contains($code, '$this->'.$relationMethod.'(')); + }) + ->map(function (ReflectionMethod $method) use ($model) { + $relation = $method->invoke($model); + + if (! $relation instanceof Relation) { + return null; + } + + return [ + 'name' => $method->getName(), + 'type' => Str::afterLast(get_class($relation), '\\'), + 'related' => get_class($relation->getRelated()), + ]; + }) + ->filter() + ->values(); + } + + /** + * Get the Observers watching this model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return Illuminate\Support\Collection + */ + protected function getObservers($model) + { + $listeners = $this->getLaravel()->make('events')->getRawListeners(); + + // Get the Eloquent observers for this model... + $listeners = array_filter($listeners, function ($v, $key) use ($model) { + return Str::startsWith($key, 'eloquent.') && Str::endsWith($key, $model::class); + }, ARRAY_FILTER_USE_BOTH); + + // Format listeners Eloquent verb => Observer methods... + $extractVerb = function ($key) { + preg_match('/eloquent.([a-zA-Z]+)\: /', $key, $matches); + + return $matches[1] ?? '?'; + }; + + $formatted = []; + + foreach ($listeners as $key => $observerMethods) { + $formatted[] = [ + 'event' => $extractVerb($key), + 'observer' => array_map(fn ($obs) => is_string($obs) ? $obs : 'Closure', $observerMethods), + ]; + } + + return collect($formatted); + } + + /** + * Render the model information. + * + * @param string $class + * @param string $database + * @param string $table + * @param string $policy + * @param \Illuminate\Support\Collection $attributes + * @param \Illuminate\Support\Collection $relations + * @param \Illuminate\Support\Collection $observers + * @return void + */ + protected function display($class, $database, $table, $policy, $attributes, $relations, $observers) + { + $this->option('json') + ? $this->displayJson($class, $database, $table, $policy, $attributes, $relations, $observers) + : $this->displayCli($class, $database, $table, $policy, $attributes, $relations, $observers); + } + + /** + * Render the model information as JSON. + * + * @param string $class + * @param string $database + * @param string $table + * @param string $policy + * @param \Illuminate\Support\Collection $attributes + * @param \Illuminate\Support\Collection $relations + * @param \Illuminate\Support\Collection $observers + * @return void + */ + protected function displayJson($class, $database, $table, $policy, $attributes, $relations, $observers) + { + $this->output->writeln( + collect([ + 'class' => $class, + 'database' => $database, + 'table' => $table, + 'policy' => $policy, + 'attributes' => $attributes, + 'relations' => $relations, + 'observers' => $observers, + ])->toJson() + ); + } + + /** + * Render the model information for the CLI. + * + * @param string $class + * @param string $database + * @param string $table + * @param string $policy + * @param \Illuminate\Support\Collection $attributes + * @param \Illuminate\Support\Collection $relations + * @param \Illuminate\Support\Collection $observers + * @return void + */ + protected function displayCli($class, $database, $table, $policy, $attributes, $relations, $observers) + { + $this->newLine(); + + $this->components->twoColumnDetail(''.$class.''); + $this->components->twoColumnDetail('Database', $database); + $this->components->twoColumnDetail('Table', $table); + + if ($policy) { + $this->components->twoColumnDetail('Policy', $policy); + } + + $this->newLine(); + + $this->components->twoColumnDetail( + 'Attributes', + 'type / cast', + ); + + foreach ($attributes as $attribute) { + $first = trim(sprintf( + '%s %s', + $attribute['name'], + collect(['increments', 'unique', 'nullable', 'fillable', 'hidden', 'appended']) + ->filter(fn ($property) => $attribute[$property]) + ->map(fn ($property) => sprintf('%s', $property)) + ->implode(', ') + )); + + $second = collect([ + $attribute['type'], + $attribute['cast'] ? ''.$attribute['cast'].'' : null, + ])->filter()->implode(' / '); + + $this->components->twoColumnDetail($first, $second); + + if ($attribute['default'] !== null) { + $this->components->bulletList( + [sprintf('default: %s', $attribute['default'])], + OutputInterface::VERBOSITY_VERBOSE + ); + } + } + + $this->newLine(); + + $this->components->twoColumnDetail('Relations'); + + foreach ($relations as $relation) { + $this->components->twoColumnDetail( + sprintf('%s %s', $relation['name'], $relation['type']), + $relation['related'] + ); + } + + $this->newLine(); + + $this->components->twoColumnDetail('Observers'); + + if ($observers->count()) { + foreach ($observers as $observer) { + $this->components->twoColumnDetail( + sprintf('%s', $observer['event']), + implode(', ', $observer['observer']) + ); + } + } + + $this->newLine(); + } + + /** + * Get the cast type for the given column. + * + * @param string $column + * @param \Illuminate\Database\Eloquent\Model $model + * @return string|null + */ + protected function getCastType($column, $model) + { + if ($model->hasGetMutator($column) || $model->hasSetMutator($column)) { + return 'accessor'; + } + + if ($model->hasAttributeMutator($column)) { + return 'attribute'; + } + + return $this->getCastsWithDates($model)->get($column) ?? null; + } + + /** + * Get the model casts, including any date casts. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\Collection + */ + protected function getCastsWithDates($model) + { + return collect($model->getDates()) + ->filter() + ->flip() + ->map(fn () => 'datetime') + ->merge($model->getCasts()); + } + + /** + * Get the type of the given column. + * + * @param \Doctrine\DBAL\Schema\Column $column + * @return string + */ + protected function getColumnType($column) + { + $name = $column->getType()->getName(); + + $unsigned = $column->getUnsigned() ? ' unsigned' : ''; + + $details = match (get_class($column->getType())) { + DecimalType::class => $column->getPrecision().','.$column->getScale(), + default => $column->getLength(), + }; + + if ($details) { + return sprintf('%s(%s)%s', $name, $details, $unsigned); + } + + return sprintf('%s%s', $name, $unsigned); + } + + /** + * Get the default value for the given column. + * + * @param \Doctrine\DBAL\Schema\Column $column + * @param \Illuminate\Database\Eloquent\Model $model + * @return mixed|null + */ + protected function getColumnDefault($column, $model) + { + $attributeDefault = $model->getAttributes()[$column->getName()] ?? null; + + return match (true) { + $attributeDefault instanceof BackedEnum => $attributeDefault->value, + $attributeDefault instanceof UnitEnum => $attributeDefault->name, + default => $attributeDefault ?? $column->getDefault(), + }; + } + + /** + * Determine if the given attribute is hidden. + * + * @param string $attribute + * @param \Illuminate\Database\Eloquent\Model $model + * @return bool + */ + protected function attributeIsHidden($attribute, $model) + { + if (count($model->getHidden()) > 0) { + return in_array($attribute, $model->getHidden()); + } + + if (count($model->getVisible()) > 0) { + return ! in_array($attribute, $model->getVisible()); + } + + return false; + } + + /** + * Determine if the given attribute is unique. + * + * @param string $column + * @param \Doctrine\DBAL\Schema\Index[] $indexes + * @return bool + */ + protected function columnIsUnique($column, $indexes) + { + return collect($indexes) + ->filter(fn (Index $index) => count($index->getColumns()) === 1 && $index->getColumns()[0] === $column) + ->contains(fn (Index $index) => $index->isUnique()); + } + + /** + * Qualify the given model class base name. + * + * @param string $model + * @return string + * + * @see \Illuminate\Console\GeneratorCommand + */ + protected function qualifyModel(string $model) + { + if (str_contains($model, '\\') && class_exists($model)) { + return $model; + } + + $model = ltrim($model, '\\/'); + + $model = str_replace('/', '\\', $model); + + $rootNamespace = $this->laravel->getNamespace(); + + if (Str::startsWith($model, $rootNamespace)) { + return $model; + } + + return is_dir(app_path('Models')) + ? $rootNamespace.'Models\\'.$model + : $rootNamespace.$model; + } +} diff --git a/src/Illuminate/Foundation/Console/StorageLinkCommand.php b/src/Illuminate/Foundation/Console/StorageLinkCommand.php index f5decaf044fa..10427557bff9 100644 --- a/src/Illuminate/Foundation/Console/StorageLinkCommand.php +++ b/src/Illuminate/Foundation/Console/StorageLinkCommand.php @@ -46,7 +46,7 @@ public function handle() foreach ($this->links() as $link => $target) { if (file_exists($link) && ! $this->isRemovableSymlink($link, $this->option('force'))) { - $this->error("The [$link] link already exists."); + $this->components->error("The [$link] link already exists."); continue; } @@ -60,10 +60,8 @@ public function handle() $this->laravel->make('files')->link($target, $link); } - $this->info("The [$link] link has been connected to [$target]."); + $this->components->info("The [$link] link has been connected to [$target]."); } - - $this->info('The links have been created.'); } /** diff --git a/src/Illuminate/Foundation/Console/StubPublishCommand.php b/src/Illuminate/Foundation/Console/StubPublishCommand.php index ada13946162b..58bf204050fc 100644 --- a/src/Illuminate/Foundation/Console/StubPublishCommand.php +++ b/src/Illuminate/Foundation/Console/StubPublishCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; +use Illuminate\Foundation\Events\PublishingStubs; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'stub:publish')] @@ -14,7 +15,9 @@ class StubPublishCommand extends Command * * @var string */ - protected $signature = 'stub:publish {--force : Overwrite any existing files}'; + protected $signature = 'stub:publish + {--existing : Publish and overwrite only the files that have already been published} + {--force : Overwrite any existing files}'; /** * The name of the console command. @@ -45,54 +48,63 @@ public function handle() (new Filesystem)->makeDirectory($stubsPath); } - $files = [ - __DIR__.'/stubs/cast.inbound.stub' => $stubsPath.'/cast.inbound.stub', - __DIR__.'/stubs/cast.stub' => $stubsPath.'/cast.stub', - __DIR__.'/stubs/console.stub' => $stubsPath.'/console.stub', - __DIR__.'/stubs/event.stub' => $stubsPath.'/event.stub', - __DIR__.'/stubs/job.queued.stub' => $stubsPath.'/job.queued.stub', - __DIR__.'/stubs/job.stub' => $stubsPath.'/job.stub', - __DIR__.'/stubs/mail.stub' => $stubsPath.'/mail.stub', - __DIR__.'/stubs/markdown-mail.stub' => $stubsPath.'/markdown-mail.stub', - __DIR__.'/stubs/markdown-notification.stub' => $stubsPath.'/markdown-notification.stub', - __DIR__.'/stubs/model.pivot.stub' => $stubsPath.'/model.pivot.stub', - __DIR__.'/stubs/model.stub' => $stubsPath.'/model.stub', - __DIR__.'/stubs/notification.stub' => $stubsPath.'/notification.stub', - __DIR__.'/stubs/observer.plain.stub' => $stubsPath.'/observer.plain.stub', - __DIR__.'/stubs/observer.stub' => $stubsPath.'/observer.stub', - __DIR__.'/stubs/policy.plain.stub' => $stubsPath.'/policy.plain.stub', - __DIR__.'/stubs/policy.stub' => $stubsPath.'/policy.stub', - __DIR__.'/stubs/provider.stub' => $stubsPath.'/provider.stub', - __DIR__.'/stubs/request.stub' => $stubsPath.'/request.stub', - __DIR__.'/stubs/resource.stub' => $stubsPath.'/resource.stub', - __DIR__.'/stubs/resource-collection.stub' => $stubsPath.'/resource-collection.stub', - __DIR__.'/stubs/rule.stub' => $stubsPath.'/rule.stub', - __DIR__.'/stubs/scope.stub' => $stubsPath.'/scope.stub', - __DIR__.'/stubs/test.stub' => $stubsPath.'/test.stub', - __DIR__.'/stubs/test.unit.stub' => $stubsPath.'/test.unit.stub', - __DIR__.'/stubs/view-component.stub' => $stubsPath.'/view-component.stub', - realpath(__DIR__.'/../../Database/Console/Factories/stubs/factory.stub') => $stubsPath.'/factory.stub', - realpath(__DIR__.'/../../Database/Console/Seeds/stubs/seeder.stub') => $stubsPath.'/seeder.stub', - realpath(__DIR__.'/../../Database/Migrations/stubs/migration.create.stub') => $stubsPath.'/migration.create.stub', - realpath(__DIR__.'/../../Database/Migrations/stubs/migration.stub') => $stubsPath.'/migration.stub', - realpath(__DIR__.'/../../Database/Migrations/stubs/migration.update.stub') => $stubsPath.'/migration.update.stub', - realpath(__DIR__.'/../../Routing/Console/stubs/controller.api.stub') => $stubsPath.'/controller.api.stub', - realpath(__DIR__.'/../../Routing/Console/stubs/controller.invokable.stub') => $stubsPath.'/controller.invokable.stub', - realpath(__DIR__.'/../../Routing/Console/stubs/controller.model.api.stub') => $stubsPath.'/controller.model.api.stub', - realpath(__DIR__.'/../../Routing/Console/stubs/controller.model.stub') => $stubsPath.'/controller.model.stub', - realpath(__DIR__.'/../../Routing/Console/stubs/controller.nested.api.stub') => $stubsPath.'/controller.nested.api.stub', - realpath(__DIR__.'/../../Routing/Console/stubs/controller.nested.stub') => $stubsPath.'/controller.nested.stub', - realpath(__DIR__.'/../../Routing/Console/stubs/controller.plain.stub') => $stubsPath.'/controller.plain.stub', - realpath(__DIR__.'/../../Routing/Console/stubs/controller.stub') => $stubsPath.'/controller.stub', - realpath(__DIR__.'/../../Routing/Console/stubs/middleware.stub') => $stubsPath.'/middleware.stub', + $stubs = [ + __DIR__.'/stubs/cast.inbound.stub' => 'cast.inbound.stub', + __DIR__.'/stubs/cast.stub' => 'cast.stub', + __DIR__.'/stubs/console.stub' => 'console.stub', + __DIR__.'/stubs/event.stub' => 'event.stub', + __DIR__.'/stubs/job.queued.stub' => 'job.queued.stub', + __DIR__.'/stubs/job.stub' => 'job.stub', + __DIR__.'/stubs/mail.stub' => 'mail.stub', + __DIR__.'/stubs/markdown-mail.stub' => 'markdown-mail.stub', + __DIR__.'/stubs/markdown-notification.stub' => 'markdown-notification.stub', + __DIR__.'/stubs/model.pivot.stub' => 'model.pivot.stub', + __DIR__.'/stubs/model.stub' => 'model.stub', + __DIR__.'/stubs/notification.stub' => 'notification.stub', + __DIR__.'/stubs/observer.plain.stub' => 'observer.plain.stub', + __DIR__.'/stubs/observer.stub' => 'observer.stub', + __DIR__.'/stubs/policy.plain.stub' => 'policy.plain.stub', + __DIR__.'/stubs/policy.stub' => 'policy.stub', + __DIR__.'/stubs/provider.stub' => 'provider.stub', + __DIR__.'/stubs/request.stub' => 'request.stub', + __DIR__.'/stubs/resource.stub' => 'resource.stub', + __DIR__.'/stubs/resource-collection.stub' => 'resource-collection.stub', + __DIR__.'/stubs/rule.stub' => 'rule.stub', + __DIR__.'/stubs/scope.stub' => 'scope.stub', + __DIR__.'/stubs/test.stub' => 'test.stub', + __DIR__.'/stubs/test.unit.stub' => 'test.unit.stub', + __DIR__.'/stubs/view-component.stub' => 'view-component.stub', + realpath(__DIR__.'/../../Database/Console/Factories/stubs/factory.stub') => 'factory.stub', + realpath(__DIR__.'/../../Database/Console/Seeds/stubs/seeder.stub') => 'seeder.stub', + realpath(__DIR__.'/../../Database/Migrations/stubs/migration.create.stub') => 'migration.create.stub', + realpath(__DIR__.'/../../Database/Migrations/stubs/migration.stub') => 'migration.stub', + realpath(__DIR__.'/../../Database/Migrations/stubs/migration.update.stub') => 'migration.update.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.api.stub') => 'controller.api.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.invokable.stub') => 'controller.invokable.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.model.api.stub') => 'controller.model.api.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.model.stub') => 'controller.model.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.nested.api.stub') => 'controller.nested.api.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.nested.singleton.api.stub') => 'controller.nested.singleton.api.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.nested.singleton.stub') => 'controller.nested.singleton.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.nested.stub') => 'controller.nested.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.plain.stub') => 'controller.plain.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.singleton.api.stub') => 'controller.singleton.api.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.singleton.stub') => 'controller.singleton.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.stub') => 'controller.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/middleware.stub') => 'middleware.stub', ]; - foreach ($files as $from => $to) { - if (! file_exists($to) || $this->option('force')) { + $this->laravel['events']->dispatch($event = new PublishingStubs($stubs)); + + foreach ($event->stubs as $from => $to) { + $to = $stubsPath.DIRECTORY_SEPARATOR.ltrim($to, DIRECTORY_SEPARATOR); + + if ((! $this->option('existing') && (! file_exists($to) || $this->option('force'))) + || ($this->option('existing') && file_exists($to))) { file_put_contents($to, file_get_contents($from)); } } - $this->info('Stubs published successfully.'); + $this->components->info('Stubs published successfully.'); } } diff --git a/src/Illuminate/Foundation/Console/TestMakeCommand.php b/src/Illuminate/Foundation/Console/TestMakeCommand.php index d1110b80f296..f3dd30a07309 100644 --- a/src/Illuminate/Foundation/Console/TestMakeCommand.php +++ b/src/Illuminate/Foundation/Console/TestMakeCommand.php @@ -5,7 +5,9 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'make:test')] class TestMakeCommand extends GeneratorCommand @@ -115,8 +117,37 @@ protected function rootNamespace() protected function getOptions() { return [ - ['unit', 'u', InputOption::VALUE_NONE, 'Create a unit test.'], - ['pest', 'p', InputOption::VALUE_NONE, 'Create a Pest test.'], + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the test already exists'], + ['unit', 'u', InputOption::VALUE_NONE, 'Create a unit test'], + ['pest', 'p', InputOption::VALUE_NONE, 'Create a Pest test'], ]; } + + /** + * Interact further with the user if they were prompted for missing arguments. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + { + if ($this->isReservedName($this->getNameInput()) || $this->didReceiveOptions($input)) { + return; + } + + $type = $this->components->choice('Which type of test would you like', [ + 'feature', + 'unit', + 'pest feature', + 'pest unit', + ], default: 0); + + match ($type) { + 'feature' => null, + 'unit' => $input->setOption('unit', true), + 'pest feature' => $input->setOption('pest', true), + 'pest unit' => tap($input)->setOption('pest', true)->setOption('unit', true), + }; + } } diff --git a/src/Illuminate/Foundation/Console/UpCommand.php b/src/Illuminate/Foundation/Console/UpCommand.php index 86adaed46154..000d3c625907 100644 --- a/src/Illuminate/Foundation/Console/UpCommand.php +++ b/src/Illuminate/Foundation/Console/UpCommand.php @@ -44,7 +44,7 @@ public function handle() { try { if (! $this->laravel->maintenanceMode()->active()) { - $this->comment('Application is already up.'); + $this->components->info('Application is already up.'); return 0; } @@ -55,15 +55,18 @@ public function handle() unlink(storage_path('framework/maintenance.php')); } - $this->laravel->get('events')->dispatch(MaintenanceModeDisabled::class); + $this->laravel->get('events')->dispatch(new MaintenanceModeDisabled()); - $this->info('Application is now live.'); + $this->components->info('Application is now live.'); } catch (Exception $e) { - $this->error('Failed to disable maintenance mode.'); - - $this->error($e->getMessage()); + $this->components->error(sprintf( + 'Failed to disable maintenance mode: %s.', + $e->getMessage(), + )); return 1; } + + return 0; } } diff --git a/src/Illuminate/Foundation/Console/VendorPublishCommand.php b/src/Illuminate/Foundation/Console/VendorPublishCommand.php index 69020fba7188..ad41fa772872 100644 --- a/src/Illuminate/Foundation/Console/VendorPublishCommand.php +++ b/src/Illuminate/Foundation/Console/VendorPublishCommand.php @@ -44,7 +44,9 @@ class VendorPublishCommand extends Command * * @var string */ - protected $signature = 'vendor:publish {--force : Overwrite any existing files} + protected $signature = 'vendor:publish + {--existing : Publish and overwrite only the files that have already been published} + {--force : Overwrite any existing files} {--all : Publish assets for all service providers without prompt} {--provider= : The service provider that has assets you want to publish} {--tag=* : One or many tags that have assets you want to publish}'; @@ -92,8 +94,6 @@ public function handle() foreach ($this->tags ?: [null] as $tag) { $this->publishTag($tag); } - - $this->info('Publishing complete.'); } /** @@ -123,7 +123,7 @@ protected function determineWhatShouldBePublished() */ protected function promptForProviderOrTag() { - $choice = $this->choice( + $choice = $this->components->choice( "Which provider or tag's files would you like to publish?", $choices = $this->publishableChoices() ); @@ -144,8 +144,8 @@ protected function publishableChoices() { return array_merge( ['Publish files from all providers and tags listed below'], - preg_filter('/^/', 'Provider: ', Arr::sort(ServiceProvider::publishableProviders())), - preg_filter('/^/', 'Tag: ', Arr::sort(ServiceProvider::publishableGroups())) + preg_filter('/^/', 'Provider: ', Arr::sort(ServiceProvider::publishableProviders())), + preg_filter('/^/', 'Tag: ', Arr::sort(ServiceProvider::publishableGroups())) ); } @@ -178,16 +178,23 @@ protected function publishTag($tag) $pathsToPublish = $this->pathsToPublish($tag); + if ($publishing = count($pathsToPublish) > 0) { + $this->components->info(sprintf( + 'Publishing %sassets', + $tag ? "[$tag] " : '', + )); + } + foreach ($pathsToPublish as $from => $to) { $this->publishItem($from, $to); - - $published = true; } - if ($published === false) { - $this->comment('No publishable resources for tag ['.$tag.'].'); + if ($publishing === false) { + $this->components->info('No publishable resources for tag ['.$tag.'].'); } else { $this->laravel['events']->dispatch(new VendorTagPublished($tag, $pathsToPublish)); + + $this->newLine(); } } @@ -219,7 +226,7 @@ protected function publishItem($from, $to) return $this->publishDirectory($from, $to); } - $this->error("Can't locate path: <{$from}>"); + $this->components->error("Can't locate path: <{$from}>"); } /** @@ -231,12 +238,25 @@ protected function publishItem($from, $to) */ protected function publishFile($from, $to) { - if (! $this->files->exists($to) || $this->option('force')) { + if ((! $this->option('existing') && (! $this->files->exists($to) || $this->option('force'))) + || ($this->option('existing') && $this->files->exists($to))) { $this->createParentDirectory(dirname($to)); $this->files->copy($from, $to); - $this->status($from, $to, 'File'); + $this->status($from, $to, 'file'); + } else { + if ($this->option('existing')) { + $this->components->twoColumnDetail(sprintf( + 'File [%s] does not exist', + str_replace(base_path().'/', '', $to), + ), 'SKIPPED'); + } else { + $this->components->twoColumnDetail(sprintf( + 'File [%s] already exists', + str_replace(base_path().'/', '', realpath($to)), + ), 'SKIPPED'); + } } } @@ -256,7 +276,7 @@ protected function publishDirectory($from, $to) 'to' => new Flysystem(new LocalAdapter($to, $visibility)), ])); - $this->status($from, $to, 'Directory'); + $this->status($from, $to, 'directory'); } /** @@ -270,7 +290,13 @@ protected function moveManagedFiles($manager) foreach ($manager->listContents('from://', true) as $file) { $path = Str::after($file['path'], 'from://'); - if ($file['type'] === 'file' && (! $manager->fileExists('to://'.$path) || $this->option('force'))) { + if ( + $file['type'] === 'file' + && ( + (! $this->option('existing') && (! $manager->fileExists('to://'.$path) || $this->option('force'))) + || ($this->option('existing') && $manager->fileExists('to://'.$path)) + ) + ) { $manager->write('to://'.$path, $manager->read($file['path'])); } } @@ -299,10 +325,15 @@ protected function createParentDirectory($directory) */ protected function status($from, $to, $type) { - $from = str_replace(base_path(), '', realpath($from)); + $from = str_replace(base_path().'/', '', realpath($from)); - $to = str_replace(base_path(), '', realpath($to)); + $to = str_replace(base_path().'/', '', realpath($to)); - $this->line('Copied '.$type.' ['.$from.'] To ['.$to.']'); + $this->components->task(sprintf( + 'Copying %s [%s] to [%s]', + $type, + $from, + $to, + )); } } diff --git a/src/Illuminate/Foundation/Console/ViewCacheCommand.php b/src/Illuminate/Foundation/Console/ViewCacheCommand.php index 468d92b9b08b..03f8d75d9a39 100644 --- a/src/Illuminate/Foundation/Console/ViewCacheCommand.php +++ b/src/Illuminate/Foundation/Console/ViewCacheCommand.php @@ -5,6 +5,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Collection; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo; @@ -43,13 +44,19 @@ class ViewCacheCommand extends Command */ public function handle() { - $this->call('view:clear'); + $this->callSilent('view:clear'); $this->paths()->each(function ($path) { + $prefix = $this->output->isVeryVerbose() ? 'DIR ' : ''; + + $this->components->task($prefix.$path, null, OutputInterface::VERBOSITY_VERBOSE); + $this->compileViews($this->bladeFilesIn([$path])); }); - $this->info('Blade templates cached successfully.'); + $this->newLine(); + + $this->components->info('Blade templates cached successfully.'); } /** @@ -63,8 +70,14 @@ protected function compileViews(Collection $views) $compiler = $this->laravel['view']->getEngineResolver()->resolve('blade')->getCompiler(); $views->map(function (SplFileInfo $file) use ($compiler) { + $this->components->task(' '.$file->getRelativePathname(), null, OutputInterface::VERBOSITY_VERY_VERBOSE); + $compiler->compile($file->getRealPath()); }); + + if ($this->output->isVeryVerbose()) { + $this->newLine(); + } } /** diff --git a/src/Illuminate/Foundation/Console/ViewClearCommand.php b/src/Illuminate/Foundation/Console/ViewClearCommand.php index 9aed80bb75b1..2480f965fff9 100644 --- a/src/Illuminate/Foundation/Console/ViewClearCommand.php +++ b/src/Illuminate/Foundation/Console/ViewClearCommand.php @@ -70,10 +70,14 @@ public function handle() throw new RuntimeException('View path not found.'); } + $this->laravel['view.engine.resolver'] + ->resolve('blade') + ->forgetCompiledOrNotExpired(); + foreach ($this->files->glob("{$path}/*") as $view) { $this->files->delete($view); } - $this->info('Compiled views cleared successfully.'); + $this->components->info('Compiled views cleared successfully.'); } } diff --git a/src/Illuminate/Foundation/Console/stubs/console.stub b/src/Illuminate/Foundation/Console/stubs/console.stub index f409b4f8a3e2..cef60a91ee2e 100644 --- a/src/Illuminate/Foundation/Console/stubs/console.stub +++ b/src/Illuminate/Foundation/Console/stubs/console.stub @@ -27,6 +27,6 @@ class {{ class }} extends Command */ public function handle() { - return 0; + return Command::SUCCESS; } } diff --git a/src/Illuminate/Foundation/Console/stubs/mail.stub b/src/Illuminate/Foundation/Console/stubs/mail.stub index f432a815cec6..45967d8ac3fb 100644 --- a/src/Illuminate/Foundation/Console/stubs/mail.stub +++ b/src/Illuminate/Foundation/Console/stubs/mail.stub @@ -5,6 +5,8 @@ namespace {{ namespace }}; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; +use Illuminate\Mail\Mailables\Content; +use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; class {{ class }} extends Mailable @@ -22,12 +24,36 @@ class {{ class }} extends Mailable } /** - * Build the message. + * Get the message envelope. * - * @return $this + * @return \Illuminate\Mail\Mailables\Envelope */ - public function build() + public function envelope() { - return $this->view('view.name'); + return new Envelope( + subject: '{{ subject }}', + ); + } + + /** + * Get the message content definition. + * + * @return \Illuminate\Mail\Mailables\Content + */ + public function content() + { + return new Content( + view: 'view.name', + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments() + { + return []; } } diff --git a/src/Illuminate/Foundation/Console/stubs/markdown-mail.stub b/src/Illuminate/Foundation/Console/stubs/markdown-mail.stub index e4c7cd4b93fa..76b6ccc53b25 100644 --- a/src/Illuminate/Foundation/Console/stubs/markdown-mail.stub +++ b/src/Illuminate/Foundation/Console/stubs/markdown-mail.stub @@ -5,6 +5,8 @@ namespace {{ namespace }}; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; +use Illuminate\Mail\Mailables\Content; +use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; class {{ class }} extends Mailable @@ -22,12 +24,36 @@ class {{ class }} extends Mailable } /** - * Build the message. + * Get the message envelope. * - * @return $this + * @return \Illuminate\Mail\Mailables\Envelope */ - public function build() + public function envelope() { - return $this->markdown('{{ view }}'); + return new Envelope( + subject: '{{ subject }}', + ); + } + + /** + * Get the message content definition. + * + * @return \Illuminate\Mail\Mailables\Content + */ + public function content() + { + return new Content( + markdown: '{{ view }}', + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments() + { + return []; } } diff --git a/src/Illuminate/Foundation/Console/stubs/markdown.stub b/src/Illuminate/Foundation/Console/stubs/markdown.stub index bc41428273d6..de9a155b34d3 100644 --- a/src/Illuminate/Foundation/Console/stubs/markdown.stub +++ b/src/Illuminate/Foundation/Console/stubs/markdown.stub @@ -1,12 +1,12 @@ -@component('mail::message') + # Introduction The body of your message. -@component('mail::button', ['url' => '']) + Button Text -@endcomponent + Thanks,
{{ config('app.name') }} -@endcomponent +
diff --git a/src/Illuminate/Foundation/Console/stubs/scope.stub b/src/Illuminate/Foundation/Console/stubs/scope.stub index b7dc351dacce..e49d265a65f4 100644 --- a/src/Illuminate/Foundation/Console/stubs/scope.stub +++ b/src/Illuminate/Foundation/Console/stubs/scope.stub @@ -19,5 +19,4 @@ class {{ class }} implements Scope { // } - } diff --git a/src/Illuminate/Foundation/Events/PublishingStubs.php b/src/Illuminate/Foundation/Events/PublishingStubs.php new file mode 100644 index 000000000000..914ff1e40d65 --- /dev/null +++ b/src/Illuminate/Foundation/Events/PublishingStubs.php @@ -0,0 +1,40 @@ +stubs = $stubs; + } + + /** + * Add a new stub to be published. + * + * @param string $path + * @param string $name + * @return $this + */ + public function add(string $path, string $name) + { + $this->stubs[$path] = $name; + + return $this; + } +} diff --git a/src/Illuminate/Foundation/Exceptions/Handler.php b/src/Illuminate/Foundation/Exceptions/Handler.php index dc3cf85f5b86..40a4dc4cbda0 100644 --- a/src/Illuminate/Foundation/Exceptions/Handler.php +++ b/src/Illuminate/Foundation/Exceptions/Handler.php @@ -6,6 +6,8 @@ use Exception; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; +use Illuminate\Console\View\Components\BulletList; +use Illuminate\Console\View\Components\Error; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Illuminate\Contracts\Foundation\ExceptionRenderer; @@ -30,6 +32,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use Symfony\Component\Console\Application as ConsoleApplication; +use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse; @@ -265,11 +268,7 @@ public function report(Throwable $e) $this->levels, fn ($level, $type) => $e instanceof $type, LogLevel::ERROR ); - $context = array_merge( - $this->exceptionContext($e), - $this->context(), - ['exception' => $e] - ); + $context = $this->buildExceptionContext($e); method_exists($logger, $level) ? $logger->{$level}($e->getMessage(), $context) @@ -300,6 +299,21 @@ protected function shouldntReport(Throwable $e) return ! is_null(Arr::first($dontReport, fn ($type) => $e instanceof $type)); } + /** + * Create the context array for logging the given exception. + * + * @param \Throwable $e + * @return array + */ + protected function buildExceptionContext(Throwable $e) + { + return array_merge( + $this->exceptionContext($e), + $this->context(), + ['exception' => $e] + ); + } + /** * Get the default exception context variables for logging. * @@ -375,7 +389,10 @@ protected function prepareException(Throwable $e) return match (true) { $e instanceof BackedEnumCaseNotFoundException => new NotFoundHttpException($e->getMessage(), $e), $e instanceof ModelNotFoundException => new NotFoundHttpException($e->getMessage(), $e), - $e instanceof AuthorizationException => new AccessDeniedHttpException($e->getMessage(), $e), + $e instanceof AuthorizationException && $e->hasStatus() => new HttpException( + $e->status(), $e->response()?->message() ?: (Response::$statusTexts[$e->status()] ?? 'Whoops, looks like something went wrong.'), $e + ), + $e instanceof AuthorizationException && ! $e->hasStatus() => new AccessDeniedHttpException($e->getMessage(), $e), $e instanceof TokenMismatchException => new HttpException(419, $e->getMessage(), $e), $e instanceof SuspiciousOperationException => new NotFoundHttpException('Bad hostname provided.', $e), $e instanceof RecordsNotFoundException => new NotFoundHttpException('Not found.', $e), @@ -526,16 +543,16 @@ protected function shouldReturnJson($request, Throwable $e) protected function prepareResponse($request, Throwable $e) { if (! $this->isHttpException($e) && config('app.debug')) { - return $this->toIlluminateResponse($this->convertExceptionToResponse($e), $e); + return $this->toIlluminateResponse($this->convertExceptionToResponse($e), $e)->prepare($request); } if (! $this->isHttpException($e)) { - $e = new HttpException(500, $e->getMessage()); + $e = new HttpException(500, $e->getMessage(), $e); } return $this->toIlluminateResponse( $this->renderHttpException($e), $e - ); + )->prepare($request); } /** @@ -717,6 +734,23 @@ protected function convertExceptionToArray(Throwable $e) */ public function renderForConsole($output, Throwable $e) { + if ($e instanceof CommandNotFoundException) { + $message = str($e->getMessage())->explode('.')->first(); + + if (! empty($alternatives = $e->getAlternatives())) { + $message .= '. Did you mean one of these?'; + + with(new Error($output))->render($message); + with(new BulletList($output))->render($e->getAlternatives()); + + $output->writeln(''); + } else { + with(new Error($output))->render($message); + } + + return; + } + (new ConsoleApplication)->renderThrowable($e, $output); } diff --git a/src/Illuminate/Foundation/Exceptions/Whoops/WhoopsExceptionRenderer.php b/src/Illuminate/Foundation/Exceptions/Whoops/WhoopsExceptionRenderer.php index 908d9a262866..82f707e0a628 100644 --- a/src/Illuminate/Foundation/Exceptions/Whoops/WhoopsExceptionRenderer.php +++ b/src/Illuminate/Foundation/Exceptions/Whoops/WhoopsExceptionRenderer.php @@ -3,9 +3,10 @@ namespace Illuminate\Foundation\Exceptions\Whoops; use Illuminate\Contracts\Foundation\ExceptionRenderer; -use function tap; use Whoops\Run as Whoops; +use function tap; + class WhoopsExceptionRenderer implements ExceptionRenderer { /** diff --git a/src/Illuminate/Foundation/Exceptions/views/402.blade.php b/src/Illuminate/Foundation/Exceptions/views/402.blade.php new file mode 100644 index 000000000000..3bc23efd2f3f --- /dev/null +++ b/src/Illuminate/Foundation/Exceptions/views/402.blade.php @@ -0,0 +1,5 @@ +@extends('errors::minimal') + +@section('title', __('Payment Required')) +@section('code', '402') +@section('message', __('Payment Required')) diff --git a/src/Illuminate/Foundation/Http/Exceptions/MaintenanceModeException.php b/src/Illuminate/Foundation/Http/Exceptions/MaintenanceModeException.php index 5553fde625d2..2770e62d918e 100644 --- a/src/Illuminate/Foundation/Http/Exceptions/MaintenanceModeException.php +++ b/src/Illuminate/Foundation/Http/Exceptions/MaintenanceModeException.php @@ -43,7 +43,7 @@ class MaintenanceModeException extends ServiceUnavailableHttpException * @param int $code * @return void */ - public function __construct($time, $retryAfter = null, $message = null, Throwable $previous = null, $code = 0) + public function __construct($time, $retryAfter = null, $message = null, ?Throwable $previous = null, $code = 0) { parent::__construct($retryAfter, $message, $previous, $code); diff --git a/src/Illuminate/Foundation/Http/FormRequest.php b/src/Illuminate/Foundation/Http/FormRequest.php index 4ddd0c50f11c..4f9310e6c3f6 100644 --- a/src/Illuminate/Foundation/Http/FormRequest.php +++ b/src/Illuminate/Foundation/Http/FormRequest.php @@ -109,10 +109,20 @@ protected function getValidatorInstance() */ protected function createDefaultValidator(ValidationFactory $factory) { - return $factory->make( - $this->validationData(), $this->container->call([$this, 'rules']), + $rules = method_exists($this, 'rules') ? $this->container->call([$this, 'rules']) : []; + + $validator = $factory->make( + $this->validationData(), $rules, $this->messages(), $this->attributes() )->stopOnFirstFailure($this->stopOnFirstFailure); + + if ($this->isPrecognitive()) { + $validator->setRules( + $this->filterPrecognitiveRules($validator->getRulesWithoutPlaceholders()) + ); + } + + return $validator; } /** @@ -196,7 +206,7 @@ protected function failedAuthorization() * @param array|null $keys * @return \Illuminate\Support\ValidatedInput|array */ - public function safe(array $keys = null) + public function safe(?array $keys = null) { return is_array($keys) ? $this->validator->safe()->only($keys) @@ -206,7 +216,7 @@ public function safe(array $keys = null) /** * Get the validated data from the request. * - * @param string|null $key + * @param array|int|string|null $key * @param mixed $default * @return mixed */ diff --git a/src/Illuminate/Foundation/Http/HtmlDumper.php b/src/Illuminate/Foundation/Http/HtmlDumper.php new file mode 100644 index 000000000000..2df09013fe65 --- /dev/null +++ b/src/Illuminate/Foundation/Http/HtmlDumper.php @@ -0,0 +1,140 @@ +'; + + /** + * Where the source should be placed on "non expanded" kind of dumps. + * + * @var string + */ + const NON_EXPANDED_SEPARATOR = "\n - HTML, - $url - ) + return $this->makeScriptTagWithAttributes( + $url, + $this->resolveScriptTagAttributes($src, $url, $chunk, $manifest) ); } /** - * Generate an appropriate tag for the given URL. + * Make a preload tag for the given chunk. + * + * @param string $src + * @param string $url + * @param array $chunk + * @param array $manifest + * @return string + */ + protected function makePreloadTagForChunk($src, $url, $chunk, $manifest) + { + $attributes = $this->resolvePreloadTagAttributes($src, $url, $chunk, $manifest); + + if ($attributes === false) { + return ''; + } + + $this->preloadedAssets[$url] = $this->parseAttributes( + Collection::make($attributes)->forget('href')->all() + ); + + return 'parseAttributes($attributes)).' />'; + } + + /** + * Resolve the attributes for the chunks generated script tag. + * + * @param string $src + * @param string $url + * @param array|null $chunk + * @param array|null $manifest + * @return array + */ + protected function resolveScriptTagAttributes($src, $url, $chunk, $manifest) + { + $attributes = $this->integrityKey !== false + ? ['integrity' => $chunk[$this->integrityKey] ?? false] + : []; + + foreach ($this->scriptTagAttributesResolvers as $resolver) { + $attributes = array_merge($attributes, $resolver($src, $url, $chunk, $manifest)); + } + + return $attributes; + } + + /** + * Resolve the attributes for the chunks generated stylesheet tag. + * + * @param string $src + * @param string $url + * @param array|null $chunk + * @param array|null $manifest + * @return array + */ + protected function resolveStylesheetTagAttributes($src, $url, $chunk, $manifest) + { + $attributes = $this->integrityKey !== false + ? ['integrity' => $chunk[$this->integrityKey] ?? false] + : []; + + foreach ($this->styleTagAttributesResolvers as $resolver) { + $attributes = array_merge($attributes, $resolver($src, $url, $chunk, $manifest)); + } + + return $attributes; + } + + /** + * Resolve the attributes for the chunks generated preload tag. + * + * @param string $src + * @param string $url + * @param array $chunk + * @param array $manifest + * @return array|false + */ + protected function resolvePreloadTagAttributes($src, $url, $chunk, $manifest) + { + $attributes = $this->isCssPath($url) ? [ + 'rel' => 'preload', + 'as' => 'style', + 'href' => $url, + 'nonce' => $this->nonce ?? false, + 'crossorigin' => $this->resolveStylesheetTagAttributes($src, $url, $chunk, $manifest)['crossorigin'] ?? false, + ] : [ + 'rel' => 'modulepreload', + 'href' => $url, + 'nonce' => $this->nonce ?? false, + 'crossorigin' => $this->resolveScriptTagAttributes($src, $url, $chunk, $manifest)['crossorigin'] ?? false, + ]; + + $attributes = $this->integrityKey !== false + ? array_merge($attributes, ['integrity' => $chunk[$this->integrityKey] ?? false]) + : $attributes; + + foreach ($this->preloadTagAttributesResolvers as $resolver) { + if (false === ($resolvedAttributes = $resolver($src, $url, $chunk, $manifest))) { + return false; + } + + $attributes = array_merge($attributes, $resolvedAttributes); + } + + return $attributes; + } + + /** + * Generate an appropriate tag for the given URL in HMR mode. + * + * @deprecated Will be removed in a future Laravel version. * * @param string $url * @return string @@ -125,23 +507,63 @@ protected function makeTag($url) /** * Generate a script tag for the given URL. * + * @deprecated Will be removed in a future Laravel version. + * * @param string $url * @return string */ protected function makeScriptTag($url) { - return sprintf('', $url); + return $this->makeScriptTagWithAttributes($url, []); } /** - * Generate a stylesheet tag for the given URL. + * Generate a stylesheet tag for the given URL in HMR mode. + * + * @deprecated Will be removed in a future Laravel version. * * @param string $url * @return string */ protected function makeStylesheetTag($url) { - return sprintf('', $url); + return $this->makeStylesheetTagWithAttributes($url, []); + } + + /** + * Generate a script tag with attributes for the given URL. + * + * @param string $url + * @param array $attributes + * @return string + */ + protected function makeScriptTagWithAttributes($url, $attributes) + { + $attributes = $this->parseAttributes(array_merge([ + 'type' => 'module', + 'src' => $url, + 'nonce' => $this->nonce ?? false, + ], $attributes)); + + return ''; + } + + /** + * Generate a link tag with attributes for the given URL. + * + * @param string $url + * @param array $attributes + * @return string + */ + protected function makeStylesheetTagWithAttributes($url, $attributes) + { + $attributes = $this->parseAttributes(array_merge([ + 'rel' => 'stylesheet', + 'href' => $url, + 'nonce' => $this->nonce ?? false, + ], $attributes)); + + return ''; } /** @@ -154,4 +576,187 @@ protected function isCssPath($path) { return preg_match('/\.(css|less|sass|scss|styl|stylus|pcss|postcss)$/', $path) === 1; } + + /** + * Parse the attributes into key="value" strings. + * + * @param array $attributes + * @return array + */ + protected function parseAttributes($attributes) + { + return Collection::make($attributes) + ->reject(fn ($value, $key) => in_array($value, [false, null], true)) + ->flatMap(fn ($value, $key) => $value === true ? [$key] : [$key => $value]) + ->map(fn ($value, $key) => is_int($key) ? $value : $key.'="'.$value.'"') + ->values() + ->all(); + } + + /** + * Generate React refresh runtime script. + * + * @return \Illuminate\Support\HtmlString|void + */ + public function reactRefresh() + { + if (! $this->isRunningHot()) { + return; + } + + $attributes = $this->parseAttributes([ + 'nonce' => $this->cspNonce(), + ]); + + return new HtmlString( + sprintf( + <<<'HTML' + + HTML, + implode(' ', $attributes), + $this->hotAsset('@react-refresh') + ) + ); + } + + /** + * Get the path to a given asset when running in HMR mode. + * + * @return string + */ + protected function hotAsset($asset) + { + return rtrim(file_get_contents($this->hotFile())).'/'.$asset; + } + + /** + * Get the URL for an asset. + * + * @param string $asset + * @param string|null $buildDirectory + * @return string + */ + public function asset($asset, $buildDirectory = null) + { + $buildDirectory ??= $this->buildDirectory; + + if ($this->isRunningHot()) { + return $this->hotAsset($asset); + } + + $chunk = $this->chunk($this->manifest($buildDirectory), $asset); + + return $this->assetPath($buildDirectory.'/'.$chunk['file']); + } + + /** + * Generate an asset path for the application. + * + * @param string $path + * @param bool|null $secure + * @return string + */ + protected function assetPath($path, $secure = null) + { + return asset($path, $secure); + } + + /** + * Get the the manifest file for the given build directory. + * + * @param string $buildDirectory + * @return array + * + * @throws \Exception + */ + protected function manifest($buildDirectory) + { + $path = $this->manifestPath($buildDirectory); + + if (! isset(static::$manifests[$path])) { + if (! is_file($path)) { + throw new Exception("Vite manifest not found at: {$path}"); + } + + static::$manifests[$path] = json_decode(file_get_contents($path), true); + } + + return static::$manifests[$path]; + } + + /** + * Get the path to the manifest file for the given build directory. + * + * @param string $buildDirectory + * @return string + */ + protected function manifestPath($buildDirectory) + { + return public_path($buildDirectory.'/'.$this->manifestFilename); + } + + /** + * Get a unique hash representing the current manifest, or null if there is no manifest. + * + * @param string|null $buildDirectory + * @return string|null + */ + public function manifestHash($buildDirectory = null) + { + $buildDirectory ??= $this->buildDirectory; + + if ($this->isRunningHot()) { + return null; + } + + if (! is_file($path = $this->manifestPath($buildDirectory))) { + return null; + } + + return md5_file($path) ?: null; + } + + /** + * Get the chunk for the given entry point / asset. + * + * @param array $manifest + * @param string $file + * @return array + * + * @throws \Exception + */ + protected function chunk($manifest, $file) + { + if (! isset($manifest[$file])) { + throw new Exception("Unable to locate file in Vite manifest: {$file}."); + } + + return $manifest[$file]; + } + + /** + * Determine if the HMR server is running. + * + * @return bool + */ + public function isRunningHot() + { + return is_file($this->hotFile()); + } + + /** + * Get the Vite tag content as a string of HTML. + * + * @return string + */ + public function toHtml() + { + return $this->__invoke($this->entryPoints)->toHtml(); + } } diff --git a/src/Illuminate/Foundation/helpers.php b/src/Illuminate/Foundation/helpers.php index b948c55d2b51..8540f487473a 100644 --- a/src/Illuminate/Foundation/helpers.php +++ b/src/Illuminate/Foundation/helpers.php @@ -17,6 +17,7 @@ use Illuminate\Foundation\Mix; use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Queue\CallQueuedClosure; +use Illuminate\Routing\Router; use Illuminate\Support\Facades\Date; use Illuminate\Support\HtmlString; use Symfony\Component\HttpFoundation\Response; @@ -455,12 +456,16 @@ function event(...$args) /** * Get a faker instance. * - * @param ?string $locale + * @param string|null $locale * @return \Faker\Generator */ function fake($locale = null) { - $locale ??= app('config')->get('app.faker_locale') ?? 'en_US'; + if (app()->bound('config')) { + $locale ??= app('config')->get('app.faker_locale'); + } + + $locale ??= 'en_US'; $abstract = \Faker\Generator::class.':'.$locale; @@ -601,6 +606,35 @@ function policy($class) } } +if (! function_exists('precognitive')) { + /** + * Handle a Precognition controller hook. + * + * @param null|callable $callable + * @return mixed + */ + function precognitive($callable = null) + { + $callable ??= function () { + // + }; + + $payload = $callable(function ($default, $precognition = null) { + $response = request()->isPrecognitive() + ? ($precognition ?? $default) + : $default; + + abort(Router::toResponse(request(), value($response))); + }); + + if (request()->isPrecognitive()) { + abort(204); + } + + return $payload; + } +} + if (! function_exists('public_path')) { /** * Get the path to the public folder. @@ -651,6 +685,38 @@ function report($exception) } } +if (! function_exists('report_if')) { + /** + * Report an exception if the given condition is true. + * + * @param bool $boolean + * @param \Throwable|string $exception + * @return void + */ + function report_if($boolean, $exception) + { + if ($boolean) { + report($exception); + } + } +} + +if (! function_exists('report_unless')) { + /** + * Report an exception unless the given condition is true. + * + * @param bool $boolean + * @param \Throwable|string $exception + * @return void + */ + function report_unless($boolean, $exception) + { + if (! $boolean) { + report($exception); + } + } +} + if (! function_exists('request')) { /** * Get an instance of the current request or an input item from the request. @@ -681,7 +747,7 @@ function request($key = null, $default = null) * * @param callable $callback * @param mixed $rescue - * @param bool $report + * @param bool|callable $report * @return mixed */ function rescue(callable $callback, $rescue = null, $report = true) @@ -689,7 +755,7 @@ function rescue(callable $callback, $rescue = null, $report = true) try { return $callback(); } catch (Throwable $e) { - if ($report) { + if (value($report, $e)) { report($e); } diff --git a/src/Illuminate/Hashing/AbstractHasher.php b/src/Illuminate/Hashing/AbstractHasher.php index 7ec087bee258..f10371290b1d 100644 --- a/src/Illuminate/Hashing/AbstractHasher.php +++ b/src/Illuminate/Hashing/AbstractHasher.php @@ -19,13 +19,13 @@ public function info($hashedValue) * Check the given plain value against a hash. * * @param string $value - * @param string $hashedValue + * @param string|null $hashedValue * @param array $options * @return bool */ public function check($value, $hashedValue, array $options = []) { - if (strlen($hashedValue) === 0) { + if (is_null($hashedValue) || strlen($hashedValue) === 0) { return false; } diff --git a/src/Illuminate/Hashing/Argon2IdHasher.php b/src/Illuminate/Hashing/Argon2IdHasher.php index 0a36a3000213..9aca47ac9c71 100644 --- a/src/Illuminate/Hashing/Argon2IdHasher.php +++ b/src/Illuminate/Hashing/Argon2IdHasher.php @@ -10,7 +10,7 @@ class Argon2IdHasher extends ArgonHasher * Check the given plain value against a hash. * * @param string $value - * @param string $hashedValue + * @param string|null $hashedValue * @param array $options * @return bool * @@ -22,7 +22,7 @@ public function check($value, $hashedValue, array $options = []) throw new RuntimeException('This password does not use the Argon2id algorithm.'); } - if (strlen($hashedValue) === 0) { + if (is_null($hashedValue) || strlen($hashedValue) === 0) { return false; } diff --git a/src/Illuminate/Hashing/HashManager.php b/src/Illuminate/Hashing/HashManager.php index 977ef2229302..5584f7a1d026 100644 --- a/src/Illuminate/Hashing/HashManager.php +++ b/src/Illuminate/Hashing/HashManager.php @@ -5,6 +5,9 @@ use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Support\Manager; +/** + * @mixin \Illuminate\Contracts\Hashing\Hasher + */ class HashManager extends Manager implements Hasher { /** diff --git a/src/Illuminate/Http/Client/Concerns/DeterminesStatusCode.php b/src/Illuminate/Http/Client/Concerns/DeterminesStatusCode.php new file mode 100644 index 000000000000..ab9132006ecf --- /dev/null +++ b/src/Illuminate/Http/Client/Concerns/DeterminesStatusCode.php @@ -0,0 +1,157 @@ +status() === 200; + } + + /** + * Determine if the response code was 201 "Created" response. + * + * @return bool + */ + public function created() + { + return $this->status() === 201; + } + + /** + * Determine if the response code was 202 "Accepted" response. + * + * @return bool + */ + public function accepted() + { + return $this->status() === 202; + } + + /** + * Determine if the response code was the given status code and the body has no content. + * + * @param int $status + * @return bool + */ + public function noContent($status = 204) + { + return $this->status() === $status && $this->body() === ''; + } + + /** + * Determine if the response code was a 301 "Moved Permanently". + * + * @return bool + */ + public function movedPermanently() + { + return $this->status() === 301; + } + + /** + * Determine if the response code was a 302 "Found" response. + * + * @return bool + */ + public function found() + { + return $this->status() === 302; + } + + /** + * Determine if the response was a 400 "Bad Request" response. + * + * @return bool + */ + public function badRequest() + { + return $this->status() === 400; + } + + /** + * Determine if the response was a 401 "Unauthorized" response. + * + * @return bool + */ + public function unauthorized() + { + return $this->status() === 401; + } + + /** + * Determine if the response was a 402 "Payment Required" response. + * + * @return bool + */ + public function paymentRequired() + { + return $this->status() === 402; + } + + /** + * Determine if the response was a 403 "Forbidden" response. + * + * @return bool + */ + public function forbidden() + { + return $this->status() === 403; + } + + /** + * Determine if the response was a 404 "Not Found" response. + * + * @return bool + */ + public function notFound() + { + return $this->status() === 404; + } + + /** + * Determine if the response was a 408 "Request Timeout" response. + * + * @return bool + */ + public function requestTimeout() + { + return $this->status() === 408; + } + + /** + * Determine if the response was a 409 "Conflict" response. + * + * @return bool + */ + public function conflict() + { + return $this->status() === 409; + } + + /** + * Determine if the response was a 422 "Unprocessable Entity" response. + * + * @return bool + */ + public function unprocessableEntity() + { + return $this->status() === 422; + } + + /** + * Determine if the response was a 429 "Too Many Requests" response. + * + * @return bool + */ + public function tooManyRequests() + { + return $this->status() === 429; + } +} diff --git a/src/Illuminate/Http/Client/Factory.php b/src/Illuminate/Http/Client/Factory.php index b0c7ba5b3861..dd0b67e166c4 100644 --- a/src/Illuminate/Http/Client/Factory.php +++ b/src/Illuminate/Http/Client/Factory.php @@ -12,49 +12,7 @@ use PHPUnit\Framework\Assert as PHPUnit; /** - * @method \Illuminate\Http\Client\PendingRequest accept(string $contentType) - * @method \Illuminate\Http\Client\PendingRequest acceptJson() - * @method \Illuminate\Http\Client\PendingRequest asForm() - * @method \Illuminate\Http\Client\PendingRequest asJson() - * @method \Illuminate\Http\Client\PendingRequest asMultipart() - * @method \Illuminate\Http\Client\PendingRequest async() - * @method \Illuminate\Http\Client\PendingRequest attach(string|array $name, string|resource $contents = '', string|null $filename = null, array $headers = []) - * @method \Illuminate\Http\Client\PendingRequest baseUrl(string $url) - * @method \Illuminate\Http\Client\PendingRequest beforeSending(callable $callback) - * @method \Illuminate\Http\Client\PendingRequest bodyFormat(string $format) - * @method \Illuminate\Http\Client\PendingRequest connectTimeout(int $seconds) - * @method \Illuminate\Http\Client\PendingRequest contentType(string $contentType) - * @method \Illuminate\Http\Client\PendingRequest dd() - * @method \Illuminate\Http\Client\PendingRequest dump() - * @method \Illuminate\Http\Client\PendingRequest maxRedirects(int $max) - * @method \Illuminate\Http\Client\PendingRequest retry(int $times, int $sleepMilliseconds = 0, ?callable $when = null, bool $throw = true) - * @method \Illuminate\Http\Client\PendingRequest sink(string|resource $to) - * @method \Illuminate\Http\Client\PendingRequest stub(callable $callback) - * @method \Illuminate\Http\Client\PendingRequest timeout(int $seconds) - * @method \Illuminate\Http\Client\PendingRequest withBasicAuth(string $username, string $password) - * @method \Illuminate\Http\Client\PendingRequest withBody(resource|string $content, string $contentType) - * @method \Illuminate\Http\Client\PendingRequest withCookies(array $cookies, string $domain) - * @method \Illuminate\Http\Client\PendingRequest withDigestAuth(string $username, string $password) - * @method \Illuminate\Http\Client\PendingRequest withHeaders(array $headers) - * @method \Illuminate\Http\Client\PendingRequest withMiddleware(callable $middleware) - * @method \Illuminate\Http\Client\PendingRequest withOptions(array $options) - * @method \Illuminate\Http\Client\PendingRequest withToken(string $token, string $type = 'Bearer') - * @method \Illuminate\Http\Client\PendingRequest withUserAgent(string $userAgent) - * @method \Illuminate\Http\Client\PendingRequest withoutRedirecting() - * @method \Illuminate\Http\Client\PendingRequest withoutVerifying() - * @method \Illuminate\Http\Client\PendingRequest throw(callable $callback = null) - * @method \Illuminate\Http\Client\PendingRequest throwIf($condition) - * @method \Illuminate\Http\Client\PendingRequest throwUnless($condition) - * @method array pool(callable $callback) - * @method \Illuminate\Http\Client\Response delete(string $url, array $data = []) - * @method \Illuminate\Http\Client\Response get(string $url, array|string|null $query = null) - * @method \Illuminate\Http\Client\Response head(string $url, array|string|null $query = null) - * @method \Illuminate\Http\Client\Response patch(string $url, array $data = []) - * @method \Illuminate\Http\Client\Response post(string $url, array $data = []) - * @method \Illuminate\Http\Client\Response put(string $url, array $data = []) - * @method \Illuminate\Http\Client\Response send(string $method, string $url, array $options = []) - * - * @see \Illuminate\Http\Client\PendingRequest + * @mixin \Illuminate\Http\Client\PendingRequest */ class Factory { @@ -110,7 +68,7 @@ class Factory * @param \Illuminate\Contracts\Events\Dispatcher|null $dispatcher * @return void */ - public function __construct(Dispatcher $dispatcher = null) + public function __construct(?Dispatcher $dispatcher = null) { $this->dispatcher = $dispatcher; @@ -154,7 +112,7 @@ public function sequence(array $responses = []) /** * Register a stub callable that will intercept requests and be able to return stub responses. * - * @param callable|array $callback + * @param callable|array|null $callback * @return $this */ public function fake($callback = null) @@ -231,7 +189,7 @@ public function stubUrl($url, $callback) } /** - * Indicate that an exception should not be thrown if any request is not faked. + * Indicate that an exception should be thrown if any request is not faked. * * @param bool $prevent * @return $this diff --git a/src/Illuminate/Http/Client/PendingRequest.php b/src/Illuminate/Http/Client/PendingRequest.php index 695f29bb6a59..5877434dd5f2 100644 --- a/src/Illuminate/Http/Client/PendingRequest.php +++ b/src/Illuminate/Http/Client/PendingRequest.php @@ -9,6 +9,7 @@ use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Exception\TransferException; use GuzzleHttp\HandlerStack; +use GuzzleHttp\UriTemplate\UriTemplate; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Http\Client\Events\ConnectionFailed; use Illuminate\Http\Client\Events\RequestSending; @@ -42,6 +43,13 @@ class PendingRequest */ protected $client; + /** + * The Guzzle HTTP handler. + * + * @var callable + */ + protected $handler; + /** * The base URL for the request. * @@ -49,6 +57,13 @@ class PendingRequest */ protected $baseUrl = ''; + /** + * The parameters that can be substituted into the URL. + * + * @var array + */ + protected $urlParameters = []; + /** * The request body format. * @@ -80,7 +95,7 @@ class PendingRequest /** * The transfer stats for the request. * - * \GuzzleHttp\TransferStats + * @var \GuzzleHttp\TransferStats */ protected $transferStats; @@ -98,6 +113,13 @@ class PendingRequest */ protected $throwCallback; + /** + * A callback to check if an exception should be thrown when a server or client error occurs. + * + * @var \Closure + */ + protected $throwIfCallback; + /** * The number of times to try the request. * @@ -195,7 +217,7 @@ class PendingRequest * @param \Illuminate\Http\Client\Factory|null $factory * @return void */ - public function __construct(Factory $factory = null) + public function __construct(?Factory $factory = null) { $this->factory = $factory; $this->middleware = new Collection; @@ -329,7 +351,9 @@ public function bodyFormat(string $format) */ public function contentType(string $contentType) { - return $this->withHeaders(['Content-Type' => $contentType]); + $this->options['headers']['Content-Type'] = $contentType; + + return $this; } /** @@ -423,6 +447,19 @@ public function withUserAgent($userAgent) }); } + /** + * Specify the URL parameters that can be substituted into the request URL. + * + * @param array $parameters + * @return $this + */ + public function withUrlParameters(array $parameters = []) + { + return tap($this, function () use ($parameters) { + $this->urlParameters = $parameters; + }); + } + /** * Specify the cookies that should be included with the request. * @@ -582,7 +619,7 @@ public function beforeSending($callback) * @param callable|null $callback * @return $this */ - public function throw(callable $callback = null) + public function throw(?callable $callback = null) { $this->throwCallback = $callback ?: fn () => null; @@ -592,12 +629,17 @@ public function throw(callable $callback = null) /** * Throw an exception if a server or client error occurred and the given condition evaluates to true. * - * @param bool $condition + * @param callable|bool $condition + * @param callable|null $throwCallback * @return $this */ public function throwIf($condition) { - return $condition ? $this->throw() : $this; + if (is_callable($condition)) { + $this->throwIfCallback = $condition; + } + + return $condition ? $this->throw(func_get_args()[1] ?? null) : $this; } /** @@ -694,7 +736,7 @@ public function post(string $url, $data = []) * @param array $data * @return \Illuminate\Http\Client\Response */ - public function patch($url, $data = []) + public function patch(string $url, $data = []) { return $this->send('PATCH', $url, [ $this->bodyFormat => $data, @@ -708,7 +750,7 @@ public function patch($url, $data = []) * @param array $data * @return \Illuminate\Http\Client\Response */ - public function put($url, $data = []) + public function put(string $url, $data = []) { return $this->send('PUT', $url, [ $this->bodyFormat => $data, @@ -722,7 +764,7 @@ public function put($url, $data = []) * @param array $data * @return \Illuminate\Http\Client\Response */ - public function delete($url, $data = []) + public function delete(string $url, $data = []) { return $this->send('DELETE', $url, empty($data) ? [] : [ $this->bodyFormat => $data, @@ -764,6 +806,8 @@ public function send(string $method, string $url, array $options = []) $url = ltrim(rtrim($this->baseUrl, '/').'/'.ltrim($url, '/'), '/'); } + $url = $this->expandUrlParameters($url); + $options = $this->parseHttpOptions($options); [$this->pendingBody, $this->pendingFiles] = [null, []]; @@ -790,7 +834,9 @@ public function send(string $method, string $url, array $options = []) throw $exception; } - if ($this->throwCallback) { + if ($this->throwCallback && + ($this->throwIfCallback === null || + call_user_func($this->throwIfCallback, $response))) { $response->throw($this->throwCallback); } @@ -817,6 +863,17 @@ public function send(string $method, string $url, array $options = []) }); } + /** + * Substitute the URL parameters in the given URL. + * + * @param string $url + * @return string + */ + protected function expandUrlParameters(string $url) + { + return UriTemplate::expand($url, $this->urlParameters); + } + /** * Parse the given HTTP options and set the appropriate additional options. * @@ -966,9 +1023,7 @@ protected function populateResponse(Response $response) */ public function buildClient() { - return $this->requestsReusableClient() - ? $this->getReusableClient() - : $this->createClient($this->buildHandlerStack()); + return $this->client ?? $this->createClient($this->buildHandlerStack()); } /** @@ -1012,7 +1067,7 @@ public function createClient($handlerStack) */ public function buildHandlerStack() { - return $this->pushHandlers(HandlerStack::create()); + return $this->pushHandlers(HandlerStack::create($this->handler)); } /** @@ -1157,7 +1212,7 @@ public function runBeforeSendingCallbacks($request, array $options) /** * Replace the given options with the current request options. * - * @param array $options + * @param array ...$options * @return array */ public function mergeOptions(...$options) @@ -1278,9 +1333,7 @@ public function setClient(Client $client) */ public function setHandler($handler) { - $this->client = $this->createClient( - $this->pushHandlers(HandlerStack::create($handler)) - ); + $this->handler = $handler; return $this; } diff --git a/src/Illuminate/Http/Client/Pool.php b/src/Illuminate/Http/Client/Pool.php index bedffcb1d652..da0ef7e736d8 100644 --- a/src/Illuminate/Http/Client/Pool.php +++ b/src/Illuminate/Http/Client/Pool.php @@ -36,7 +36,7 @@ class Pool * @param \Illuminate\Http\Client\Factory|null $factory * @return void */ - public function __construct(Factory $factory = null) + public function __construct(?Factory $factory = null) { $this->factory = $factory ?: new Factory(); diff --git a/src/Illuminate/Http/Client/Response.php b/src/Illuminate/Http/Client/Response.php index 39af3de0ce0c..f26886e2f80e 100644 --- a/src/Illuminate/Http/Client/Response.php +++ b/src/Illuminate/Http/Client/Response.php @@ -9,7 +9,7 @@ class Response implements ArrayAccess { - use Macroable { + use Concerns\DeterminesStatusCode, Macroable { __call as macroCall; } @@ -27,6 +27,20 @@ class Response implements ArrayAccess */ protected $decoded; + /** + * The request cookies. + * + * @var \GuzzleHttp\Cookie\CookieJar + */ + public $cookies; + + /** + * The transfer stats for the request. + * + * @var \GuzzleHttp\TransferStats|null + */ + public $transferStats; + /** * Create a new response instance. * @@ -71,7 +85,7 @@ public function json($key = null, $default = null) /** * Get the JSON decoded body of the response as an object. * - * @return object|array + * @return object|null */ public function object() { @@ -150,16 +164,6 @@ public function successful() return $this->status() >= 200 && $this->status() < 300; } - /** - * Determine if the response code was "OK". - * - * @return bool - */ - public function ok() - { - return $this->status() === 200; - } - /** * Determine if the response was a redirect. * @@ -170,26 +174,6 @@ public function redirect() return $this->status() >= 300 && $this->status() < 400; } - /** - * Determine if the response was a 401 "Unauthorized" response. - * - * @return bool - */ - public function unauthorized() - { - return $this->status() === 401; - } - - /** - * Determine if the response was a 403 "Forbidden" response. - * - * @return bool - */ - public function forbidden() - { - return $this->status() === 403; - } - /** * Determine if the response indicates a client or server error occurred. * @@ -315,14 +299,75 @@ public function throw() /** * Throw an exception if a server or client error occurred and the given condition evaluates to true. * - * @param bool $condition + * @param \Closure|bool $condition + * @param \Closure|null $throwCallback * @return $this * * @throws \Illuminate\Http\Client\RequestException */ public function throwIf($condition) { - return $condition ? $this->throw() : $this; + return value($condition, $this) ? $this->throw(func_get_args()[1] ?? null) : $this; + } + + /** + * Throw an exception if the response status code matches the given code. + * + * @param callable|int $statusCode + * @return $this + * + * @throws \Illuminate\Http\Client\RequestException + */ + public function throwIfStatus($statusCode) + { + if (is_callable($statusCode) && + $statusCode($this->status(), $this)) { + return $this->throw(); + } + + return $this->status() === $statusCode ? $this->throw() : $this; + } + + /** + * Throw an exception unless the response status code matches the given code. + * + * @param callable|int $statusCode + * @return $this + * + * @throws \Illuminate\Http\Client\RequestException + */ + public function throwUnlessStatus($statusCode) + { + if (is_callable($statusCode) && + ! $statusCode($this->status(), $this)) { + return $this->throw(); + } + + return $this->status() === $statusCode ? $this : $this->throw(); + } + + /** + * Throw an exception if the response status code is a 4xx level code. + * + * @return $this + * + * @throws \Illuminate\Http\Client\RequestException + */ + public function throwIfClientError() + { + return $this->clientError() ? $this->throw() : $this; + } + + /** + * Throw an exception if the response status code is a 5xx level code. + * + * @return $this + * + * @throws \Illuminate\Http\Client\RequestException + */ + public function throwIfServerError() + { + return $this->serverError() ? $this->throw() : $this; } /** diff --git a/src/Illuminate/Http/Client/ResponseSequence.php b/src/Illuminate/Http/Client/ResponseSequence.php index 9630454ae7a8..5925c03bca6b 100644 --- a/src/Illuminate/Http/Client/ResponseSequence.php +++ b/src/Illuminate/Http/Client/ResponseSequence.php @@ -51,8 +51,6 @@ public function __construct(array $responses) */ public function push($body = null, int $status = 200, array $headers = []) { - $body = is_array($body) ? json_encode($body) : $body; - return $this->pushResponse( Factory::response($body, $status, $headers) ); @@ -145,11 +143,11 @@ public function isEmpty() */ public function __invoke() { - if ($this->failWhenEmpty && count($this->responses) === 0) { + if ($this->failWhenEmpty && $this->isEmpty()) { throw new OutOfBoundsException('A request was made, but the response sequence is empty.'); } - if (! $this->failWhenEmpty && count($this->responses) === 0) { + if (! $this->failWhenEmpty && $this->isEmpty()) { return value($this->emptyResponse ?? Factory::response()); } diff --git a/src/Illuminate/Http/Concerns/CanBePrecognitive.php b/src/Illuminate/Http/Concerns/CanBePrecognitive.php new file mode 100644 index 000000000000..54cd79d49836 --- /dev/null +++ b/src/Illuminate/Http/Concerns/CanBePrecognitive.php @@ -0,0 +1,45 @@ +headers->has('Precognition-Validate-Only')) { + return $rules; + } + + return Collection::make($rules) + ->only(explode(',', $this->header('Precognition-Validate-Only'))) + ->all(); + } + + /** + * Determine if the request is attempting to be precognitive. + * + * @return bool + */ + public function isAttemptingPrecognition() + { + return $this->header('Precognition') === 'true'; + } + + /** + * Determine if the request is precognitive. + * + * @return bool + */ + public function isPrecognitive() + { + return $this->attributes->get('precognitive', false); + } +} diff --git a/src/Illuminate/Http/Concerns/InteractsWithInput.php b/src/Illuminate/Http/Concerns/InteractsWithInput.php index 4d17bf189a3d..11b8adfed971 100644 --- a/src/Illuminate/Http/Concerns/InteractsWithInput.php +++ b/src/Illuminate/Http/Concerns/InteractsWithInput.php @@ -120,7 +120,7 @@ public function hasAny($keys) * @param callable|null $default * @return $this|mixed */ - public function whenHas($key, callable $callback, callable $default = null) + public function whenHas($key, callable $callback, ?callable $default = null) { if ($this->has($key)) { return $callback(data_get($this->all(), $key)) ?: $this; @@ -198,7 +198,7 @@ public function anyFilled($keys) * @param callable|null $default * @return $this|mixed */ - public function whenFilled($key, callable $callback, callable $default = null) + public function whenFilled($key, callable $callback, ?callable $default = null) { if ($this->filled($key)) { return $callback(data_get($this->all(), $key)) ?: $this; @@ -225,7 +225,28 @@ public function missing($key) } /** - * Determine if the given input key is an empty string for "has". + * Apply the callback if the request is missing the given input item key. + * + * @param string $key + * @param callable $callback + * @param callable|null $default + * @return $this|mixed + */ + public function whenMissing($key, callable $callback, ?callable $default = null) + { + if ($this->missing($key)) { + return $callback(data_get($this->all(), $key)) ?: $this; + } + + if ($default) { + return $default(); + } + + return $this; + } + + /** + * Determine if the given input key is an empty string for "filled". * * @param string $key * @return bool @@ -322,6 +343,30 @@ public function boolean($key = null, $default = false) return filter_var($this->input($key, $default), FILTER_VALIDATE_BOOLEAN); } + /** + * Retrieve input as an integer value. + * + * @param string $key + * @param int $default + * @return int + */ + public function integer($key, $default = 0) + { + return intval($this->input($key, $default)); + } + + /** + * Retrieve input as a float value. + * + * @param string $key + * @param float $default + * @return float + */ + public function float($key, $default = 0.0) + { + return floatval($this->input($key, $default)); + } + /** * Retrieve input from the request as a Carbon instance. * @@ -329,6 +374,8 @@ public function boolean($key = null, $default = false) * @param string|null $format * @param string|null $tz * @return \Illuminate\Support\Carbon|null + * + * @throws \Carbon\Exceptions\InvalidFormatException */ public function date($key, $format = null, $tz = null) { @@ -343,6 +390,27 @@ public function date($key, $format = null, $tz = null) return Date::createFromFormat($format, $this->input($key), $tz); } + /** + * Retrieve input from the request as an enum. + * + * @template TEnum + * + * @param string $key + * @param class-string $enumClass + * @return TEnum|null + */ + public function enum($key, $enumClass) + { + if ($this->isNotFilled($key) || + ! function_exists('enum_exists') || + ! enum_exists($enumClass) || + ! method_exists($enumClass, 'tryFrom')) { + return null; + } + + return $enumClass::tryFrom($this->input($key)); + } + /** * Retrieve input from the request as a collection. * @@ -522,7 +590,7 @@ public function file($key = null, $default = null) * Retrieve a parameter item from a given source. * * @param string $source - * @param string $key + * @param string|null $key * @param string|array|null $default * @return string|array|null */ @@ -542,8 +610,8 @@ protected function retrieveItem($source, $key, $default) /** * Dump the request items and end the script. * - * @param mixed $keys - * @return void + * @param mixed ...$keys + * @return never */ public function dd(...$keys) { diff --git a/src/Illuminate/Http/Exceptions/PostTooLargeException.php b/src/Illuminate/Http/Exceptions/PostTooLargeException.php index d5bbc60dc44c..560b8af411b0 100644 --- a/src/Illuminate/Http/Exceptions/PostTooLargeException.php +++ b/src/Illuminate/Http/Exceptions/PostTooLargeException.php @@ -16,7 +16,7 @@ class PostTooLargeException extends HttpException * @param int $code * @return void */ - public function __construct($message = '', Throwable $previous = null, array $headers = [], $code = 0) + public function __construct($message = '', ?Throwable $previous = null, array $headers = [], $code = 0) { parent::__construct(413, $message, $previous, $headers, $code); } diff --git a/src/Illuminate/Http/Exceptions/ThrottleRequestsException.php b/src/Illuminate/Http/Exceptions/ThrottleRequestsException.php index d6c6e336806a..98dc78ddbd3e 100644 --- a/src/Illuminate/Http/Exceptions/ThrottleRequestsException.php +++ b/src/Illuminate/Http/Exceptions/ThrottleRequestsException.php @@ -16,7 +16,7 @@ class ThrottleRequestsException extends TooManyRequestsHttpException * @param int $code * @return void */ - public function __construct($message = '', Throwable $previous = null, array $headers = [], $code = 0) + public function __construct($message = '', ?Throwable $previous = null, array $headers = [], $code = 0) { parent::__construct(null, $message, $previous, $code, $headers); } diff --git a/src/Illuminate/Http/Middleware/AddLinkHeadersForPreloadedAssets.php b/src/Illuminate/Http/Middleware/AddLinkHeadersForPreloadedAssets.php new file mode 100644 index 000000000000..93ca06e958b5 --- /dev/null +++ b/src/Illuminate/Http/Middleware/AddLinkHeadersForPreloadedAssets.php @@ -0,0 +1,27 @@ +header('Link', Collection::make(Vite::preloadedAssets()) + ->map(fn ($attributes, $url) => "<{$url}>; ".implode('; ', $attributes)) + ->join(', ')); + } + }); + } +} diff --git a/src/Illuminate/Http/Middleware/SetCacheHeaders.php b/src/Illuminate/Http/Middleware/SetCacheHeaders.php index 770a523ae249..0a1a156674f3 100644 --- a/src/Illuminate/Http/Middleware/SetCacheHeaders.php +++ b/src/Illuminate/Http/Middleware/SetCacheHeaders.php @@ -4,6 +4,7 @@ use Closure; use Illuminate\Support\Carbon; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class SetCacheHeaders { @@ -21,7 +22,7 @@ public function handle($request, Closure $next, $options = []) { $response = $next($request); - if (! $request->isMethodCacheable() || ! $response->getContent()) { + if (! $request->isMethodCacheable() || (! $response->getContent() && ! $response instanceof BinaryFileResponse)) { return $response; } diff --git a/src/Illuminate/Http/Middleware/TrustProxies.php b/src/Illuminate/Http/Middleware/TrustProxies.php index fd2514e1479d..1fb4dd36cb87 100644 --- a/src/Illuminate/Http/Middleware/TrustProxies.php +++ b/src/Illuminate/Http/Middleware/TrustProxies.php @@ -10,7 +10,7 @@ class TrustProxies /** * The trusted proxies for the application. * - * @var array|string|null + * @var array|string|null */ protected $proxies; @@ -49,6 +49,10 @@ protected function setTrustedProxyIpAddresses(Request $request) { $trustedIps = $this->proxies() ?: config('trustedproxy.proxies'); + if (is_null($trustedIps) && laravel_cloud()) { + $trustedIps = '*'; + } + if ($trustedIps === '*' || $trustedIps === '**') { return $this->setTrustedProxyIpAddressesToTheCallingIp($request); } diff --git a/src/Illuminate/Http/RedirectResponse.php b/src/Illuminate/Http/RedirectResponse.php index 6edd57efe9a5..5c506ba6b00b 100755 --- a/src/Illuminate/Http/RedirectResponse.php +++ b/src/Illuminate/Http/RedirectResponse.php @@ -71,7 +71,7 @@ public function withCookies(array $cookies) * @param array|null $input * @return $this */ - public function withInput(array $input = null) + public function withInput(?array $input = null) { $this->session->flashInput($this->removeFilesFromInput( ! is_null($input) ? $input : $this->request->input() diff --git a/src/Illuminate/Http/Request.php b/src/Illuminate/Http/Request.php index 58346575002b..59996bb0a18e 100644 --- a/src/Illuminate/Http/Request.php +++ b/src/Illuminate/Http/Request.php @@ -11,7 +11,7 @@ use Illuminate\Support\Traits\Macroable; use RuntimeException; use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; -use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; use Symfony\Component\HttpFoundation\Session\SessionInterface; @@ -22,7 +22,8 @@ */ class Request extends SymfonyRequest implements Arrayable, ArrayAccess { - use Concerns\InteractsWithContentTypes, + use Concerns\CanBePrecognitive, + Concerns\InteractsWithContentTypes, Concerns\InteractsWithFlashData, Concerns\InteractsWithInput, Macroable; @@ -402,7 +403,7 @@ public function get(string $key, mixed $default = null): mixed public function json($key = null, $default = null) { if (! isset($this->json)) { - $this->json = new ParameterBag((array) json_decode($this->getContent(), true)); + $this->json = new InputBag((array) json_decode($this->getContent(), true)); } if (is_null($key)) { @@ -451,9 +452,9 @@ public static function createFrom(self $from, $to = null) $request->headers->replace($from->headers->all()); - $request->setLocale($from->getLocale()); + $request->setRequestLocale($from->getLocale()); - $request->setDefaultLocale($from->getDefaultLocale()); + $request->setDefaultRequestLocale($from->getDefaultLocale()); $request->setJson($from->json()); @@ -497,7 +498,7 @@ public static function createFromBase(SymfonyRequest $request) * * @return static */ - public function duplicate(array $query = null, array $request = null, array $attributes = null, array $cookies = null, array $files = null, array $server = null): static + public function duplicate(?array $query = null, ?array $request = null, ?array $attributes = null, ?array $cookies = null, ?array $files = null, ?array $server = null): static { return parent::duplicate($query, $request, $attributes, $cookies, $this->filterFiles($files), $server); } @@ -572,6 +573,28 @@ public function setLaravelSession($session) $this->session = $session; } + /** + * Set the locale for the request instance. + * + * @param string $locale + * @return void + */ + public function setRequestLocale(string $locale) + { + $this->locale = $locale; + } + + /** + * Set the default locale for the request instance. + * + * @param string $locale + * @return void + */ + public function setDefaultRequestLocale(string $locale) + { + $this->defaultLocale = $locale; + } + /** * Get the user making the request. * diff --git a/src/Illuminate/Http/Resources/ConditionallyLoadsAttributes.php b/src/Illuminate/Http/Resources/ConditionallyLoadsAttributes.php index 668a2b0ba056..72a0466897f7 100644 --- a/src/Illuminate/Http/Resources/ConditionallyLoadsAttributes.php +++ b/src/Illuminate/Http/Resources/ConditionallyLoadsAttributes.php @@ -3,6 +3,7 @@ namespace Illuminate\Http\Resources; use Illuminate\Support\Arr; +use Illuminate\Support\Str; trait ConditionallyLoadsAttributes { @@ -92,7 +93,7 @@ protected function removeMissingValues($data) } /** - * Retrieve a value based on a given condition. + * Retrieve a value if the given "condition" is truthy. * * @param bool $condition * @param mixed $value @@ -108,6 +109,21 @@ protected function when($condition, $value, $default = null) return func_num_args() === 3 ? value($default) : new MissingValue; } + /** + * Retrieve a value if the given "condition" is falsy. + * + * @param bool $condition + * @param mixed $value + * @param mixed $default + * @return \Illuminate\Http\Resources\MissingValue|mixed + */ + public function unless($condition, $value, $default = null) + { + $arguments = func_num_args() === 2 ? [$value] : [$value, $default]; + + return $this->when(! $condition, ...$arguments); + } + /** * Merge a value into the array. * @@ -120,7 +136,7 @@ protected function merge($value) } /** - * Merge a value based on a given condition. + * Merge a value if the given condition is truthy. * * @param bool $condition * @param mixed $value @@ -131,6 +147,18 @@ protected function mergeWhen($condition, $value) return $condition ? new MergeValue(value($value)) : new MissingValue; } + /** + * Merge a value unless the given condition is truthy. + * + * @param bool $condition + * @param mixed $value + * @return \Illuminate\Http\Resources\MergeValue|mixed + */ + protected function mergeUnless($condition, $value) + { + return ! $condition ? new MergeValue(value($value)) : new MissingValue; + } + /** * Merge the given attributes. * @@ -144,6 +172,43 @@ protected function attributes($attributes) ); } + /** + * Retrieve an attribute if it exists on the resource. + * + * @param string $attribute + * @param mixed $value + * @param mixed $default + * @return \Illuminate\Http\Resources\MissingValue|mixed + */ + public function whenHas($attribute, $value = null, $default = null) + { + if (func_num_args() < 3) { + $default = new MissingValue; + } + + if (! array_key_exists($attribute, $this->resource->getAttributes())) { + return value($default); + } + + return func_num_args() === 1 + ? $this->resource->{$attribute} + : value($value, $this->resource->{$attribute}); + } + + /** + * Retrieve a model attribute if it is null. + * + * @param mixed $value + * @param mixed $default + * @return \Illuminate\Http\Resources\MissingValue|mixed + */ + protected function whenNull($value, $default = null) + { + $arguments = func_num_args() == 1 ? [$value] : [$value, $default]; + + return $this->when(is_null($value), ...$arguments); + } + /** * Retrieve a model attribute if it is not null. * @@ -204,6 +269,37 @@ protected function whenLoaded($relationship, $value = null, $default = null) return value($value); } + /** + * Retrieve a relationship count if it exists. + * + * @param string $relationship + * @param mixed $value + * @param mixed $default + * @return \Illuminate\Http\Resources\MissingValue|mixed + */ + public function whenCounted($relationship, $value = null, $default = null) + { + if (func_num_args() < 3) { + $default = new MissingValue; + } + + $attribute = (string) Str::of($relationship)->snake()->finish('_count'); + + if (! isset($this->resource->getAttributes()[$attribute])) { + return value($default); + } + + if (func_num_args() === 1) { + return $this->resource->{$attribute}; + } + + if ($this->resource->{$attribute} === null) { + return; + } + + return value($value, $this->resource->{$attribute}); + } + /** * Execute a callback if the given pivot table has been loaded. * @@ -233,7 +329,7 @@ protected function whenPivotLoadedAs($accessor, $table, $value, $default = null) } return $this->when( - $this->resource->$accessor && + isset($this->resource->$accessor) && ($this->resource->$accessor instanceof $table || $this->resource->$accessor->getTable() === $table), ...[$value, $default] diff --git a/src/Illuminate/Http/Resources/Json/AnonymousResourceCollection.php b/src/Illuminate/Http/Resources/Json/AnonymousResourceCollection.php index a583136490a6..26f5c460ce63 100644 --- a/src/Illuminate/Http/Resources/Json/AnonymousResourceCollection.php +++ b/src/Illuminate/Http/Resources/Json/AnonymousResourceCollection.php @@ -11,6 +11,13 @@ class AnonymousResourceCollection extends ResourceCollection */ public $collects; + /** + * Indicates if the collection keys should be preserved. + * + * @var bool + */ + public $preserveKeys = false; + /** * Create a new anonymous resource collection. * diff --git a/src/Illuminate/Http/composer.json b/src/Illuminate/Http/composer.json index a5ad5da9a79d..f20efc8dc9ba 100755 --- a/src/Illuminate/Http/composer.json +++ b/src/Illuminate/Http/composer.json @@ -15,8 +15,9 @@ ], "require": { "php": "^8.0.2", - "ext-json": "*", + "ext-filter": "*", "fruitcake/php-cors": "^1.2", + "guzzlehttp/uri-template": "^1.0", "illuminate/collections": "^9.0", "illuminate/macroable": "^9.0", "illuminate/session": "^9.0", @@ -32,7 +33,7 @@ }, "suggest": { "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", - "guzzlehttp/guzzle": "Required to use the HTTP Client (^7.2)." + "guzzlehttp/guzzle": "Required to use the HTTP Client (^7.5)." }, "extra": { "branch-alias": { diff --git a/src/Illuminate/Log/LogManager.php b/src/Illuminate/Log/LogManager.php index 48e9868557b0..bf6f1d969d83 100644 --- a/src/Illuminate/Log/LogManager.php +++ b/src/Illuminate/Log/LogManager.php @@ -19,6 +19,9 @@ use Psr\Log\LoggerInterface; use Throwable; +/** + * @mixin \Illuminate\Log\Logger + */ class LogManager implements LoggerInterface { use ParsesLogConfiguration; @@ -417,7 +420,13 @@ protected function prepareHandlers(array $handlers) protected function prepareHandler(HandlerInterface $handler, array $config = []) { if (isset($config['action_level'])) { - $handler = new FingersCrossedHandler($handler, $this->actionLevel($config)); + $handler = new FingersCrossedHandler( + $handler, + $this->actionLevel($config), + 0, + true, + $config['stop_buffering'] ?? true + ); } if (! $handler instanceof FormattableHandlerInterface) { @@ -544,7 +553,7 @@ public function extend($driver, Closure $callback) * Unset the given channel instance. * * @param string|null $driver - * @return $this + * @return void */ public function forgetChannel($driver = null) { diff --git a/src/Illuminate/Log/Logger.php b/src/Illuminate/Log/Logger.php index 40626a03319d..baa6acef2731 100755 --- a/src/Illuminate/Log/Logger.php +++ b/src/Illuminate/Log/Logger.php @@ -40,7 +40,7 @@ class Logger implements LoggerInterface * @param \Illuminate\Contracts\Events\Dispatcher|null $dispatcher * @return void */ - public function __construct(LoggerInterface $logger, Dispatcher $dispatcher = null) + public function __construct(LoggerInterface $logger, ?Dispatcher $dispatcher = null) { $this->logger = $logger; $this->dispatcher = $dispatcher; diff --git a/src/Illuminate/Mail/Attachment.php b/src/Illuminate/Mail/Attachment.php index d8f8322fb24b..5c78ddf394dc 100644 --- a/src/Illuminate/Mail/Attachment.php +++ b/src/Illuminate/Mail/Attachment.php @@ -97,7 +97,7 @@ public static function fromStorageDisk($disk, $path) ->as($attachment->as ?? basename($path)) ->withMime($attachment->mime ?? $storage->mimeType($path)); - $dataStrategy(fn () => $storage->get($path), $attachment); + return $dataStrategy(fn () => $storage->get($path), $attachment); }); } @@ -152,4 +152,21 @@ public function attachTo($mail) fn ($data) => $mail->attachData($data(), $this->as, ['mime' => $this->mime]) ); } + + /** + * Determine if the given attachment is equivalent to this attachment. + * + * @param \Illuminate\Mail\Attachment $attachment + * @return bool + */ + public function isEquivalent(Attachment $attachment) + { + return $this->attachWith( + fn ($path) => [$path, ['as' => $this->as, 'mime' => $this->mime]], + fn ($data) => [$data(), ['as' => $this->as, 'mime' => $this->mime]], + ) === $attachment->attachWith( + fn ($path) => [$path, ['as' => $attachment->as, 'mime' => $attachment->mime]], + fn ($data) => [$data(), ['as' => $attachment->as, 'mime' => $attachment->mime]], + ); + } } diff --git a/src/Illuminate/Mail/MailManager.php b/src/Illuminate/Mail/MailManager.php index 03edae29bfcd..daa5a032209c 100644 --- a/src/Illuminate/Mail/MailManager.php +++ b/src/Illuminate/Mail/MailManager.php @@ -3,16 +3,19 @@ namespace Illuminate\Mail; use Aws\Ses\SesClient; +use Aws\SesV2\SesV2Client; use Closure; use Illuminate\Contracts\Mail\Factory as FactoryContract; use Illuminate\Log\LogManager; use Illuminate\Mail\Transport\ArrayTransport; use Illuminate\Mail\Transport\LogTransport; use Illuminate\Mail\Transport\SesTransport; +use Illuminate\Mail\Transport\SesV2Transport; use Illuminate\Support\Arr; use Illuminate\Support\Str; use InvalidArgumentException; use Psr\Log\LoggerInterface; +use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory; use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory; use Symfony\Component\Mailer\Transport\Dsn; @@ -153,7 +156,8 @@ public function createSymfonyTransport(array $config) return call_user_func($this->customCreators[$transport], $config); } - if (trim($transport ?? '') === '' || ! method_exists($this, $method = 'create'.ucfirst($transport).'Transport')) { + if (trim($transport ?? '') === '' || + ! method_exists($this, $method = 'create'.ucfirst(Str::camel($transport)).'Transport')) { throw new InvalidArgumentException("Unsupported mail transport [{$transport}]."); } @@ -170,8 +174,16 @@ protected function createSmtpTransport(array $config) { $factory = new EsmtpTransportFactory; + $scheme = $config['scheme'] ?? null; + + if (! $scheme) { + $scheme = ! empty($config['encryption']) && $config['encryption'] === 'tls' + ? (($config['port'] == 465) ? 'smtps' : 'smtp') + : ''; + } + $transport = $factory->create(new Dsn( - ! empty($config['encryption']) && $config['encryption'] === 'tls' ? (($config['port'] == 465) ? 'smtps' : 'smtp') : '', + $scheme, $config['host'], $config['username'] ?? null, $config['password'] ?? null, @@ -223,7 +235,7 @@ protected function createSendmailTransport(array $config) * Create an instance of the Symfony Amazon SES Transport driver. * * @param array $config - * @return \Symfony\Component\Mailer\Bridge\Amazon\Transport\SesApiAsyncAwsTransport + * @return \Illuminate\Mail\Transport\SesTransport */ protected function createSesTransport(array $config) { @@ -241,6 +253,28 @@ protected function createSesTransport(array $config) ); } + /** + * Create an instance of the Symfony Amazon SES V2 Transport driver. + * + * @param array $config + * @return \Illuminate\Mail\Transport\Se2VwTransport + */ + protected function createSesV2Transport(array $config) + { + $config = array_merge( + $this->app['config']->get('services.ses', []), + ['version' => 'latest'], + $config + ); + + $config = Arr::except($config, ['transport']); + + return new SesV2Transport( + new SesV2Client($this->addSesCredentials($config)), + $config['options'] ?? [] + ); + } + /** * Add the SES credentials to the configuration array. * @@ -253,7 +287,7 @@ protected function addSesCredentials(array $config) $config['credentials'] = Arr::only($config, ['key', 'secret', 'token']); } - return $config; + return Arr::except($config, ['token']); } /** @@ -270,11 +304,11 @@ protected function createMailTransport() * Create an instance of the Symfony Mailgun Transport driver. * * @param array $config - * @return \Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunApiTransport + * @return \Symfony\Component\Mailer\Transport\TransportInterface */ protected function createMailgunTransport(array $config) { - $factory = new MailgunTransportFactory(); + $factory = new MailgunTransportFactory(null, $this->getHttpClient($config)); if (! isset($config['secret'])) { $config = $this->app['config']->get('services.mailgun', []); @@ -296,7 +330,7 @@ protected function createMailgunTransport(array $config) */ protected function createPostmarkTransport(array $config) { - $factory = new PostmarkTransportFactory(); + $factory = new PostmarkTransportFactory(null, $this->getHttpClient($config)); $options = isset($config['message_stream_id']) ? ['message_stream' => $config['message_stream_id']] @@ -369,6 +403,21 @@ protected function createArrayTransport() return new ArrayTransport; } + /** + * Get a configured Symfony HTTP client instance. + * + * @return \Symfony\Contracts\HttpClient\HttpClientInterface|null + */ + protected function getHttpClient(array $config) + { + if ($options = ($config['client'] ?? false)) { + $maxHostConnections = Arr::pull($options, 'max_host_connections', 6); + $maxPendingPushes = Arr::pull($options, 'max_pending_pushes', 50); + + return HttpClient::create($options, $maxHostConnections, $maxPendingPushes); + } + } + /** * Set a global address on the mailer by type. * diff --git a/src/Illuminate/Mail/Mailable.php b/src/Illuminate/Mail/Mailable.php index 65825b851304..4c291f64c0da 100644 --- a/src/Illuminate/Mail/Mailable.php +++ b/src/Illuminate/Mail/Mailable.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Queue\Factory as Queue; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Renderable; +use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Support\Collection; use Illuminate\Support\HtmlString; use Illuminate\Support\Str; @@ -194,7 +195,7 @@ class Mailable implements MailableContract, Renderable public function send($mailer) { return $this->withLocale($this->locale, function () use ($mailer) { - Container::getInstance()->call([$this, 'build']); + $this->prepareMailableForDelivery(); $mailer = $mailer instanceof MailFactory ? $mailer->mailer($this->mailer) @@ -258,7 +259,7 @@ public function later($delay, Queue $queue) */ protected function newQueuedJob() { - return (new SendQueuedMailable($this)) + return Container::getInstance()->make(SendQueuedMailable::class, ['mailable' => $this]) ->through(array_merge( method_exists($this, 'middleware') ? $this->middleware() : [], $this->middleware ?? [] @@ -275,7 +276,7 @@ protected function newQueuedJob() public function render() { return $this->withLocale($this->locale, function () { - Container::getInstance()->call([$this, 'build']); + $this->prepareMailableForDelivery(); return Container::getInstance()->make('mailer')->render( $this->buildView(), $this->buildViewData() @@ -577,6 +578,10 @@ public function hasFrom($address, $name = null) */ public function to($address, $name = null) { + if (! $this->locale && $address instanceof HasLocalePreference) { + $this->locale($address->preferredLocale()); + } + return $this->setAddress($address, $name, 'to'); } @@ -689,6 +694,13 @@ protected function setAddress($address, $name = null, $property = 'to') ]; } + $this->{$property} = collect($this->{$property}) + ->reverse() + ->unique('address') + ->reverse() + ->values() + ->all(); + return $this; } @@ -728,6 +740,8 @@ protected function normalizeRecipient($recipient) return (object) ['email' => $recipient]; } elseif ($recipient instanceof Address) { return (object) ['email' => $recipient->getAddress(), 'name' => $recipient->getName()]; + } elseif ($recipient instanceof Mailables\Address) { + return (object) ['email' => $recipient->address, 'name' => $recipient->name]; } return $recipient; @@ -756,6 +770,10 @@ protected function hasRecipient($address, $name = null, $property = 'to') 'address' => $expected->email, ]; + if ($this->hasEnvelopeRecipient($expected['address'], $expected['name'], $property)) { + return true; + } + return collect($this->{$property})->contains(function ($actual) use ($expected) { if (! isset($expected['name'])) { return $actual['address'] == $expected['address']; @@ -765,6 +783,25 @@ protected function hasRecipient($address, $name = null, $property = 'to') }); } + /** + * Determine if the mailable "envelope" method defines a recipient. + * + * @param string $address + * @param string|null $name + * @param string $property + * @return bool + */ + private function hasEnvelopeRecipient($address, $name, $property) + { + return method_exists($this, 'envelope') && match ($property) { + 'from' => $this->envelope()->isFrom($address, $name), + 'to' => $this->envelope()->hasTo($address, $name), + 'cc' => $this->envelope()->hasCc($address, $name), + 'bcc' => $this->envelope()->hasBcc($address, $name), + 'replyTo' => $this->envelope()->hasReplyTo($address, $name), + }; + } + /** * Set the subject of the message. * @@ -786,7 +823,8 @@ public function subject($subject) */ public function hasSubject($subject) { - return $this->subject === $subject; + return $this->subject === $subject || + (method_exists($this, 'envelope') && $this->envelope()->hasSubject($subject)); } /** @@ -890,6 +928,81 @@ public function attach($file, array $options = []) return $this; } + /** + * Attach multiple files to the message. + * + * @param array $files + * @return $this + */ + public function attachMany($files) + { + foreach ($files as $file => $options) { + if (is_int($file)) { + $this->attach($options); + } else { + $this->attach($file, $options); + } + } + + return $this; + } + + /** + * Determine if the mailable has the given attachment. + * + * @param string|\Illuminate\Contracts\Mail\Attachable|\Illuminate\Mail\Attachment $file + * @param array $options + * @return bool + */ + public function hasAttachment($file, array $options = []) + { + if ($file instanceof Attachable) { + $file = $file->toMailAttachment(); + } + + if ($file instanceof Attachment && $this->hasEnvelopeAttachment($file)) { + return true; + } + + if ($file instanceof Attachment) { + $parts = $file->attachWith( + fn ($path) => [$path, ['as' => $file->as, 'mime' => $file->mime]], + fn ($data) => $this->hasAttachedData($data(), $file->as, ['mime' => $file->mime]) + ); + + if ($parts === true) { + return true; + } + + [$file, $options] = $parts === false + ? [null, []] + : $parts; + } + + return collect($this->attachments)->contains( + fn ($attachment) => $attachment['file'] === $file && array_filter($attachment['options']) === array_filter($options) + ); + } + + /** + * Determine if the mailable has the given envelope attachment. + * + * @param \Illuminate\Mail\Attachment $attachment + * @return bool + */ + private function hasEnvelopeAttachment($attachment) + { + if (! method_exists($this, 'envelope')) { + return false; + } + + $attachments = $this->attachments(); + + return Collection::make(is_object($attachments) ? [$attachments] : $attachments) + ->map(fn ($attached) => $attached instanceof Attachable ? $attached->toMailAttachment() : $attached) + ->contains(fn ($attached) => $attached->isEquivalent($attachment)); + } + /** * Attach a file to the message from storage. * @@ -926,6 +1039,38 @@ public function attachFromStorageDisk($disk, $path, $name = null, array $options return $this; } + /** + * Determine if the mailable has the given attachment from storage. + * + * @param string $path + * @param string|null $name + * @param array $options + * @return bool + */ + public function hasAttachmentFromStorage($path, $name = null, array $options = []) + { + return $this->hasAttachmentFromStorageDisk(null, $path, $name, $options); + } + + /** + * Determine if the mailable has the given attachment from a specific storage disk. + * + * @param string $disk + * @param string $path + * @param string|null $name + * @param array $options + * @return bool + */ + public function hasAttachmentFromStorageDisk($disk, $path, $name = null, array $options = []) + { + return collect($this->diskAttachments)->contains( + fn ($attachment) => $attachment['disk'] === $disk + && $attachment['path'] === $path + && $attachment['name'] === ($name ?? basename($path)) + && $attachment['options'] === $options + ); + } + /** * Attach in-memory data as an attachment. * @@ -945,6 +1090,23 @@ public function attachData($data, $name, array $options = []) return $this; } + /** + * Determine if the mailable has the given data as an attachment. + * + * @param string $data + * @param string $name + * @param array $options + * @return bool + */ + public function hasAttachedData($data, $name, array $options = []) + { + return collect($this->rawAttachments)->contains( + fn ($attachment) => $attachment['data'] === $data + && $attachment['name'] === $name + && array_filter($attachment['options']) === array_filter($options) + ); + } + /** * Add a tag header to the message when supported by the underlying transport. * @@ -958,6 +1120,18 @@ public function tag($value) return $this; } + /** + * Determine if the mailable has the given tag. + * + * @param string $value + * @return bool + */ + public function hasTag($value) + { + return in_array($value, $this->tags) || + (method_exists($this, 'envelope') && in_array($value, $this->envelope()->tags)); + } + /** * Add a metadata header to the message when supported by the underlying transport. * @@ -972,6 +1146,162 @@ public function metadata($key, $value) return $this; } + /** + * Determine if the mailable has the given metadata. + * + * @param string $key + * @param string $value + * @return bool + */ + public function hasMetadata($key, $value) + { + return (isset($this->metadata[$key]) && $this->metadata[$key] === $value) || + (method_exists($this, 'envelope') && $this->envelope()->hasMetadata($key, $value)); + } + + /** + * Assert that the mailable is from the given address. + * + * @param object|array|string $address + * @param string|null $name + * @return $this + */ + public function assertFrom($address, $name = null) + { + $recipient = $this->formatAssertionRecipient($address, $name); + + PHPUnit::assertTrue( + $this->hasFrom($address, $name), + "Email was not from expected address [{$recipient}]." + ); + + return $this; + } + + /** + * Assert that the mailable has the given recipient. + * + * @param object|array|string $address + * @param string|null $name + * @return $this + */ + public function assertTo($address, $name = null) + { + $recipient = $this->formatAssertionRecipient($address, $name); + + PHPUnit::assertTrue( + $this->hasTo($address, $name), + "Did not see expected recipient [{$recipient}] in email recipients." + ); + + return $this; + } + + /** + * Assert that the mailable has the given recipient. + * + * @param object|array|string $address + * @param string|null $name + * @return $this + */ + public function assertHasTo($address, $name = null) + { + return $this->assertTo($address, $name); + } + + /** + * Assert that the mailable has the given recipient. + * + * @param object|array|string $address + * @param string|null $name + * @return $this + */ + public function assertHasCc($address, $name = null) + { + $recipient = $this->formatAssertionRecipient($address, $name); + + PHPUnit::assertTrue( + $this->hasCc($address, $name), + "Did not see expected recipient [{$recipient}] in email recipients." + ); + + return $this; + } + + /** + * Assert that the mailable has the given recipient. + * + * @param object|array|string $address + * @param string|null $name + * @return $this + */ + public function assertHasBcc($address, $name = null) + { + $recipient = $this->formatAssertionRecipient($address, $name); + + PHPUnit::assertTrue( + $this->hasBcc($address, $name), + "Did not see expected recipient [{$recipient}] in email recipients." + ); + + return $this; + } + + /** + * Assert that the mailable has the given "reply to" address. + * + * @param object|array|string $address + * @param string|null $name + * @return $this + */ + public function assertHasReplyTo($address, $name = null) + { + $replyTo = $this->formatAssertionRecipient($address, $name); + + PHPUnit::assertTrue( + $this->hasReplyTo($address, $name), + "Did not see expected address [{$replyTo}] as email 'reply to' recipient." + ); + + return $this; + } + + /** + * Format the mailable recipient for display in an assertion message. + * + * @param object|array|string $address + * @param string|null $name + * @return string + */ + private function formatAssertionRecipient($address, $name = null) + { + if (! is_string($address)) { + $address = json_encode($address); + } + + if (filled($name)) { + $address .= ' ('.$name.')'; + } + + return $address; + } + + /** + * Assert that the mailable has the given subject. + * + * @param string $subject + * @return $this + */ + public function assertHasSubject($subject) + { + PHPUnit::assertTrue( + $this->hasSubject($subject), + "Did not see expected text [{$subject}] in email subject." + ); + + return $this; + } + /** * Assert that the given text is present in the HTML email body. * @@ -982,8 +1312,9 @@ public function assertSeeInHtml($string) { [$html, $text] = $this->renderForAssertions(); - PHPUnit::assertTrue( - str_contains($html, $string), + PHPUnit::assertStringContainsString( + $string, + $html, "Did not see expected text [{$string}] within email body." ); @@ -1000,8 +1331,9 @@ public function assertDontSeeInHtml($string) { [$html, $text] = $this->renderForAssertions(); - PHPUnit::assertFalse( - str_contains($html, $string), + PHPUnit::assertStringNotContainsString( + $string, + $html, "Saw unexpected text [{$string}] within email body." ); @@ -1033,8 +1365,9 @@ public function assertSeeInText($string) { [$html, $text] = $this->renderForAssertions(); - PHPUnit::assertTrue( - str_contains($text, $string), + PHPUnit::assertStringContainsString( + $string, + $text, "Did not see expected text [{$string}] within text email body." ); @@ -1051,8 +1384,9 @@ public function assertDontSeeInText($string) { [$html, $text] = $this->renderForAssertions(); - PHPUnit::assertFalse( - str_contains($text, $string), + PHPUnit::assertStringNotContainsString( + $string, + $text, "Saw unexpected text [{$string}] within text email body." ); @@ -1074,6 +1408,119 @@ public function assertSeeInOrderInText($strings) return $this; } + /** + * Assert the mailable has the given attachment. + * + * @param string|\Illuminate\Contracts\Mail\Attachable|\Illuminate\Mail\Attachment $file + * @param array $options + * @return $this + */ + public function assertHasAttachment($file, array $options = []) + { + $this->renderForAssertions(); + + PHPUnit::assertTrue( + $this->hasAttachment($file, $options), + 'Did not find the expected attachment.' + ); + + return $this; + } + + /** + * Assert the mailable has the given data as an attachment. + * + * @param string $data + * @param string $name + * @param array $options + * @return $this + */ + public function assertHasAttachedData($data, $name, array $options = []) + { + $this->renderForAssertions(); + + PHPUnit::assertTrue( + $this->hasAttachedData($data, $name, $options), + 'Did not find the expected attachment.' + ); + + return $this; + } + + /** + * Assert the mailable has the given attachment from storage. + * + * @param string $path + * @param string|null $name + * @param array $options + * @return $this + */ + public function assertHasAttachmentFromStorage($path, $name = null, array $options = []) + { + $this->renderForAssertions(); + + PHPUnit::assertTrue( + $this->hasAttachmentFromStorage($path, $name, $options), + 'Did not find the expected attachment.' + ); + + return $this; + } + + /** + * Assert the mailable has the given attachment from a specific storage disk. + * + * @param string $disk + * @param string $path + * @param string|null $name + * @param array $options + * @return $this + */ + public function assertHasAttachmentFromStorageDisk($disk, $path, $name = null, array $options = []) + { + $this->renderForAssertions(); + + PHPUnit::assertTrue( + $this->hasAttachmentFromStorageDisk($disk, $path, $name, $options), + 'Did not find the expected attachment.' + ); + + return $this; + } + + /** + * Assert that the mailable has the given tag. + * + * @param string $tag + * @return $this + */ + public function assertHasTag($tag) + { + PHPUnit::assertTrue( + $this->hasTag($tag), + "Did not see expected tag [{$tag}] in email tags." + ); + + return $this; + } + + /** + * Assert that the mailable has the given metadata. + * + * @param string $key + * @param string $value + * @return $this + */ + public function assertHasMetadata($key, $value) + { + PHPUnit::assertTrue( + $this->hasMetadata($key, $value), + "Did not see expected key [{$key}] and value [{$value}] in email metadata." + ); + + return $this; + } + /** * Render the HTML and plain-text version of the mailable into views for assertions. * @@ -1088,7 +1535,7 @@ protected function renderForAssertions() } return $this->assertionableRenderStrings = $this->withLocale($this->locale, function () { - Container::getInstance()->call([$this, 'build']); + $this->prepareMailableForDelivery(); $html = Container::getInstance()->make('mailer')->render( $view = $this->buildView(), $this->buildViewData() @@ -1110,6 +1557,148 @@ protected function renderForAssertions() }); } + /** + * Prepare the mailable instance for delivery. + * + * @return void + */ + private function prepareMailableForDelivery() + { + if (method_exists($this, 'build')) { + Container::getInstance()->call([$this, 'build']); + } + + $this->ensureHeadersAreHydrated(); + $this->ensureEnvelopeIsHydrated(); + $this->ensureContentIsHydrated(); + $this->ensureAttachmentsAreHydrated(); + } + + /** + * Ensure the mailable's headers are hydrated from the "headers" method. + * + * @return void + */ + private function ensureHeadersAreHydrated() + { + if (! method_exists($this, 'headers')) { + return; + } + + $headers = $this->headers(); + + $this->withSymfonyMessage(function ($message) use ($headers) { + if ($headers->messageId) { + $message->getHeaders()->addIdHeader('Message-Id', $headers->messageId); + } + + if (count($headers->references) > 0) { + $message->getHeaders()->addTextHeader('References', $headers->referencesString()); + } + + foreach ($headers->text as $key => $value) { + $message->getHeaders()->addTextHeader($key, $value); + } + }); + } + + /** + * Ensure the mailable's "envelope" data is hydrated from the "envelope" method. + * + * @return void + */ + private function ensureEnvelopeIsHydrated() + { + if (! method_exists($this, 'envelope')) { + return; + } + + $envelope = $this->envelope(); + + if (isset($envelope->from)) { + $this->from($envelope->from->address, $envelope->from->name); + } + + foreach (['to', 'cc', 'bcc', 'replyTo'] as $type) { + foreach ($envelope->{$type} as $address) { + $this->{$type}($address->address, $address->name); + } + } + + if ($envelope->subject) { + $this->subject($envelope->subject); + } + + foreach ($envelope->tags as $tag) { + $this->tag($tag); + } + + foreach ($envelope->metadata as $key => $value) { + $this->metadata($key, $value); + } + + foreach ($envelope->using as $callback) { + $this->withSymfonyMessage($callback); + } + } + + /** + * Ensure the mailable's content is hydrated from the "content" method. + * + * @return void + */ + private function ensureContentIsHydrated() + { + if (! method_exists($this, 'content')) { + return; + } + + $content = $this->content(); + + if ($content->view) { + $this->view($content->view); + } + + if ($content->html) { + $this->view($content->html); + } + + if ($content->text) { + $this->text($content->text); + } + + if ($content->markdown) { + $this->markdown($content->markdown); + } + + if ($content->htmlString) { + $this->html($content->htmlString); + } + + foreach ($content->with as $key => $value) { + $this->with($key, $value); + } + } + + /** + * Ensure the mailable's attachments are hydrated from the "attachments" method. + * + * @return void + */ + private function ensureAttachmentsAreHydrated() + { + if (! method_exists($this, 'attachments')) { + return; + } + + $attachments = $this->attachments(); + + Collection::make(is_object($attachments) ? [$attachments] : $attachments) + ->each(function ($attachment) { + $this->attach($attachment); + }); + } + /** * Set the name of the mailer that should send the message. * diff --git a/src/Illuminate/Mail/Mailables/Address.php b/src/Illuminate/Mail/Mailables/Address.php new file mode 100644 index 000000000000..7a9ed2aa66cd --- /dev/null +++ b/src/Illuminate/Mail/Mailables/Address.php @@ -0,0 +1,33 @@ +address = $address; + $this->name = $name; + } +} diff --git a/src/Illuminate/Mail/Mailables/Attachment.php b/src/Illuminate/Mail/Mailables/Attachment.php new file mode 100644 index 000000000000..e11d2e96e169 --- /dev/null +++ b/src/Illuminate/Mail/Mailables/Attachment.php @@ -0,0 +1,10 @@ +view = $view; + $this->html = $html; + $this->text = $text; + $this->markdown = $markdown; + $this->with = $with; + $this->htmlString = $htmlString; + } + + /** + * Set the view for the message. + * + * @param string $view + * @return $this + */ + public function view(string $view) + { + $this->view = $view; + + return $this; + } + + /** + * Set the view for the message. + * + * @param string $view + * @return $this + */ + public function html(string $view) + { + return $this->view($view); + } + + /** + * Set the plain text view for the message. + * + * @param string $view + * @return $this + */ + public function text(string $view) + { + $this->text = $view; + + return $this; + } + + /** + * Set the Markdown view for the message. + * + * @param string $view + * @return $this + */ + public function markdown(string $view) + { + $this->markdown = $view; + + return $this; + } + + /** + * Set the pre-rendered HTML for the message. + * + * @param string $html + * @return $this + */ + public function htmlString(string $html) + { + $this->htmlString = $html; + + return $this; + } + + /** + * Add a piece of view data to the message. + * + * @param string $key + * @param mixed|null $value + * @return $this + */ + public function with($key, $value = null) + { + if (is_array($key)) { + $this->with = array_merge($this->with, $key); + } else { + $this->with[$key] = $value; + } + + return $this; + } +} diff --git a/src/Illuminate/Mail/Mailables/Envelope.php b/src/Illuminate/Mail/Mailables/Envelope.php new file mode 100644 index 000000000000..05b165a46971 --- /dev/null +++ b/src/Illuminate/Mail/Mailables/Envelope.php @@ -0,0 +1,369 @@ +from = is_string($from) ? new Address($from) : $from; + $this->to = $this->normalizeAddresses($to); + $this->cc = $this->normalizeAddresses($cc); + $this->bcc = $this->normalizeAddresses($bcc); + $this->replyTo = $this->normalizeAddresses($replyTo); + $this->subject = $subject; + $this->tags = $tags; + $this->metadata = $metadata; + $this->using = Arr::wrap($using); + } + + /** + * Normalize the given array of addresses. + * + * @param array $addresses + * @return array + */ + protected function normalizeAddresses($addresses) + { + return collect($addresses)->map(function ($address) { + return is_string($address) ? new Address($address) : $address; + })->all(); + } + + /** + * Specify who the message will be "from". + * + * @param \Illuminate\Mail\Mailables\Address|string $address + * @param string|null $name + * @return $this + */ + public function from(Address|string $address, $name = null) + { + $this->from = is_string($address) ? new Address($address, $name) : $address; + + return $this; + } + + /** + * Add a "to" recipient to the message envelope. + * + * @param \Illuminate\Mail\Mailables\Address|array|string $address + * @param string|null $name + * @return $this + */ + public function to(Address|array|string $address, $name = null) + { + $this->to = array_merge($this->to, $this->normalizeAddresses( + is_string($name) ? [new Address($address, $name)] : Arr::wrap($address), + )); + + return $this; + } + + /** + * Add a "cc" recipient to the message envelope. + * + * @param \Illuminate\Mail\Mailables\Address|array|string $address + * @param string|null $name + * @return $this + */ + public function cc(Address|array|string $address, $name = null) + { + $this->cc = array_merge($this->cc, $this->normalizeAddresses( + is_string($name) ? [new Address($address, $name)] : Arr::wrap($address), + )); + + return $this; + } + + /** + * Add a "bcc" recipient to the message envelope. + * + * @param \Illuminate\Mail\Mailables\Address|array|string $address + * @param string|null $name + * @return $this + */ + public function bcc(Address|array|string $address, $name = null) + { + $this->bcc = array_merge($this->bcc, $this->normalizeAddresses( + is_string($name) ? [new Address($address, $name)] : Arr::wrap($address), + )); + + return $this; + } + + /** + * Add a "reply to" recipient to the message envelope. + * + * @param \Illuminate\Mail\Mailables\Address|array|string $address + * @param string|null $name + * @return $this + */ + public function replyTo(Address|array|string $address, $name = null) + { + $this->replyTo = array_merge($this->replyTo, $this->normalizeAddresses( + is_string($name) ? [new Address($address, $name)] : Arr::wrap($address), + )); + + return $this; + } + + /** + * Set the subject of the message. + * + * @param string $subject + * @return $this + */ + public function subject(string $subject) + { + $this->subject = $subject; + + return $this; + } + + /** + * Add "tags" to the message. + * + * @param array $tags + * @return $this + */ + public function tags(array $tags) + { + $this->tags = array_merge($this->tags, $tags); + + return $this; + } + + /** + * Add a "tag" to the message. + * + * @param string $tag + * @return $this + */ + public function tag(string $tag) + { + $this->tags[] = $tag; + + return $this; + } + + /** + * Add metadata to the message. + * + * @param string $key + * @param string|int $value + * @return $this + */ + public function metadata(string $key, string|int $value) + { + $this->metadata[$key] = $value; + + return $this; + } + + /** + * Add a Symfony Message customization callback to the message. + * + * @param \Closure $callback + * @return $this + */ + public function using(Closure $callback) + { + $this->using[] = $callback; + + return $this; + } + + /** + * Determine if the message is from the given address. + * + * @param string $address + * @param string|null $name + * @return bool + */ + public function isFrom(string $address, ?string $name = null) + { + if (is_null($name)) { + return $this->from->address === $address; + } + + return $this->from->address === $address && + $this->from->name === $name; + } + + /** + * Determine if the message has the given address as a recipient. + * + * @param string $address + * @param string|null $name + * @return bool + */ + public function hasTo(string $address, ?string $name = null) + { + return $this->hasRecipient($this->to, $address, $name); + } + + /** + * Determine if the message has the given address as a "cc" recipient. + * + * @param string $address + * @param string|null $name + * @return bool + */ + public function hasCc(string $address, ?string $name = null) + { + return $this->hasRecipient($this->cc, $address, $name); + } + + /** + * Determine if the message has the given address as a "bcc" recipient. + * + * @param string $address + * @param string|null $name + * @return bool + */ + public function hasBcc(string $address, ?string $name = null) + { + return $this->hasRecipient($this->bcc, $address, $name); + } + + /** + * Determine if the message has the given address as a "reply to" recipient. + * + * @param string $address + * @param string|null $name + * @return bool + */ + public function hasReplyTo(string $address, ?string $name = null) + { + return $this->hasRecipient($this->replyTo, $address, $name); + } + + /** + * Determine if the message has the given recipient. + * + * @param array $recipients + * @param string $address + * @param string|null $name + * @return bool + */ + protected function hasRecipient(array $recipients, string $address, ?string $name = null) + { + return collect($recipients)->contains(function ($recipient) use ($address, $name) { + if (is_null($name)) { + return $recipient->address === $address; + } + + return $recipient->address === $address && + $recipient->name === $name; + }); + } + + /** + * Determine if the message has the given subject. + * + * @param string $subject + * @return bool + */ + public function hasSubject(string $subject) + { + return $this->subject === $subject; + } + + /** + * Determine if the message has the given metadata. + * + * @param string $key + * @param string $value + * @return bool + */ + public function hasMetadata(string $key, string $value) + { + return isset($this->metadata[$key]) && (string) $this->metadata[$key] === $value; + } +} diff --git a/src/Illuminate/Mail/Mailables/Headers.php b/src/Illuminate/Mail/Mailables/Headers.php new file mode 100644 index 000000000000..0428f250416b --- /dev/null +++ b/src/Illuminate/Mail/Mailables/Headers.php @@ -0,0 +1,100 @@ +messageId = $messageId; + $this->references = $references; + $this->text = $text; + } + + /** + * Set the message ID. + * + * @param string $messageId + * @return $this + */ + public function messageId(string $messageId) + { + $this->messageId = $messageId; + + return $this; + } + + /** + * Set the message IDs referenced by this message. + * + * @param array $references + * @return $this + */ + public function references(array $references) + { + $this->references = array_merge($this->references, $references); + + return $this; + } + + /** + * Set the headers for this message. + * + * @param array $references + * @return $this + */ + public function text(array $text) + { + $this->text = array_merge($this->text, $text); + + return $this; + } + + /** + * Get the references header as a string. + * + * @return string + */ + public function referencesString(): string + { + return collect($this->references)->map(function ($messageId) { + return Str::finish(Str::start($messageId, '<'), '>'); + })->implode(' '); + } +} diff --git a/src/Illuminate/Mail/Mailer.php b/src/Illuminate/Mail/Mailer.php index 4a253cf29a19..ed2c62bc34a1 100755 --- a/src/Illuminate/Mail/Mailer.php +++ b/src/Illuminate/Mail/Mailer.php @@ -95,7 +95,7 @@ class Mailer implements MailerContract, MailQueueContract * @param \Illuminate\Contracts\Events\Dispatcher|null $events * @return void */ - public function __construct(string $name, Factory $views, TransportInterface $transport, Dispatcher $events = null) + public function __construct(string $name, Factory $views, TransportInterface $transport, ?Dispatcher $events = null) { $this->name = $name; $this->views = $views; @@ -253,6 +253,8 @@ public function send($view, array $data = [], $callback = null) return $this->sendMailable($view); } + $data['mailer'] = $this->name; + // First we need to parse the view, which could either be a string or an array // containing both an HTML and plain text versions of the view which should // be used when sending an e-mail. We will extract both of them out here. diff --git a/src/Illuminate/Mail/Message.php b/src/Illuminate/Mail/Message.php index 88e83eee8eaa..a0420b5af748 100755 --- a/src/Illuminate/Mail/Message.php +++ b/src/Illuminate/Mail/Message.php @@ -315,7 +315,7 @@ public function attach($file, array $options = []) /** * Attach in-memory data as an attachment. * - * @param string $data + * @param string|resource $data * @param string $name * @param array $options * @return $this @@ -366,7 +366,7 @@ function ($data) use ($file) { /** * Embed in-memory data in the message and get the CID. * - * @param string $data + * @param string|resource $data * @param string $name * @param string|null $contentType * @return string diff --git a/src/Illuminate/Mail/SendQueuedMailable.php b/src/Illuminate/Mail/SendQueuedMailable.php index d974bb9f0d0c..7f2023221d8e 100644 --- a/src/Illuminate/Mail/SendQueuedMailable.php +++ b/src/Illuminate/Mail/SendQueuedMailable.php @@ -6,10 +6,11 @@ use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Mailable as MailableContract; use Illuminate\Contracts\Queue\ShouldBeEncrypted; +use Illuminate\Queue\InteractsWithQueue; class SendQueuedMailable { - use Queueable; + use Queueable, InteractsWithQueue; /** * The mailable message instance. @@ -32,6 +33,13 @@ class SendQueuedMailable */ public $timeout; + /** + * The maximum number of unhandled exceptions to allow before failing. + * + * @return int|null + */ + public $maxExceptions; + /** * Indicates if the job should be encrypted. * @@ -50,6 +58,7 @@ public function __construct(MailableContract $mailable) $this->mailable = $mailable; $this->tries = property_exists($mailable, 'tries') ? $mailable->tries : null; $this->timeout = property_exists($mailable, 'timeout') ? $mailable->timeout : null; + $this->maxExceptions = property_exists($mailable, 'maxExceptions') ? $mailable->maxExceptions : null; $this->afterCommit = property_exists($mailable, 'afterCommit') ? $mailable->afterCommit : null; $this->shouldBeEncrypted = $mailable instanceof ShouldBeEncrypted; } diff --git a/src/Illuminate/Mail/Transport/ArrayTransport.php b/src/Illuminate/Mail/Transport/ArrayTransport.php index dc26ed69d90b..02ba21d90c70 100644 --- a/src/Illuminate/Mail/Transport/ArrayTransport.php +++ b/src/Illuminate/Mail/Transport/ArrayTransport.php @@ -30,7 +30,7 @@ public function __construct() /** * {@inheritdoc} */ - public function send(RawMessage $message, Envelope $envelope = null): ?SentMessage + public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage { return $this->messages[] = new SentMessage($message, $envelope ?? Envelope::create($message)); } diff --git a/src/Illuminate/Mail/Transport/LogTransport.php b/src/Illuminate/Mail/Transport/LogTransport.php index d9ec8ac09d7e..cd2db7e771c3 100644 --- a/src/Illuminate/Mail/Transport/LogTransport.php +++ b/src/Illuminate/Mail/Transport/LogTransport.php @@ -31,7 +31,7 @@ public function __construct(LoggerInterface $logger) /** * {@inheritdoc} */ - public function send(RawMessage $message, Envelope $envelope = null): ?SentMessage + public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage { $this->logger->debug($message->toString()); diff --git a/src/Illuminate/Mail/Transport/SesTransport.php b/src/Illuminate/Mail/Transport/SesTransport.php index d6a64da89d67..9db7734c62ad 100644 --- a/src/Illuminate/Mail/Transport/SesTransport.php +++ b/src/Illuminate/Mail/Transport/SesTransport.php @@ -88,16 +88,6 @@ protected function doSend(SentMessage $message): void $message->getOriginalMessage()->getHeaders()->addHeader('X-SES-Message-ID', $messageId); } - /** - * Get the string representation of the transport. - * - * @return string - */ - public function __toString(): string - { - return 'ses'; - } - /** * Get the Amazon SES client for the SesTransport instance. * @@ -128,4 +118,14 @@ public function setOptions(array $options) { return $this->options = $options; } + + /** + * Get the string representation of the transport. + * + * @return string + */ + public function __toString(): string + { + return 'ses'; + } } diff --git a/src/Illuminate/Mail/Transport/SesV2Transport.php b/src/Illuminate/Mail/Transport/SesV2Transport.php new file mode 100644 index 000000000000..5cc3936d85b6 --- /dev/null +++ b/src/Illuminate/Mail/Transport/SesV2Transport.php @@ -0,0 +1,135 @@ +ses = $ses; + $this->options = $options; + + parent::__construct(); + } + + /** + * {@inheritDoc} + */ + protected function doSend(SentMessage $message): void + { + $options = $this->options; + + if ($message->getOriginalMessage() instanceof Message) { + foreach ($message->getOriginalMessage()->getHeaders()->all() as $header) { + if ($header instanceof MetadataHeader) { + $options['Tags'][] = ['Name' => $header->getKey(), 'Value' => $header->getValue()]; + } + } + } + + try { + $result = $this->ses->sendEmail( + array_merge( + $options, [ + 'Source' => $message->getEnvelope()->getSender()->toString(), + 'Destination' => [ + 'ToAddresses' => collect($message->getEnvelope()->getRecipients()) + ->map + ->toString() + ->values() + ->all(), + ], + 'Content' => [ + 'Raw' => [ + 'Data' => $message->toString(), + ], + ], + ] + ) + ); + } catch (AwsException $e) { + $reason = $e->getAwsErrorMessage() ?? $e->getMessage(); + + throw new Exception( + sprintf('Request to AWS SES V2 API failed. Reason: %s.', $reason), + is_int($e->getCode()) ? $e->getCode() : 0, + $e + ); + } + + $messageId = $result->get('MessageId'); + + $message->getOriginalMessage()->getHeaders()->addHeader('X-Message-ID', $messageId); + $message->getOriginalMessage()->getHeaders()->addHeader('X-SES-Message-ID', $messageId); + } + + /** + * Get the Amazon SES V2 client for the SesV2Transport instance. + * + * @return \Aws\SesV2\SesV2Client + */ + public function ses() + { + return $this->ses; + } + + /** + * Get the transmission options being used by the transport. + * + * @return array + */ + public function getOptions() + { + return $this->options; + } + + /** + * Set the transmission options being used by the transport. + * + * @param array $options + * @return array + */ + public function setOptions(array $options) + { + return $this->options = $options; + } + + /** + * Get the string representation of the transport. + * + * @return string + */ + public function __toString(): string + { + return 'ses-v2'; + } +} diff --git a/src/Illuminate/Mail/composer.json b/src/Illuminate/Mail/composer.json index 56e008e18256..6d3d34681f13 100755 --- a/src/Illuminate/Mail/composer.json +++ b/src/Illuminate/Mail/composer.json @@ -15,7 +15,6 @@ ], "require": { "php": "^8.0.2", - "ext-json": "*", "illuminate/collections": "^9.0", "illuminate/container": "^9.0", "illuminate/contracts": "^9.0", @@ -24,7 +23,7 @@ "league/commonmark": "^2.2", "psr/log": "^1.0|^2.0|^3.0", "symfony/mailer": "^6.0", - "tijsverkoyen/css-to-inline-styles": "^2.2.2" + "tijsverkoyen/css-to-inline-styles": "^2.2.5" }, "autoload": { "psr-4": { @@ -37,7 +36,7 @@ } }, "suggest": { - "aws/aws-sdk-php": "Required to use the SES mail driver (^3.198.1).", + "aws/aws-sdk-php": "Required to use the SES mail driver (^3.235.5).", "symfony/http-client": "Required to use the Symfony API mail transports (^6.0).", "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^6.0).", "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^6.0)." diff --git a/src/Illuminate/Mail/resources/views/html/button.blade.php b/src/Illuminate/Mail/resources/views/html/button.blade.php index e74fe55a716c..4a9bf7d00495 100644 --- a/src/Illuminate/Mail/resources/views/html/button.blade.php +++ b/src/Illuminate/Mail/resources/views/html/button.blade.php @@ -1,13 +1,18 @@ - +@props([ + 'url', + 'color' => 'primary', + 'align' => 'center', +]) + -