From 2dc43a0069485dcd198d6d246f4b6081e5390b04 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:44:05 +0000 Subject: [PATCH 001/303] docs: document PHPStan test bases Add PHPStan rule and type-inference bases to the supported test-base table. Scope automatic coroutine execution to Hypervel test bases and reference the table from the porting checklist. --- AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 621b48f3f..daddff452 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -456,6 +456,8 @@ Package-specific tests that require one database driver go in `tests/Integration |-------|----------| | `Hypervel\Tests\TestCase` | Unit tests, mocks only, no container needed. Tests that only need an isolated scratch directory stay here — use `ParallelTesting::tempDir()` (see Temp directories below). | | `Hypervel\Testbench\TestCase` | Integration tests (needs container for facades, config, DB, etc.) **or any test that needs a full app skeleton / writes through `BASE_PATH`** — testbench clones a disposable runtime skeleton per run and exposes its path via `BASE_PATH` (and `TESTBENCH_BASE_PATH` for subprocesses), deleted on shutdown. Committed source is never mutated. | +| `PHPStan\Testing\RuleTestCase` | PHPStan rule tests in `tests/PHPStan`. | +| `PHPStan\Testing\TypeInferenceTestCase` | PHPStan type-inference tests in `tests/PHPStan`. | Always call `parent::setUp()` in your setUp method. @@ -509,7 +511,7 @@ The Testbench skeleton clone is shared for the whole worker, so tests that write ### Coroutine support -All tests run inside coroutines by default. The `RunTestsInCoroutine` trait is on both base test cases (`Hypervel\Tests\TestCase` and `Hypervel\Foundation\Testing\TestCase` / Testbench), so each test method automatically runs in a fresh coroutine. Context is destroyed when the coroutine ends — no manual cleanup needed. +Tests using Hypervel's base classes run inside coroutines by default. The `RunTestsInCoroutine` trait is on both base test cases (`Hypervel\Tests\TestCase` and `Hypervel\Foundation\Testing\TestCase` / Testbench), so each test method automatically runs in a fresh coroutine. Context is destroyed when the coroutine ends — no manual cleanup needed. **Never add `use RunTestsInCoroutine;` to individual test classes.** It's inherited from the base class. If you encounter a test extending raw `PHPUnit\Framework\TestCase`, change it to extend `Hypervel\Tests\TestCase` instead. @@ -959,7 +961,7 @@ Tests for these features should be **removed** (not commented out) without askin 1. Update namespace to `Hypervel\Tests\{Package}` 2. Add `declare(strict_types=1);` 3. Change `Illuminate\` imports to `Hypervel\` -4. Extend correct base TestCase (`Hypervel\Tests\TestCase` or `Hypervel\Testbench\TestCase`) +4. Extend the appropriate test base listed under [Base classes](#base-classes) 5. Ensure `parent::setUp()` is called 6. Add type declarations to model properties 7. Fix mock types (PDO, QueryBuilder, Grammar, etc.) From 662cc1be3768c57773edec2486f82f8986f8669f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:45:10 +0000 Subject: [PATCH 002/303] docs: plan the Hypervel PHPStan package Define the tracked Larastan port, centralized extensions, configuration, Hypervel rules, deprecation reporting, consumer integration and verification strategy. Use the documented PHPStan test bases and remove the completed instruction-update approval requirement. --- docs/plans/2026-11-04-hypervel-phpstan.md | 431 ++++++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 docs/plans/2026-11-04-hypervel-phpstan.md diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md new file mode 100644 index 000000000..83df266b2 --- /dev/null +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -0,0 +1,431 @@ +# Hypervel PHPStan package + +## Outcome and working state + +Build `hypervel/phpstan` as a tracked Larastan port in `src/phpstan`, published through the normal component split. It supplies framework-aware type inference and diagnostics for Components, applications, third-party packages, and the private `packages/hypervel` and `packages/hypervel-dev` repositories. All reusable PHPStan extensions currently owned by Database, Foundation, Head and Scout move into this package. Ordinary source types remain in their owning components. + +Worktree: `/home/binaryfire/workspace/contrib/hypervel/components-phpstan`; branch: `feature/phpstan-package`, based on `0.4`. This worktree has its own Composer installation and a copy of Components' `.env`. Do not share vendor directories between worktrees. Private monorepo changes belong on its `main`, under its own instructions. + +This document specifies the final design and implementation work. Read it, the root monorepo `CLAUDE.md`, Components' `AGENTS.md`, and relevant package READMEs after compaction. Follow their approval, porting and testing rules. Planning authorizes no commits, pushes, publication, or instruction-file edits. The implementation needs the owner's approval after plan review. + +Remaining work: + +- Obtain owner implementation approval and the validation-contract decision specified below. +- Complete the implementation and verification sections below, keeping a single remaining-file checklist during porting. + +## Design decisions + +- Port Larastan's architecture, file organization, naming, algorithms and applicable tests; track its upstream. Use `cp`, read the complete copy, then edit, one file at a time in alphabetical order. Use `mv` for existing Hypervel extensions. Preserve upstream member order and useful comments. Fix inherited defects at their owning boundary. +- Boot the actual application; use Hypervel Testbench for packages. Retain runtime discovery of container bindings, macros, facade roots, authentication configuration and console definitions. Do not build a second static service-container simulator or require an alternative no-boot mode. +- Retain the upstream `extension.neon`, parameter schemas, PHPStan DI attributes and applicable rule switches. Select stricter Hypervel defaults. Keep settings for retained features, including paths and scanning/cache options; omit settings only when their feature is omitted. Do not implement anti-override mechanisms, non-ignorable policy errors, multiple strictness profiles, or a custom configuration language. +- Register `extension.neon` in `extra.phpstan.includes`. Document explicit inclusion and optional discovery through `phpstan/extension-installer`; installing the analyzer alone does not install or authorize that Composer plugin. +- Include maintained deprecation reporting through a required `phpstan/phpstan-deprecation-rules` dependency. Use normal `@deprecated Use X instead.` metadata for future Hypervel deprecations; do not build duplicate deprecation rules. Reporting covers known deprecated APIs from dependencies and PHP as well as Hypervel, subject to upstream metadata and deprecated-scope behavior. +- The public configuration catalog below is the source of truth for defaults. Preserve PHPStan's own level, paths, parallelism, baseline and suppression settings. PHPStan already defaults unmatched-ignore reporting to true; remove the three consumer false overrides after auditing their ignores rather than redundantly setting that default in the extension. Stronger defaults do not change the meaning of retained options. +- Package-owned diagnostics use stable `hypervel.*` identifiers, state the concrete problem and give the replacement. Rename Larastan-owned identifiers (`larastan.*` and `rules.modelAppends`) only; preserve PHPStan-owned identifiers emitted by reused offset/argument checks, such as `offsetAccess.notFound`. Keep ordinary suppressions for legitimate exceptions and analyzer limitations. New rule switches follow Larastan's boolean parameter/conditional-registration convention; do not add detailed tuning without a real requirement. +- Use PHPStan rules, type/reflection extensions, restricted-usage extensions and collectors for their intended jobs. Collectors can supply cross-file diagnostic facts after analysis; they cannot feed same-run type inference. Share an existing helper where needed, not a parallel inference engine. +- Runtime work is confined to genuine source typing/correctness fixes exposed by this port and changes needed to support the analyzer correctly. Configurable authoring-policy diagnostics can recommend modern APIs while legacy APIs remain supported. The separate legacy-removal todo is not permission to remove runtime APIs in this port; see the legacy-policy section. +- Shared component release versions apply. New mandatory defaults belong in a `0.x` minor release; false-positive corrections can ship in patches. Do not introduce an independent analyzer release train. + +## Public configuration and identifiers + +Use this catalog for `extension.neon`, parameter schemas, documentation and assertions; do not maintain a second defaults list in the plan. All boolean defaults apply to the normal extension include, and consumers may override them. Retained settings keep upstream meanings. + +| Retained upstream parameter | Default | +|---|---| +| `noEnvCallsOutsideOfConfig` | `true` | +| `noModelMake` | `true` | +| `noImplicitQueryBuilderCall` | `false` | +| `noUnnecessaryCollectionCall` | `true` | +| `noUnnecessaryCollectionCallOnly` | `[]` | +| `noUnnecessaryCollectionCallExcept` | `[]` | +| `noUnnecessaryEnumerableToArrayCalls` | `true` | +| `squashedMigrationsPath` | `[]` | +| `databaseMigrationsPath` | `[]` | +| `disableMigrationScan` | `false` | +| `disableSchemaScan` | `false` | +| `configDirectories` | `[]` | +| `viewDirectories` | `[]` | +| `translationDirectories` | `[]` | +| `checkModelProperties` | `true` | +| `checkUnusedViews` | `false` | +| `checkMissingTranslations` | `true` | +| `checkModelAppends` | `true` | +| `checkModelMethodVisibility` | `true` | +| `checkConfigTypes` | `true` | +| `checkAuthCallsWhenInRequestScope` | `false` | +| `parseModelCastsMethod` | `true` | +| `enableMigrationCache` | `true` | +| `checkUniqueJobUniqueFor` | `false` | +| `checkUniqueJobUniqueId` | `true` | +| `noBatchedUniqueJob` | `true` | +| `checkJobSerializesModels` | `true` | +| `checkBatchedJobIsBatchable` | `true` | +| `checkBatchableJobChecksCancellation` | `false` | +| `checkDispatchInTransactionAfterCommit` | `true` | + +Omit `checkOctaneCompatibility`, `generalizeEnvReturnType` and `redisDriver` with their unsupported/incorrect mechanisms. Keep the existing Hypervel `eloquentBuilderMacros` map, with its SoftDeletes and optional Scout entries pointing to the moved signature interfaces; it is extension metadata, not a rule toggle. Preserve PHPStan's normal built-in settings and adapt framework-specific bootstrap/early-termination entries to the real Hypervel symbols. Keep upstream's `universalObjectCratesClasses` entries as `Hypervel\Http\Request` and `Hypervel\Support\Optional`, whose contracts permit arbitrary property names. Set the built-in `reportIgnoresWithoutComments` to `true`; leave `reportUnmatchedIgnoredErrors` at PHPStan's default `true`. + +| New Hypervel parameter | Default | Package-owned diagnostic identifier | +|---|---|---| +| `checkCancellationHandling` | `true` | `hypervel.cancellation.exactType` | +| `checkCancellationPrecedence` | `true` | `hypervel.cancellation.precedence` | +| `checkWorkerSharedState` | `true` | `hypervel.workerSharedState` | +| `checkStaticStateMutation` | `true` | `hypervel.staticStateMutation` | +| `checkUnboundedWorkerCaches` | `true` | `hypervel.unboundedWorkerCache` | +| `noRuntimeConfigMutation` | `true` | `hypervel.runtimeConfigMutation` | +| `noCoroutinesInTransactions` | `true` | `hypervel.coroutineInTransaction` | +| `noRawSleepCalls` | `true` | `hypervel.rawSleep` | +| `noLegacyModelScopes` | `true` | `hypervel.legacyModelScope` | +| `noLegacyModelProperties` | `true` | `hypervel.legacyModelProperty` | +| `noPipeSeparatedValidationRules` | `true` | `hypervel.pipeSeparatedValidationRules` | + +The behavior and soundness limits of each new check are specified under Hypervel rules; a public name does not authorize speculative detection. Report one actionable diagnostic for the same underlying write rather than duplicating worker/static/config errors. No queue-property preference switch is defined before owner approval. + +Map upstream-owned identifiers exactly as follows. Core PHPStan offset/argument/type identifiers are unchanged, including when emitted by a ported rule. The two omitted diagnostics have no new identifier. + +| Upstream identifier | Hypervel identifier | +|---|---| +| `larastan.batchableJobChecksCancellation` | `hypervel.batchableJobChecksCancellation` | +| `larastan.batchedJobIsBatchable` | `hypervel.batchedJobIsBatchable` | +| `larastan.configCollection` | `hypervel.configCollection` | +| `larastan.console.undefinedArgument` | `hypervel.console.undefinedArgument` | +| `larastan.console.undefinedOption` | `hypervel.console.undefinedOption` | +| `larastan.deferrableServiceProvider.missingProvides` | Omitted with deferred-provider support | +| `larastan.dispatchInTransactionAfterCommit` | `hypervel.dispatchInTransactionAfterCommit` | +| `larastan.events.noConstructor` | `hypervel.events.noConstructor` | +| `larastan.jobSerializesModels` | `hypervel.jobSerializesModels` | +| `larastan.jobs.noConstructor` | `hypervel.jobs.noConstructor` | +| `larastan.missingTranslations` | `hypervel.missingTranslations` | +| `larastan.noAuthFacadeInRequestScope` | `hypervel.noAuthFacadeInRequestScope` | +| `larastan.noAuthHelperInRequestScope` | `hypervel.noAuthHelperInRequestScope` | +| `larastan.noBatchedUniqueJob` | `hypervel.noBatchedUniqueJob` | +| `larastan.noEnvCallsOutsideOfConfig` | `hypervel.noEnvCallsOutsideOfConfig` | +| `larastan.noImplicitQueryBuilderCall` | `hypervel.noImplicitQueryBuilderCall` | +| `larastan.noModelMake` | `hypervel.noModelMake` | +| `larastan.noPublicModelAccessorMethod` | `hypervel.noPublicModelAccessorMethod` | +| `larastan.noPublicModelScopeMethod` | `hypervel.noPublicModelScopeMethod` | +| `larastan.noUnnecessaryCollectionCall` | `hypervel.noUnnecessaryCollectionCall` | +| `larastan.octaneCompatibility` | Omitted with Octane support | +| `larastan.relationExistence` | `hypervel.relationExistence` | +| `larastan.uniqueJobUniqueFor` | `hypervel.uniqueJobUniqueFor` | +| `larastan.uniqueJobUniqueId` | `hypervel.uniqueJobUniqueId` | +| `larastan.unnecessaryEnumerableToArrayCall` | `hypervel.unnecessaryEnumerableToArrayCall` | +| `larastan.unusedViews` | `hypervel.unusedViews` | +| `larastan.uselessConstructs.value` | `hypervel.uselessConstructs.value` | +| `larastan.uselessConstructs.with` | `hypervel.uselessConstructs.with` | +| `rules.modelAppends` | `hypervel.modelAppends` | + +## References and facts to preserve + +Local upstream: `/home/binaryfire/workspace/references/larastan` (`3.x`). Read its current source at implementation time; it is read-only. The port tracks . PHPStan source reference: `/home/binaryfire/workspace/references/phpstan-src`; the installed package is authoritative for actual API availability. + +| Reference | Why it matters | +|---|---| +| Larastan `bootstrap.php`, `src/ApplicationResolver.php`, `src/Concerns/HasContainer.php` | App/Testbench startup and runtime reflection; adapt supported Hypervel boot APIs rather than recreating them. | +| Larastan `src/Support/ConfigParser.php` | Uses PHPStan scopes and `NodeScopeResolver`, including imports and preceding statements. Preserve the full parser, recursion protection, lazy evaluation and dependency tracking. | +| Larastan `src/Properties/{ModelPropertyHelper,MigrationHelper,SquashedMigrationHelper,MigrationCache}.php`, `src/SQL/` | Model schema inference and parsed-schema caching; parser/database support needs Hypervel adaptation. | +| Larastan `extension.neon`, `src/Rules/`, `src/LarastanStubFilesExtension.php`, `stubs/`, `tests/`, `e2e/` | Complete configuration, rule, stub and test inventory; do not infer coverage from documentation alone. | +| Components `docs/todo.md`, Static Analysis and Container sections of `AGENTS.md` | Cancellation examples, lifetime semantics, unmatched ignores, PHPStan version regression and intended eventual legacy removals. | +| `src/testbench/src/Foundation/{Application,Config}.php`, `src/testbench/src/Concerns/CreatesApplication.php` | `testbench.yaml`, `createFromConfig()`, discovery and the real per-process runtime skeleton. | +| `src/di/src/Bootstrap/GenerateProxies.php`, `src/di/src/Aop/ProxyManager.php` | Prepended proxy loader, original-source map, registry sealing, fingerprint reuse and atomic replacement. | +| `src/support/src/ServiceProvider.php`, `src/foundation/src/Bootstrap/LoadConfiguration.php` | Shallow top-level merging; named mergeable groups replace an entire matching entry. | +| `src/database/src/Eloquent/{Model,Builder}.php`, `src/database/src/Eloquent/Concerns/{HasAttributes,HasGlobalScopes}.php` | Scope registration can immediately call `extend()`; model construction, serialization, attributes and scope forwarding differ from simplistic substitutions. Local-scope discovery/dispatch lives on `Model`. | +| `src/translation/src/{Translator,FileLoader}.php` | JSON-first lookup, locale fallbacks, namespace overrides, extra paths and boot-added lines. | +| `src/foundation/config/queue.php`, `src/queue/src/{Queue,DatabaseQueue,CallQueuedHandler}.php`, `src/bus/src/UniqueLock.php` | After-commit defaults, transaction ownership, outbox and uniqueness behavior. | +| `src/coroutine/src/Coroutine.php`, `tests/Coroutine/CoroutineTest.php` | Cancellation chains, foreign/child boundaries and cancellation priority. | + +PHPStan documentation: [extension testing](https://phpstan.org/developing-extensions/testing), [rules](https://phpstan.org/developing-extensions/rules), [restricted usage](https://phpstan.org/developing-extensions/restricted-usage-extensions), [collectors](https://phpstan.org/developing-extensions/collectors), [result-cache metadata](https://phpstan.org/developing-extensions/result-cache-meta-extensions), [ignores](https://phpstan.org/user-guide/ignoring-errors), [configuration](https://phpstan.org/config-reference). Confirm APIs against the selected supported version before using them. + +## Package and repository wiring + +1. Use `src/permission/{composer.json,README.md,LICENSE.md}` as the third-party-port skeleton, preserving Larastan's MIT attribution. Set `type: phpstan-extension`, namespace `Hypervel\PHPStan\`, PHP `^8.4`, normal Hypervel support links and branch alias. The README uses `Documentation: https://hypervel.org/docs/phpstan` for the page authored in `src/docs/phpstan.md`, states deliberate public differences, and ends with `Ported from: https://github.com/larastan/larastan`. +2. Preserve the upstream package-root layout: `bootstrap.php`, `extension.neon`, `src/`, and necessary `stubs/`. Use existing `Methods`, `Properties`, `Reflection`, `ReturnTypes`, `Rules`, `Support`, `Types`, `Collectors`, `SQL` and `Concerns` directories. Put new model rules at `Rules/` root beside upstream's model rules; use `Rules/Coroutine` and `Rules/Validation` for those genuinely new domains. Do not introduce separate engines for Hypervel additions. +3. Declare direct production dependencies of the analyzer in its own manifest, even though consumers install the analyzer with `--dev`. Require `phpstan/phpstan-deprecation-rules`: included deprecation reporting is part of the supported installation contract, not an unrelated suggested tool. Check the latest compatible stable release on Packagist before adding it; the verified 2.0.5 release requires PHPStan `^2.1.39`, compatible with the planned analyzer range. Translate required Illuminate components to their actual Hypervel owners. Do not depend on `hypervel/components`, the entire framework metapackage, Head, Scout, private packages or every optional integration. Suggest `hypervel/testbench` for package analysis. Guard optional integrations using existing reflection/stub-extension mechanisms; preserve an absent-component installation test. +4. Add root autoloading and `replace` metadata for `hypervel/phpstan`. The root replaces the split package, so it must declare the analyzer's external dependencies itself; add `phpstan/phpstan-deprecation-rules` to root `require-dev` with `composer require --dev` at the selected compatible constraint. Use Composer commands for other root dependency changes; subpackage manifests without lockfiles may be edited directly. Do not add a runtime service provider just to register PHPStan services. Register them in PHPStan's container. +5. Resolve the PHPStan version requirement before importing APIs: Larastan requires `^2.3.5`, while root, Database and Foundation pin `2.3.1` for the recorded `DataCollectionEloquentCast::set()` loop-narrowing regression. Check the current compatible stable release on Packagist, reproduce the narrowed `class-string` case, and run source/type fixtures against the candidate. Restore a supported range only after that regression passes. Do not loosen source types, add vendor patches or silently discard DI attributes to evade it. If still broken, follow the dependency-bug owner decision workflow with a concrete reproduction. Check compatible stable versions for every added dependency, including `iamcal/sql-parser`, optional phpMyAdmin support and any other SQL parser. +6. Components has no extension installer: in both `phpstan.neon.dist` and `phpstan.types.neon.dist`, replace the four old includes with explicit `src/phpstan/extension.neon` and `vendor/phpstan/phpstan-deprecation-rules/rules.neon` includes, and update type-fixture scan directories. Check each skeleton/private consumer's actual installer and plugin configuration before choosing its loading mode; use discovery or an explicit include for each extension without duplication. Keep source analysis and the dedicated `types/` configuration distinct; do not run normal source analysis over `tests/`. +7. Update the base skeleton, other maintained skeletons and Testbench package examples to install `hypervel/phpstan` as a dev dependency and use the standard setup. Inspect each current manifest and analysis script; preserve existing additional Carbon/Swoole support where still necessary. +8. `bin/split.sh` discovers packages from manifests. Ensure the new package passes existing publishing checks; do not introduce a second publishing system. Prepare the split repository/Packagist setup instructions. Creating external repositories, publishing and pushing require owner authorization at the actual publication step. + +Example consumer configuration, without requiring the extension-installer plugin: + +```neon +includes: + - vendor/hypervel/phpstan/extension.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + +parameters: + level: 5 + paths: + - app +``` + +Use `src` for a package. Retain existing consumer levels unless a deliberate level change is separately justified; stricter package defaults do not require silently raising every project's PHPStan level. + +With an enabled extension installer, both installed packages advertise their own NEON files through Composer metadata; omit their manual includes. Without the installer, retain both includes above (adjusting paths for a custom vendor directory). Do not add a package-owned config loader to hide the second include. Deprecation diagnostics keep upstream identifiers such as `method.deprecated`; they are not renamed to `hypervel.*`. Use identifier-specific ignores with reasons for justified cases. To disable the whole deprecation extension, remove its manual include or, for automatic discovery, add `phpstan/phpstan-deprecation-rules` to the root Composer `extra.phpstan/extension-installer.ignore` list and regenerate discovery through Composer. That list disables automatic loading only; it does not cancel a manual NEON include. Document these standard mechanisms from the [deprecation package](https://github.com/phpstan/phpstan-deprecation-rules) and [extension installer](https://github.com/phpstan/extension-installer), without introducing a duplicate Hypervel switch. + +## Consolidate existing extensions + +Move implementations and their reusable signatures, update namespaces/imports and consumers, then delete old registration files and metadata. Preserve existing behavior and tests while merging additional upstream capabilities. Never register competing resolvers for the same method to conceal a missing merge. + +| Current files | Destination responsibility | +|---|---| +| Database `ForwardedBuilderMethodExtension`, `ForwardedModelMethodExtension`, `NamedScopeMethodExtension` | Central `Methods/`; merge Larastan builder/model/relation behavior and tests into one forwarding path. | +| Database `BuilderMacroResolver`, `ModelScopeMethodResolver`, `ModelScopeTypeResolver` | Central helpers beside the forwarding code; keep attributed/inherited scopes, generic substitution, variadics, named parameters and custom builders. | +| Database `ForwardedModelMethodReflection`, `ForwardedFluentMethodReflection`, `NamedScopeMethodReflection`, `ModelScopeParameterReflection` | Central `Reflection/`, keeping public parameter/return semantics. | +| Database `SoftDeletingMacros`; Scout `SearchableMacros` | Central analysis signatures; retain `eloquentBuilderMacros` metadata and optional trait detection. | +| Foundation `HelperFunctionReturnTypeExtension` | Merge into central helper-return extensions; preserve zero arguments versus explicit `null`, named arguments and unpacking for `response()`/`validator()`. | +| Head `WithHeadExtension`, `WithHeadMethodReflection`, `RoutingMacros` | Central optional routing-macro support without requiring Head. | + +Remove each old `src/{database,foundation,head,scout}/extension.neon`, old `extra.phpstan.includes`, analysis-only namespace references and obsolete PHPStan dependency declarations where the component no longer directly uses them. Recheck `src/`, `tests/`, `types/`, skeletons, scripts, documentation, and private consumers for every moved symbol. Keep runtime interfaces and correct native/PHPDoc types in their component. Do not leave forwarding aliases or duplicate files merely to preserve the old internal layout. + +## Bootstrap and runtime discovery + +- Port upstream app discovery and error reporting. Honor an explicitly bootstrapped application where supported, ordinary `bootstrap/app.php`, Composer's actual vendor directory, and the package/Testbench path. Do not hardcode this worktree or the Components root into the published package. +- For package mode, use Hypervel's `Foundation\Config::loadFromYaml()` and `Foundation\Application::createFromConfig()`/existing bootstrap APIs. Preserve providers, aliases, config, root package discovery, workbench configuration and the package's real Composer autoloader. Hypervel's YAML key is `hypervel`, not `laravel`. Reuse Testbench's disposable runtime and vendor-link actions; do not copy Larastan's obsolete manual symlink fallback or create a second skeleton lifecycle. +- Boot once per PHPStan process, not per file or model. Parallel analysis boots independently in worker processes. Do not start HTTP/queue workers, run migrations, or probe backend services merely for analysis. Provider code can itself perform I/O; do not hide real boot failures or claim boot is free of side effects. +- Retain `BootstrapErrorHandler`'s clear CLI failure and nonzero exit. Adapt optional renderer integrations to supported Hypervel packages; do not add Whoops solely to preserve an unsupported Laravel integration. Do not continue with an empty container after a failed boot. +- Document the actual CLI boot requirements: Swoole (not OpenSwoole), `swoole.use_shortname=Off`, and Swoole built with OpenSSL and native cURL support. `Application::checkEnvironment()` already throws specific corrective messages. Preserve those messages through `BootstrapErrorHandler` and test that they remain visible; do not duplicate the checks or add exception-message matching to manufacture another tip. +- Preserve runtime macro/reflection discovery, facade resolution, class-string/string-key container returns, configured authentication user types and console argument/option definitions. Keep interfaces honest: an arbitrary interface variable does not acquire implementation-only methods merely because the container currently binds one implementation. +- Before changing AOP behavior, add a focused app/package subprocess fixture with an aspect, a class-map replacement, a provider registering a macro, and parallel PHPStan workers. Check original source diagnostics, inferred signatures and registration behavior. `GenerateProxies` seals registries and exposes `proxySources()`; `ProxyManager` reuses fingerprints and writes atomically. Parallel writes alone do not justify bypassing it. Preserve ordinary boot unless the fixture proves a conflict. Resolve a proven source-lookup conflict using the existing original-source mapping and PHPStan's supported facilities; no blanket disabling of aspects, custom AST rewriting, or speculative alternative class loader. If a supported API cannot preserve both semantics, bring the concrete failure and simplest alternatives to the owner before a capability change. + +## Type inference and stubs + +Port every applicable upstream inference feature, not just the rules in the next section. Audit `Methods`, `Properties`, `ReturnTypes`, `ClosureTypes`, `Reflection`, `Types`, collectors and stub files against the corresponding Hypervel API. + +| Area | Required adaptation and coverage | +|---|---| +| Models | Native declarations and explicit PHPDoc take precedence appropriately; infer schema properties, relations, accessors, casts, separate read/write types, nullability, custom collections, factories and serialization shapes via `ModelSerializationDynamicMethodReturnTypeExtension`. Honor Hypervel attributes, visibility/appends and inheritance. Unknown schema knowledge must not erase declared properties or known relationships. | +| Builder/relation forwarding | Preserve model generics, custom builders, relation-specific fluent returns, attributed scopes, SoftDeletes/Scout and macro callbacks. Merge existing Hypervel capabilities with applicable upstream cases. | +| Helpers and collections | Preserve conditional argument behavior, `Arr` shapes, `filter()` narrowing, higher-order proxy callbacks and generic collections. Compare inherited annotations before adding a stub. | +| Authentication | Infer configured guard/provider user models and nullable results through actual auth APIs. Recognize real manager forwarding without advertising implementation methods on unrelated auth contracts. | +| Views and translations | Retain `view-string` and live view-finder namespaces. Keep translation typing and the adapted missing-key rule. The optional unused-view rule reports absent statically discovered usage, not proof that a view can be deleted. | +| Custom PHPDoc types | Port `model-property`, `relation-of`, `builder-of`, `collection-of`, `view-string`, their parsers, templates and applicable tests. Keep these analyzer-only annotations in stubs where adding them to framework source would require this dev package. | +| Dates and environment | Keep native/source `CarbonInterface` boundaries for configurable dates and exact immutable types for framework-owned values. Do not infer configurable dates from `get_class(now())`, or assume an `env()` default dictates the supplied environment value's type. | + +Do not copy `ContractsMethodsExtension`, container ArrayAccess/dynamic-property support, Lumen paths, deferred-provider machinery, or unsupported Laravel integrations. Remove compatibility branches for old Laravel releases, but first merge applicable newer-version stub refinements into the single supported Hypervel surface. Deleting version directories without examining their overrides would lose features. + +General type improvements go into source, with dedicated type fixtures and no runtime overhead. Confirmed examples include `Attribute` and the conditional `FormRequest::validated()` result. `Enumerable::keyBy()` already declares a new-key template, and `Builder::make()`/`create()` already return `TModel`; do not duplicate these. Analyzer-only `model-property` refinements on Model/Builder (including `array, mixed>` inputs) and `view-string` refinements on mail/view/route methods live in package stubs, not framework source. Adapt Redis stubs to Hypervel's phpredis-only pooled `RedisProxy` surface. Omit Predis stubs, Laravel's client-selection branches and the `redisDriver` parameter; there is no supported alternate transport to select. + +Preserve upstream's Request and Optional `universalObjectCratesClasses` entries: `Request::__get()` resolves arbitrary input/route keys, and `Optional::__get()` forwards an arbitrary property name or returns null. These are supported dynamic APIs, not undefined-property mistakes. Do not add blanket crates for models or other classes to hide missing inference; use their dedicated reflection extensions and preserve explicit property types. Include the documented `$request->name` and Optional property access in typing coverage. Removing either dynamic API would require owner approval. + +## Configuration and schema analysis + +### Static configuration + +Port `ConfigParser` in full, then adapt its discovery and merging. Boot supplies loaded provider identities and application paths, not a registry of package config files. `mergeConfigFrom()` records an opaque replay closure in `ConfigMutationTracker`, so inspect the loaded providers' source and statically resolve `mergeConfigFrom($path, $key)` arguments with PHPStan scope evaluation, including constant `__DIR__` expressions, inherited methods and supported helper calls. Preserve uncertainty for unresolved paths or dynamic control flow; do not invent a runtime path registry, inspect captured closure variables, or use post-analysis collectors to feed inference. Static expression analysis supplies config types, never the concrete values from the developer's `.env`. + +```php +// config/feature.php +return ['enabled' => (bool) env('FEATURE_ENABLED', false)]; + +// Inferred boolean, regardless of this machine's FEATURE_ENABLED value. +$enabled = config('feature.enabled'); +``` + +Cover helpers, facade and repository methods, typed getters and collection access. Respect explicit casts, imports, preceding assignments, nullable/union settings, defaults, package defaults and application overrides. Hypervel uses shallow merging; named groups declared by `mergeableOptions()` get one additional entry-merge level, with an application entry replacing its matching default entry whole. Honor registration order and supported custom providers. Reuse existing parser helpers rather than executing arbitrary config files a second time to get values. When a provider mutates config dynamically beyond the parser's knowledge, preserve uncertainty rather than inventing a fixed shape. + +Retain `configDirectories`, `viewDirectories`, `translationDirectories`, migration/schema paths and associated upstream settings for retained features. Prefer boot-discovered paths by default; document exactly whether an explicit setting replaces or supplements discovery. Larastan's configured migration/schema paths replace its fallback paths; preserve that contract unless a separately justified adaptation changes it. + +### Model schemas + +Port migration aggregation, SQL parsing and `MigrationCache`. Cover MySQL, MariaDB, PostgreSQL and SQLite; do not present either upstream MySQL parser/converter as a PostgreSQL or SQLite implementation. No live database connection is required just to infer properties. Package schemas and schemas owned by other services can be supplied through the retained path settings. + +Use a maintained parser before writing dialect handling. Packagist identifies [iamcal/sql-parser](https://packagist.org/packages/iamcal/sql-parser) as a MySQL schema parser and [phpmyadmin/sql-parser](https://packagist.org/packages/phpmyadmin/sql-parser) as MySQL-focused. For PostgreSQL, evaluate [calebdw/pg-schema-parser](https://packagist.org/packages/calebdw/pg-schema-parser) first: it has a stable release and a dedicated `PgDumpParser` for plain-text dumps, using `sad_spirit/pg_builder` for type grammar. Its [documented supported statements](https://github.com/calebdw/pg-schema-parser) include CREATE TABLE plus separate primary-key/default declarations; verify the actual supported Hypervel dump fixtures before selecting the dependency and adapting its output through Larastan's existing SQL boundary. Once it passes, declare it in the analyzer package's `require`, not `suggest`: PostgreSQL inference must be available in a standard dev install. A failed evaluation needs a concrete replacement decision, not silent loss of that support. + +For SQLite, evaluate parser candidates against actual `SqliteSchemaState` output: quoted identifiers, defaults, generated columns, table constraints, STRICT and WITHOUT ROWID where emitted. [k-kinzal/sql-parser](https://packagist.org/packages/k-kinzal/sql-parser) exposes a SQLite grammar but currently lists only a development branch; [maghead/sqlite-parser](https://packagist.org/packages/maghead/sqlite-parser) has an old stable release and needs source/fixture verification before consideration. Do not silently add an unversioned dependency. If no suitable maintained parser passes, reach peer consensus on a bounded parser for the schema-producing statements emitted by the supported dump tools before implementation; preserve unknown schema state for unsupported constructs. No regex rewriting into MySQL syntax, executing dumps, or general-purpose SQL interpreter. Bring any unresolved dependency/coverage tradeoff to the owner rather than claiming full dialect support. + +Track connection/table identity where it changes the result, schema changes across migrations, nullability, defaults, enums/casts and supported custom driver boundaries. If a dynamic migration, raw statement or unsupported custom-driver construct prevents a complete schema, preserve known declarations and avoid reporting an unproven missing column. Do not turn all unknown properties into `mixed` globally or suppress unrelated mistakes. Preserve upstream extension points instead of inventing a driver plugin platform. + +## Result-cache correctness and analysis cost + +- Retain PHPStan's `DependencyTracker`/`DeclarationDependencyTracker` use for configuration, migrations, schema dumps, views and translations. Track both changed files and directory membership so newly added/deleted files invalidate results. Keep declaration-based dependencies for reflection extensions and scope-based dependencies for return types/rules. +- Track runtime inputs explicitly; normal class dependencies alone do not capture registration code. For macros/mixins, track registered closure/method source files through reflection, plus registration providers and the mixin class itself. For container string keys, track loaded provider source, `bootstrap/app.php`, `bootstrap/providers.php`, Composer's installed metadata/root discovery configuration and package discovery cache, Testbench YAML, `.env` and the relevant pre-boot environment identity. Resolved concrete classes retain normal class dependencies. For console definitions, track command class files and `routes/console.php`. Auth configuration and view/translation namespace registrations inherit their config/provider/resource dependencies. Include files called by registration helpers where they supply a consumed fact; retain an explicit dependency at that boundary rather than assuming the analyzed caller references them. Compute shared inputs once per process, not per symbol. +- PHPStan restores its result cache in the main process before application bootstrap. A `ResultCacheMetaExtension` cannot assume a booted container. Use it only for global inputs available before boot, such as selected configuration/environment identity; use existing PHPStan facilities for per-file facts. Do not serialize the live container or eagerly boot it during cache restoration. +- Test a changed boot-derived return type with unchanged analyzed source, on a warm cache. A real provider/macro fixture must change a formerly valid call into an error without manually clearing the cache, and recover when the provider is corrected. Include a `mixin()` class referenced only by provider registration. Verify the same result with a cold run. +- Preserve the migration cache's locking, second read under lock and atomic replacement. Port upstream `MigrationCacheTest` and dependency tests, then add the changed-fingerprint regression: `getMTime()` has whole-second resolution, so ordinary edit-and-analyze loops can reuse a stale schema after two edits in one second. Keep mtime fixed explicitly in this regression instead of relying on test timing or sleeps. Use existing content fingerprints or a once-per-input `xxh128` digest and add a short WHY comment at the change so an upstream sync preserves it. Preserve upstream's timestamp-change invalidation alongside content changes so existing applicable tests remain meaningful. No new concurrency suite is needed for unchanged locking. Keep one parsed-schema cache, bounded in-process reflection caches and normal PHPStan temp storage; no extra persistent metadata service. +- Keep runtime source changes free of analysis-only request overhead. Parse lazily, reuse reflection/ASTs, and do not resolve every container service or model for every node. Use ordinary bounded correctness runs during development; get fresh owner confirmation that the machine is idle before any benchmark. Compare cold/warm analysis and representative runtime paths only when needed to assess a concrete cost. + +## Upstream rule disposition + +Copy retained rule files first, then adapt them. Retain their relevant toggles and tests. The table covers all 28 upstream rule classes; helpers such as `ModelRuleHelper` and `RelationExistenceHelper` follow their consumers. + +| Rule | Final behavior | +|---|---| +| `ArrKeyExistenceRule` | Diagnose definitely absent `Arr::get`/`pull` keys; respect optional keys, defaults, dynamic shapes and actual argument binding. | +| `CheckDispatchArgumentTypesCompatibleWithClassConstructorRule` | Preserve job/event constructor checking, conditional dispatch, named arguments, unpacking and callable dispatch variants actually supported by Hypervel. | +| `ConfigCollectionRule` | Port against the adapted config parser and typed getters; do not forbid legitimate nullable/union `get()` calls. | +| `ConsoleCommand/UndefinedArgumentOrOptionRule` | Use booted command definitions, including signatures and inherited/configured definitions. | +| `ModelAppendsRule` | Support `#[Appends]` and currently supported property declarations. A database-backed attribute can be appended with a getter/class cast. Distinguish readable accessors from setter-only `Attribute` definitions; preserve custom serialization. | +| `ModelRelationDefaultsRule` | Validate `$with`/`$withCount` against real relations, nested paths, aliases and inherited definitions. | +| `NoEnvCallsOutsideOfConfigRule` | Retain the policy with actual package config paths and narrowly justified framework/bootstrap uses. Do not claim outside-config calls always return null. | +| `NoMissingTranslationsRule` | Reuse the booted translator, existing usage collectors and resource dependencies; do not rebuild lookup in a file scanner. Restrict default diagnostics to literal structured keys with a known group source and known standard file-backed resolution. See the translation predicate below. | +| `NoModelMakeRule` | Use the conservative declaration/runtime-metadata exclusions below, without interpreting arbitrary model boot code. Unknown or customized construction stays silent. | +| `NoPublicModelScopeAndAccessorRule` | Apply to the real Hypervel attributed-scope/accessor surface. Coordinate with the legacy-scope rule to avoid duplicate errors. Preserve inherited visibility and valid native methods. | +| `NoUnnecessaryCollectionCallRule` | Retain useful excessive-fetching detection, but require equivalent behavior for each recommendation. Limits, offsets, grouping, casts, accessors, callbacks and PHP/SQL comparison/null semantics can invalidate a rewrite. Keep upstream method-selection settings. | +| `NoUnnecessaryEnumerableToArrayCallsRule` | Recommend `all()` only for the normal collection behavior and values requiring no recursive `Arrayable` conversion. Cover overridden `toArray()`/`all()` and custom collection semantics; element type alone is insufficient. | +| `Queue/BatchedJobIsBatchableRule` | Preserve the runtime `Batchable` requirement and inherited traits. | +| `Queue/JobDispatchedInTransactionUsesAfterCommitRule` | Adapt as specified below; retain its switch. | +| `Queue/JobWithModelPropertyDeclaresSerializesModelsRule` | Include protected/private/nullable model state and supported model collections. Recognize inherited serialization and valid custom `__serialize()`/`__unserialize()` behavior. | +| `Queue/NoBatchedUniqueJobRule` | Cover the verified batch/bulk paths that bypass uniqueness acquisition, with an accurate explanation. Do not generalize to dispatch paths that acquire locks normally. | +| `Queue/UniqueJobDeclaresUniqueIdRule` | Require explicit identity for parameterized unique jobs; permit intentional class-wide identity through an explicit constant-returning method. Do not invent an identity or include secrets in diagnostics. | +| `RelationExistenceRule` | Preserve relation existence checking for loading/query APIs, including nested paths, selected columns, polymorphism and dynamic uncertainty. | +| `UselessConstructs/NoUselessValueFunctionCallsRule` | Resolve parameters correctly and preserve all argument evaluation effects; spread arguments are not ordinary positional values. | +| `UselessConstructs/NoUselessWithFunctionCallsRule` | Same argument/effect requirements; reordered named callback/value arguments are valid. | +| `DeferrableServiceProviderMissingProvidesRule` | Omit: no deferred-provider surface. | +| `NoAuthFacadeInRequestScopeRule`, `NoAuthHelperInRequestScopeRule` | Port as opt-in preferences, default off. Supported auth APIs remain valid. Preserve guard/custom request-resolver semantics; a variable named request does not prove an autofix equivalent, so do not offer that replacement automatically without such proof. | +| `NoImplicitQueryBuilderCallRule` | Port as opt-in, default off. `User::where()` remains the documented, supported form; an enabled preference may request explicit `query()` without changing framework semantics. | +| `OctaneCompatibilityRule` | Omit and replace with Hypervel-specific lifetime checks; ordinary container/config injection is supported. | +| `UnusedViewsRule` | Port as opt-in, default off. Report that no static usage was discovered, with the limitation for dynamic/package-exported views; never provide a deletion fix or claim this proves the view unused. | +| `Queue/BatchableJobChecksCancellationRule` | Port as opt-in, default off. Recognize `batching()`, the real batch's `cancelled()` and supported cancellation middleware through types, including inheritance. Describe detection of a recognized check, not proof that cancellation prevents all work. A coincidental method name is insufficient. | +| `Queue/UniqueJobDeclaresUniqueForRule` | Port as opt-in explicit-policy checking, default off. Recognize `#[UniqueFor]`, inherited methods and the supported property fallback. Do not require a positive duration, invent a timeout or claim locks are never released: normal completion/final failure releases them, and zero has backend-specific semantics. | + +### Conservative construction and translation predicates + +For `NoModelMakeRule`, skip models with a declared/inherited/trait `make()` override, custom constructor, custom builder (`#[UseEloquentBuilder]`, `$builder` or builder-factory override), overridden model/query construction, or custom lifecycle methods. Lifecycle exclusions include `boot`/`booting`/`booted`/`boot{Trait}`, `initialize{Trait}`, and Hypervel's `#[Boot]`/`#[Initialize]` methods; permit the unchanged base Model's own implementation. Skip `#[ScopedBy]` and scopes already recorded in the booted model registry (`getAllGlobalScopes()`), including registrations made by providers. Registered boot listeners/callbacks or other unknown construction customization also preclude an equivalence claim. Use existing reflection/runtime metadata, not a static boot interpreter or forced model instantiation solely for this rule. Include the attributed-hook and provider-registered-scope cases as negative fixtures, alongside a plain model as a positive case. + +For `NoMissingTranslationsRule`, use the standard bound `FileLoader` and the booted translator's existing resolution APIs. Only analyze literal `group.item` or `namespace::group.item` keys with a known group file among relevant locales/paths. Track those lang directories and registration providers. `Translator::has()` is useful positive evidence, but false alone is not proof of absence: it returns false for a PHP/boot-added translation whose value equals its key, and intentionally bypasses missing-key callbacks. Confirm negative results with the known group data through existing loader/translator APIs; do not flag identical key/value translations or custom-resolution cases whose absence cannot be established. Preserve fallback locales, JSON full-key translations, namespace overrides and `addLines()` by reusing framework resolution, not copying it. Stay silent for custom loaders/translators where lookup can do external I/O or has unknown semantics. Add tests for explicit/dynamic locale arguments; extend collectors to carry known lookup arguments rather than assuming every call uses the boot locale. Preserve `translationDirectories` as a usable analysis input without mutating the application's translator. Comment the deliberate adaptation to upstream. + +### Transaction dispatch + +Use statically known configuration from the ported parser, plus statically known dispatch/job overrides. Never use a machine's `.env` value as proof that a deployment is safe or unsafe. Hypervel defaults `after_commit` to true except for `sync` and `database`; a same-connection database enqueue participates in the transaction, but another connection does not. + +Cover direct and named `DB` connections, nested transaction callbacks, dispatch connections, job contracts/attributes/methods, explicit `afterCommit()`, `DB::afterCommit()` callbacks and outbox dispatch. Respect deliberate `beforeCommit()` use; this is an explicit supported escape hatch, not an error to undo. Do not flag synchronous execution as asynchronous visibility risk. For unknown connection selection or dynamic state, remain silent rather than guessing. + +Required positive fixture: source config names a database queue connection different from the business transaction, with `after_commit: false`, no outbox and no explicit safe dispatch. Required negative fixtures: same-connection database, configured after-commit, explicit after-commit callback, outbox, sync, deliberate before-commit and environment-dependent connection selection. Verify queue transport/database connection names at their owning boundaries rather than equating their string identifiers. + +### Verified regression examples + +Keep these as focused tests, alongside all applicable upstream cases: + +```php +value(...[static fn (): int => 42]); +with(...[21, static fn (int $number): int => $number * 2]); +with(callback: static fn (int $number): int => $number * 2, value: 21); +``` + +All three return `42`; upstream's raw positional-argument checks misdiagnose them. A limited query over three rows can return `1` from `limit(1)->get()->count()` and `3` from `limit(1)->count()`. An appended stored attribute with a getter can serialize correctly. A global scope's `extend()` can call `withAttributes()` immediately, making `Model::make()` and `new Model` differ even without a model-defined `make()` method. A collection subclass can intentionally override conversion behavior. Preserve these supported contracts. + +## Hypervel rules + +### Cancellation + +Implement the concrete `docs/todo.md` patterns with PHPStan types and AST/control-flow information: + +- An exact `catch (CanceledException)` that rethrows, followed by a broader catch that reports, retries, falls back or records an outcome; callback/I/O/cleanup failures can wrap cancellation before the broad catch sees it. +- `instanceof CanceledException` on a throwable not already narrowed by that exact catch, and `getPrevious() instanceof CanceledException` used to classify cancellation. +- Failure accumulation such as `$failure ??= $exception` where the value is the operation's retained failure and later cancellation would lose precedence. + +Recommend `Coroutine::causedByCancellation()` and `Coroutine::preferCancellation()` at those sites. Use small shared expression/try-block helpers only where more than one rule needs them. Do not construct a general exception-flow interpreter or guess through arbitrary callbacks. + +```php +catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + // Existing ordinary-error handling remains here. +} + +$failure = Coroutine::preferCancellation($failure, $exception); +``` + +Native-only Swoole/Hypervel channel, WaitGroup, join and Waiter operations can retain exact checks when they are the only work in the relevant try block. Do not exempt an entire file or method that also invokes user callbacks or cleanup. Preserve foreign/child ownership boundaries and cleanup-error reporting. Use `tests/Coroutine/CoroutineTest.php` cases for direct/wrapped/nested cancellation, Google's separate service cause, foreign and independent-child failures, and failure priority. Typed domain catches, failure observers and arbitrary ownership propagation remain runtime-review concerns, not purported static proofs. Legitimate exact checks use a local identifier suppression with a reason. + +### Worker lifetimes and coroutine context + +Use actual container semantics: unbound buildable classes auto-singleton unless intrinsically fresh; explicit binding, `Scoped`/`Singleton`, `Transient`, `SelfBuilding`, execution-scoped constructor attributes and contextual parameterized construction change the lifetime. Do not classify every mutable object as a singleton or every singleton mutation as unsafe. + +Start from evidenced container candidates: registered `bind`/`singleton`/`scoped`/`instance` targets; statically resolved `make(X::class)`/`app(X::class)` calls; and dependencies injected into framework entry points actually constructed by the container (registered middleware/controllers/listeners/commands, after verifying their construction path). Track explicit and auto-singleton lifetimes separately from scoped/fresh/parameterized resolution. A class appearing only in `new X` is not a shared candidate. Use registration collectors for cross-file diagnostics and the booted container's existing metadata where available, honoring aliases, contextual bindings and mixed/unknown resolution paths. Do not recursively assume every referenced class is container-built. + +1. Detect known request/operation state retained on worker-shared objects, including constructor capture and writes during known request/job handling. Start from the todo's property-write candidates, then classify their ownership using types, registration and the existing `Boot-only.`/`Tests only.` contracts. Intentional driver caches, registries and resolved config are valid worker state. Measure candidate findings and false positives across Components and both private packages before choosing an exemption mechanism. Reach peer consensus on the smallest adequate mechanism, using existing contracts and reasoned local suppressions where sufficient; a new public annotation requires an exact proposal and owner approval. Do not preselect per-write suppression or a whole-class exemption before measuring real cases. +2. Detect process-global configuration writes and static state mutation in known request execution contexts; distinguish boot registrations and `flushState()` cleanup. Detect direct request-derived cache keys only where the source establishes an unbounded worker-lifetime cache. Do not pretend to prove arbitrary taint propagation or boundedness across unknown calls. +3. Detect supported child-coroutine spawning APIs inside transaction callbacks when work is being moved off the transaction-owning coroutine. Explain that pooled database ownership belongs to the parent and the child does not inherit its transaction. Preserve established transaction-preserving test infrastructure and calls that do not actually spawn children. +4. Diagnose raw source `sleep()`/`usleep()` where the documented replacement is `Hypervel\Support\Sleep`. Preserve real-time test/infrastructure waits and other explicitly justified timing contracts. + +Use collectors only for cross-file binding facts needed by diagnostics. Derive known runtime registrations through existing bootstrap/reflection facilities when available. Unknown ownership must not produce a confident leak claim. Evaluate candidates across Components and the private source before finalizing detection predicates; fix genuine violations and cover intentional patterns as negative fixtures. Do not add a separate policy framework, service registry, deep interprocedural data-flow engine or runtime instrumentation. + +### Legacy authoring policy and safe fixes + +Add configurable, default-enabled recommendations for the owner's modern conventions: + +- Model `scope[A-Z]` methods -> protected `#[Scope]` methods. Match model inheritance/traits, not unrelated methods such as route `scopeBindings()` or Passport `scopeIds()`. +- Model subclass/consumer-trait configuration property declarations with complete attribute equivalents -> the corresponding attributes listed in `docs/todo.md`: table/connection/key/incrementing/timestamps/date format/fillable/guarded/hidden/visible/appends/touches/refreshes. The base model and its owning storage traits still declare runtime storage; do not diagnose those declarations. Preserve runtime setters/assignments and properties without equivalent attributes, including casts, defaults, eager relations and pagination. Report actual replacements, including config-driven values that require runtime initialization rather than an attribute constant. +- Pipe-separated validation lists -> arrays at verified validation entry points, FormRequest and Data rules. Preserve pipes inside individual regex rules and dynamic rule objects/closures. + +Queue-property authoring diagnostics are a candidate requiring an owner policy decision, not an enabled rule in this plan. Before requesting approval, present the exact declaration-to-attribute mapping and dynamic exceptions (for example `Tries`, `Timeout`, `FailOnTimeout`, `Backoff`, `MaxExceptions`, `UniqueFor`, `Connection`, `Queue`). Do not infer blanket approval from the model-property policy or remove queue property support. If not approved for implementation, record the candidate in `docs/todo.md` with that mapping and its open policy questions; do not add dormant enforcement machinery. + +These diagnostics enforce authoring policy; messages must not say a currently supported API is unavailable. Keep current legacy type inference/runtime behavior until its removal is explicitly approved. Do not automatically execute the much larger model/validation/queue runtime removal todo as part of importing an analyzer. A runtime removal needs a concrete, owner-approved change covering source, consumers, fixtures, error messages and porting documentation together; its cached boot checks must not add work per hydrated model. This boundary preserves supported APIs while delivering the requested static guidance now. + +Retain PHPStan's autofix integration for verified local transformations, including unambiguous pipe lists and corrected `value()`/`with()` calls with preserved evaluation effects. Test fixes by reanalysis and runtime equivalence where relevant. Do not offer scope-renaming or model-property-to-attribute autofixes: cross-file direct calls and inherited precedence cannot be established by the local fix. Give an exact replacement in the diagnostic instead. Exclude `regex`/`not_regex` cases from mechanical pipe splitting. Audit other retained upstream fixes by the same criterion. Never run `--fix` automatically during ordinary analysis. + +## Testing and implementation sequence + +### Harness and fixture ownership + +Port upstream rule/type/reflection/unit/integration tests under `tests/PHPStan`, preserving relative upstream placement and all applicable cases. Use PHPStan's `RuleTestCase` and `TypeInferenceTestCase` for rule and type-inference tests, as listed in `AGENTS.md` under Base classes. These analysis tests do not need Hypervel's coroutine wrapper. Runtime integration tests retain the appropriate Hypervel base class. No further test-base instruction edit or approval is required. + +Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime instead of modifying its committed skeleton or always-loading package routes. Keep existing `types/` fixtures as public typing regressions and update moved references. + +Keep type fixtures that intentionally exercise still-supported APIs. In `phpstan.types.neon.dist`, set `noLegacyModelScopes`, `noLegacyModelProperties` and `noPipeSeparatedValidationRules` to `false`, with one comment that these fixtures assert supported types rather than authoring policy. Also set `checkModelMethodVisibility` and `noUnnecessaryEnumerableToArrayCalls` to `false` in this harness: `CustomBuilderForwarding.php` deliberately declares public scopes, and Collection/LazyCollection fixtures assert conversion return types. These preferences retain their normal defaults in source/consumer analysis and their dedicated `RuleTestCase` coverage. Preserve type assertions; do not delete compatibility cases. For upstream always-registered diagnostics without a switch, use a local identifier suppression with a fixture-specific reason only at the intentional call (for example the existing `value('foo', 42)` and `with(new User)` helper fixtures). Do not invent public switches or a second rule-registration system solely for the harness. + +Exclude mirrored analyzer `data/` directories and any intentionally preserved fixture-app files from `.php-cs-fixer.php` before running `lint:fix`; its current finder covers all tests and would rewrite deliberate syntax and error line numbers. Keep test classes and ordinary Hypervel support files formatted normally. Format new Hypervel-only fixtures before recording expected diagnostic lines, and rerun affected tests if a formatter changes those files. Convert bare `@phpstan-ignore-line`/`@phpstan-ignore-next-line` comments in ported source to accurate identifier-specific ignores with reasons, or fix the underlying type; the package itself is analyzed with the new ignore policy. + +Do not add repository-housekeeping rules for test method title docblocks or missing test return types to this reusable package. General `tests/` analysis remains excluded; the separate formatting/convention work in the todo is unchanged. Follow the current conventions for all tests touched by this port. + +PHPStan's DI container may live across tests while Hypervel cleanup flushes framework globals. Verify setup/teardown ordering and reset only package-owned static/runtime discovery caches that actually persist; use the standard cleanup registrar/subscriber. Do not blindly replace every upstream test base or require analyzer rule fixtures to execute inside coroutines. Framework runtime integration tests continue using the appropriate Hypervel base class. + +### Work order + +1. Inventory all upstream source, stubs, tests and current Hypervel extensions. Make one remaining-file checklist recording copy, move, merge or justified omission; do not duplicate it in this plan. Set up metadata, dependencies and the test harness. +2. Port bootstrap/Testbench/error reporting and verify app/package startup, optional components, AOP and worker behavior before relying on runtime discovery. +3. Move existing extensions and port the type engine, stub refinements, config/schema parsers and cache dependencies. Run each changed test class immediately. Keep public typing fixtures passing as registrations move. +4. Port retained rules with the corrections above, then add Hypervel rules and safe fixes. Include every applicable upstream test and the concrete regressions; do not weaken expected types/errors to get a green run. +5. Integrate Components, skeletons, private packages and affected private apps. Fix actual source defects and stale suppressions revealed by the new analysis; do not blanket-disable the defaults or generate a baseline to bury them. +6. Complete documentation, split/install tests, self-review and the assigned peer code review. Correct findings and rerun only affected checks. No implementation checkpoint permits commits or publication without owner authorization. + +### Verification matrix + +| Surface | Required checks | +|---|---| +| Rule behavior | Positive and negative fixtures, inheritance/traits, aliases, named/unpacked arguments, nullable/union types, dynamic unknowns, stable identifiers, toggles enabled/disabled and safe-fix output/reanalysis. | +| Type engine | `assertType` fixtures for model properties/accessors/relations, scopes/custom builders, factories/collections, auth, app/container keys, helpers, config, console, macros, views and public custom PHPDoc types; native contracts remain valid. | +| Current inference gaps | Baseline fixtures for `auth()->user()` (currently a false Factory `method.notFound`), `$model->relation`/`$model->column` (`property.notFound`), `app('cache')`, `$request->user()`, `Arr::get()` and `->map->getKey()` (currently mixed in the investigated examples), `collect([1, null])->filter()` (retains null), and a known model's `toArray()` (currently `array`). Assert the expected precise type for a fully specified fixture while preserving uncertainty for dynamic/custom cases. | +| Schema/config | Real supported database dump/migration fixtures, custom-driver uncertainty, declared-property precedence, static env casts, nullable values, package/application merge ordering and typed getters. | +| Cache | Keep upstream dependency/cache tests. Add one warm-versus-cold regression per new or changed invalidation mechanism: file content, directory membership, provider input behind a runtime fact, macro/mixin source, a pre-boot global input, and same-second migration edits. Reuse existing coverage and combine fixtures where they exercise the same mechanism; do not multiply each input by every possible edit operation or add tests for unchanged locking. | +| Boot | Skeleton app, Components through Testbench, standalone package with `testbench.yaml`, custom vendor directory, runtime-requirement/failure diagnostics, AOP/class replacements and optional Head/Scout absent/present. Verify each process's Testbench copy is removed on normal exit and copies left by terminated workers are purged on the next run under the existing ownership rules. No accidental server start or committed-skeleton writes. | +| Consumer installation | A clean standalone Composer install of the split package with own dependencies; central and deprecation extensions each load exactly once with manual includes and with installer discovery. Reuse this fixture to assert a known deprecated call produces one upstream-identifier diagnostic. Required classes/stubs are present without Components root autoloading. Do not duplicate the dependency's own rule test suite. | +| Runtime behavior | Focused existing framework tests for every source typing/correctness change. No weakened contracts or runtime casts/branches solely for PHPStan. | +| Real projects | Components, base/maintained skeletons, both private monopackages and affected private apps. Adapt upstream's e2e workflow; public CI uses public/sanitized fixtures. Private sources and their baselines are never copied into the public repository. | + +Use bounded sequential commands from the correct repository directory. Run `./vendor/bin/phpunit --no-progress ` immediately after each test file. For multiple affected framework classes use `composer test:parallel -- --processes=4` with relevant selectors. Run `composer test:testbench` if Testbench changes. At a coherent completion boundary run `composer analyse` (source, `types/`, skeleton checks); it requires the skeleton's own dependencies. Run `composer lint:fix` before scheduled review. Do not run PHPStan on general `tests/` or pass source/test paths to `phpstan.types.neon.dist`. Keep parallelism inside the chosen tool, not overlapping verification commands. Leave full matrices to CI unless a concrete unresolved issue requires them locally. + +For e2e baselines, preserve known intentional analyzer limitations with reasons and inspect baseline changes. Do not treat adding a new baseline entry as a fix for a demonstrated source defect. New Hypervel regression fixtures should have exact expected results. + +## Consumer migration and final cleanup + +- Resolve the deprecated validation-contract decision below with the owner before auditing those internal usages; do not let the general suppression guidance silently decide it. +- Audit `packages/hypervel`, `packages/hypervel-dev` and affected `apps/` under `/home/binaryfire/workspace/monorepo`. Both private package configs currently override unmatched-ignore reporting to false. Add the analyzer as a dev dependency with Composer, update source/type/generated analysis configs where applicable, supply package Testbench configuration, and remove obsolete direct-extension includes/scans. +- Private Composer repositories currently point at the main Components `src/*`. Validate against this worktree through a task-local Composer setup with its own installed dependencies, without permanently committing a worktree-specific path or disrupting another workload's vendor directory. Final private manifests reference the intended durable package source/version. +- Audit every suppression affected by improved inference or the new ignore policy in Components, both private packages, skeletons and affected apps. Convert every reason-less identifier ignore and every blanket line/next-line ignore in their analyzed paths to a source fix or an accurate identifier-specific ignore with a parenthesized reason. Do not limit this to newly ported files or errors that became unmatched. Remove obsolete inline ignores/global patterns and correct stale explanations such as claiming a boot-registered macro is unknowable. Retain narrowly justified limitations; new broad ignores require the normal owner approval. +- Audit deprecated-API usage in those same consumers, including calls to PHP, Symfony, Carbon and other vendor APIs, not just Hypervel declarations. Use the supported replacement when it preserves the contract. Keep a reasoned identifier-specific ignore when deprecated usage remains necessary, including compatibility with a still-supported public API. Do not remove supported APIs, strip truthful deprecation tags, mark unrelated code deprecated to silence errors, or add runtime workarounds merely to satisfy analysis. Dependency defects follow the normal owner decision workflow. +- Add `src/docs/phpstan.md` and navigation links, following existing Laravel-style documentation. Explain installation, app/Testbench boot, supported versions, configuration/defaults, optional integrations, custom PHPDoc types, diagnostic examples, suppressions, safe fixes, cache behavior and the distinction between authoring recommendations and runtime API removals. State in the page and release notes that adopting the documented setup diagnoses existing reason-less/blanket ignores and uses the included deprecation extension for known deprecated APIs across dependencies, including PHP, Symfony and Carbon. Explain its standard loading and opt-out mechanisms and upstream deprecated-scope behavior; do not promise detection of every runtime deprecation regardless of available metadata. Keep the package README minimal. +- Add a concise `src/docs/porting-from-laravel.md` entry: replace `larastan/larastan` with `hypervel/phpstan`, update the extension include, migrate Larastan-owned ignore identifiers (`larastan.*`, `rules.modelAppends`) to the documented `hypervel.*` mapping, and retain PHPStan core identifiers. Explain that retained parameter names remain compatible while defaults and omitted Octane/deferred-provider settings differ; link to the canonical PHPStan page. +- Update affected feature docs, skeleton setup, testing/type-fixture instructions, component manifests and package READMEs for centralized extension ownership. Amend `docs/todo.md` by removing completed items and the superseded instruction to keep extensions distributed. Preserve still-unimplemented runtime-removal and unrelated todos; update their references where this package now supplies checks. Never rewrite historical plans. +- Record intentionally omitted upstream features in the package README, a concise source comment at the natural registration/insertion point and `REMOVED:` comments at matching test locations, as required by the porting policy. Do not create empty placeholder classes for omitted features or annotate routine namespace changes and bug fixes as public API differences. +- Search the affected source, tests, types, active docs, manifests and scripts for obsolete namespaces, old extension paths, duplicated services, unused helper classes/settings, inaccurate compatibility claims and stale comments. Remove them together with their consumers. Keep upstream references/license attribution and historical plans intact. +- Before completion, review the final diff for source contract correctness, Laravel-style naming/ergonomics, coroutine ownership, analysis-only versus runtime cost, dependency isolation and unnecessary machinery. No custom service-container interpreter, universal lifetime annotation system, private PHPStan API patch or speculative registry should be needed. + +### Owner decision: deprecated validation contracts + +`Hypervel\Contracts\Validation\Rule` and `ImplicitRule` are deprecated, but remain part of the supported validation implementation and public contract, matching current Laravel. Built-in `Email`, `Enum`, `Can`, `Password`, `File` and `AnyOf` expose `passes()`/`message()` through these interfaces. `InvokableValidationRule` also adapts the modern `ValidationRule::validate()` API into that execution path. A removal affects Validator, RuleCompiler, ValidationRuleParser, ClosureValidationRule, ConditionalRules, Rule's builder/type declarations, ClonesCustomRules, and Data's RuleNormalizer and validation Rule attribute, including their type signatures and tests. + +Before implementation, obtain an explicit owner decision. The recommendation for this analyzer port is to retain the existing runtime contracts, keep their deprecation tags and user-facing diagnostics, and authorize only the specific framework compatibility suppressions needed until the separately approved legacy migration. This is a public-API decision, not an automatic response to an analysis error. Removing just `implements Rule` while keeping the methods still breaks interface/type compatibility; retaining both interfaces does not remove the deprecation diagnostic. Do not wrap or rewrite the validation engine merely to hide that fact. + +If the owner approves retention, add a named **Legacy validation rule contracts** entry under `docs/todo.md` Legacy removals, describing the eventual modern-contract conversion, affected adapters/built-ins/Data consumers, public `passes()`/`message()` and interface compatibility decisions, and ongoing upstream conversion requirements. Use the actual emitted deprecation identifier at each necessary framework boundary with one consistent reason: "Retains supported validation Rule/ImplicitRule compatibility; see docs/todo.md, Legacy validation rule contracts." Avoid a global ignore. Existing applications/private rules should use `ValidationRule` where their public contract permits migration. User code implementing the deprecated interfaces continues to receive diagnostics; do not exclude these interfaces in the analyzer. + +If the owner instead approves removal in this work, first replace this decision section with a concrete source/consumer/test/documentation migration that preserves required behavior and explicitly records the approved API departures. Execute that migration before completing the consumer audit. Do not leave a confirmed runtime defect unresolved under either choice; deprecation alone does not establish one. + +## Completion criteria + +The central split package works in an application and a standalone Testbench-backed package, owns every reusable existing extension, retains the applicable Larastan engine/configuration/tests, provides the agreed Hypervel checks, and passes the verification matrix. Source contracts and supported APIs remain correct; all real findings introduced or exposed by the integration are fixed or taken through the explicit owner decision process. Old extension files, registrations, dead code and stale active documentation are gone. The plan's implementer obtains peer code-review signoff, summarizes the result and remaining owner-authorized external steps, and sends the user notification only after the terminal summary. From c3e3a26109c1acb652ddd6314eb3c7807bb32770 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:07:36 +0000 Subject: [PATCH 003/303] Fix parentless Data type resolution Handle the false result from ReflectionClass::getParentClass instead of using nullsafe access, preserving the existing unresolved-name fallback in native and PHPDoc type resolution. Cover PHPDoc parent and a nullable native parent property inherited from a trait on a parentless class. The DataTypeFactory test file passes with 17 tests and 114 assertions; source and type analysis pass on the combined changes. --- .../src/Support/Factories/DataTypeFactory.php | 8 +++++-- tests/Data/Support/DataTypeFactoryTest.php | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/data/src/Support/Factories/DataTypeFactory.php b/src/data/src/Support/Factories/DataTypeFactory.php index 8e66fd64e..6634871b2 100644 --- a/src/data/src/Support/Factories/DataTypeFactory.php +++ b/src/data/src/Support/Factories/DataTypeFactory.php @@ -421,7 +421,9 @@ protected function resolveNativeName( return match ($name) { 'self' => $declaringClass->getName(), 'static' => $targetClass->getName(), - 'parent' => $declaringClass->getParentClass()?->getName() ?? $name, + 'parent' => ($parentClass = $declaringClass->getParentClass()) !== false + ? $parentClass->getName() + : $name, default => $name, }; } @@ -440,7 +442,9 @@ protected function resolvePhpDocName( return match ($name) { 'self' => $declaringClass->getName(), 'static', '$this' => $targetClass->getName(), - 'parent' => $declaringClass->getParentClass()?->getName() ?? $name, + 'parent' => ($parentClass = $declaringClass->getParentClass()) !== false + ? $parentClass->getName() + : $name, default => $this->typeNameResolver->resolve($name, $declaringClass), }; } diff --git a/tests/Data/Support/DataTypeFactoryTest.php b/tests/Data/Support/DataTypeFactoryTest.php index 71e635e93..53b5ced0e 100644 --- a/tests/Data/Support/DataTypeFactoryTest.php +++ b/tests/Data/Support/DataTypeFactoryTest.php @@ -364,6 +364,17 @@ public function testInheritedPhpDocKeywordsUseTheirPhpScopes(): void ); } + public function testParentWithoutAParentClassRemainsUnresolved(): void + { + $nativeType = $this->property('unresolvedNativeParent')->getNamedTypes()[0]; + $phpDocType = $this->property('unresolvedParent')->getNamedTypes()[0]; + + $this->assertSame('parent', $nativeType->name); + $this->assertNull($nativeType->dataClass); + $this->assertSame('parent', $phpDocType->iterableItemType?->getNamedTypes()[0]->name); + $this->assertNull($phpDocType->dataClass); + } + // Spatie's DataReturnTypeTest: return types are built by this factory. Spatie's buildFromNamedType() and // buildFromValue() only resolved collect() targets, which Hypervel resolves without type metadata. @@ -485,8 +496,15 @@ abstract class DataTypeFactoryItemData implements BaseData { } +trait DataTypeFactoryParentProperty +{ + public ?parent $unresolvedNativeParent = null; +} + class DataTypeFactoryFixture { + use DataTypeFactoryParentProperty; + public int $integer; /** @var int<0, max> */ @@ -524,6 +542,9 @@ class DataTypeFactoryFixture /** @var array */ public array $arrayKeys; + /** @var array */ + public array $unresolvedParent; + /** @var non-empty-array */ public array $nonEmptyArray; From 6c630b64f8b7f19e712619b8ceb954f6bfd10b73 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:07:36 +0000 Subject: [PATCH 004/303] Correct collection split result types Describe numeric key reindexing and preserved string keys in split results. LazyCollection split returns ordinary Collection chunks through its eager collection delegation, so update the contract and type fixtures to match runtime behavior. Keep collection construction and virtual push calls unchanged. Add local identifier-specific TODO ignores for the PHPStan native static factory regression and remove version-specific wording from existing ArrayAccess ignore reasons. Full source and type-fixture analysis pass. Targeted split, chunkWhile and state tests pass with 30 tests and 132 assertions; changed PHP files pass formatting. --- src/collections/src/Collection.php | 12 ++++++------ src/collections/src/Enumerable.php | 2 +- src/collections/src/LazyCollection.php | 6 ++++-- types/Collections/Collection.php | 4 ++++ types/Collections/LazyCollection.php | 5 +++-- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index 702d4c200..11d90f13e 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -363,7 +363,7 @@ public function duplicates(callable|string|null $callback = null, bool $strict = if ($uniqueItems->isNotEmpty() && $compare($value, $uniqueItems->first())) { $uniqueItems->shift(); } else { - // @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess) + // @phpstan-ignore offsetAssign.dimType (PHPStan rejects template keys on ArrayAccess) $duplicates[$key] = $value; } } @@ -1334,7 +1334,7 @@ public function slice(int $offset, ?int $length = null): static /** * Split a collection into a certain number of groups. * - * @return ($numberOfGroups is positive-int ? static : never) + * @return ($numberOfGroups is positive-int ? static> : never) * * @throws InvalidArgumentException */ @@ -1348,8 +1348,8 @@ public function split(int $numberOfGroups): static return $this->newInstance(); } - /** @var static> $groups */ - $groups = $this->newInstance(); + /** @var static> $groups */ + $groups = $this->newInstance(); // @phpstan-ignore varTag.nativeType (@TODO: Remove once PHPStan stops applying receiver generics to the factory's native static return type.) $groupSize = (int) floor($this->count() / $numberOfGroups); @@ -1491,7 +1491,7 @@ public function chunk(int $size, bool $preserveKeys = true): static public function chunkWhile(callable $callback): static { /** @var static> $chunks */ - $chunks = $this->newInstance(); + $chunks = $this->newInstance(); // @phpstan-ignore varTag.nativeType (@TODO: Remove once PHPStan stops applying receiver generics to the factory's native static return type.) /** @var static $chunk */ $chunk = $this->newInstance(); @@ -1504,7 +1504,7 @@ public function chunkWhile(callable $callback): static $chunk = $this->newInstance(); } - // @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess) + // @phpstan-ignore offsetAssign.dimType (PHPStan rejects template keys on ArrayAccess) $chunk[$key] = $value; } diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php index e344957eb..07c1149aa 100644 --- a/src/collections/src/Enumerable.php +++ b/src/collections/src/Enumerable.php @@ -862,7 +862,7 @@ public function slice(int $offset, ?int $length = null): static; /** * Split a collection into a certain number of groups. * - * @return static + * @return static> */ public function split(int $numberOfGroups): static; diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index 626533b40..14d7bcc66 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -1331,6 +1331,8 @@ public function slice(int $offset, ?int $length = null): static /** * Split a collection into a certain number of groups. * + * @return ($numberOfGroups is positive-int ? static> : never) + * * @throws InvalidArgumentException */ #[Override] @@ -1480,7 +1482,7 @@ public function chunkWhile(callable $callback): static $chunk = new Collection; if ($iterator->valid()) { - // @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess) + // @phpstan-ignore offsetAssign.dimType (PHPStan rejects template keys on ArrayAccess) $chunk[$iterator->key()] = $iterator->current(); $iterator->next(); @@ -1493,7 +1495,7 @@ public function chunkWhile(callable $callback): static $chunk = new Collection; } - // @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess) + // @phpstan-ignore offsetAssign.dimType (PHPStan rejects template keys on ArrayAccess) $chunk[$iterator->key()] = $iterator->current(); $iterator->next(); diff --git a/types/Collections/Collection.php b/types/Collections/Collection.php index 52bb5d50c..0a2714ef4 100644 --- a/types/Collections/Collection.php +++ b/types/Collections/Collection.php @@ -831,6 +831,10 @@ function ($collection, $count) { assertType('Hypervel\Support\Collection>', $collection->split(3)); assertType('Hypervel\Support\Collection>', $collection::make([1])->split(3)); +assertType('Hypervel\Support\Collection>', $associativeCollection->split(2)); +/** @var Collection<5|8, User> $nonSequentialCollection */ +$nonSequentialCollection = new Collection([5 => new User, 8 => new User]); +assertType('Hypervel\Support\Collection>', $nonSequentialCollection->split(2)); assertType('string', $collection::make(['string' => 'string'])->sole('string', 'string')); assertType('string', $collection::make(['string' => 'string'])->sole('string', '=', 'string')); diff --git a/types/Collections/LazyCollection.php b/types/Collections/LazyCollection.php index 5e360bf86..e83451052 100644 --- a/types/Collections/LazyCollection.php +++ b/types/Collections/LazyCollection.php @@ -706,8 +706,9 @@ public function toArray(): array assertType('Hypervel\Support\LazyCollection', $collection::make([1])->slice(1)); assertType('Hypervel\Support\LazyCollection', $collection->slice(1, 2)); -assertType('Hypervel\Support\LazyCollection>', $collection->split(3)); -assertType('Hypervel\Support\LazyCollection>', $collection::make([1])->split(3)); +assertType('Hypervel\Support\LazyCollection>', $collection->split(3)); +assertType('Hypervel\Support\LazyCollection>', $collection::make([1])->split(3)); +assertType('Hypervel\Support\LazyCollection>', $associativeCollection->split(2)); assertType('string', $collection::make(['string' => 'string'])->sole('string', 'string')); assertType('string', $collection::make(['string' => 'string'])->sole('string', '=', 'string')); From c0d9b2e8864fed222355a6b8ea2c49d9f480d99b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:15:00 +0000 Subject: [PATCH 005/303] Require PHPStan 2.3.5 for framework development and the skeleton Replace the root, Database and Foundation 2.3.1 pins with ^2.3.5 and align the application skeleton minimum. Remove the superseded pin-removal todo. Keep the verified Data collection class-string narrowing regression suppressed at its exact argument with a code TODO. Preserve runtime validation and generic source contracts; do not add a global ignore or dependency patch. The combined changes pass full source and type-fixture analysis on PHPStan 2.3.5, formatting and targeted Data and collection tests. The skeleton manifest passes Composer validation. --- composer.json | 2 +- docs/todo.md | 1 - skeletons/hypervel/composer.json | 2 +- src/data/src/Eloquent/DataCollectionEloquentCast.php | 2 +- src/database/composer.json | 2 +- src/foundation/composer.json | 2 +- 6 files changed, 5 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 759900944..f2ed998f6 100644 --- a/composer.json +++ b/composer.json @@ -133,7 +133,7 @@ "mockery/mockery": "^1.6.15", "opis/json-schema": "^2.6.0", "pda/pheanstalk": "^8.0.2", - "phpstan/phpstan": "2.3.1", + "phpstan/phpstan": "^2.3.5", "phpunit/phpunit": "~13.4.3", "pusher/pusher-php-server": "^7.2", "resend/resend-php": "^1.0", diff --git a/docs/todo.md b/docs/todo.md index 03bbc57eb..d4ce09969 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -89,7 +89,6 @@ Remove older ways of doing things once a modern equivalent fully replaces them, ## Testing -- Remove the PHPStan `2.3.1` pins in the root, Database and Foundation manifests once a release fixes the 2.3.2 loop-narrowing regression: `DataCollectionEloquentCast::set()` rejects a value narrowed by `instanceof` against `class-string`. Verify the full source and type-fixture analyses before restoring the version ranges. - Adopt a Swoole release fixing the intermittent AIO shutdown hang in [swoole-src#6297](https://github.com/swoole/swoole-src/issues/6297). On 6.3.0RC1, ordinary coroutine scheduler teardown can wait forever for an idle native thread that missed its shutdown notification, including between tests. Verify the released fix with the standalone reproduction and affected suites; do not add a Hypervel workaround or a timing-dependent regression test. - Add the required title docblocks to the untitled non-test methods in `tests/`: about 7,600 methods in 1,500 files, mostly helpers, fixtures, and methods of anonymous classes in older tests. `AGENTS.md` requires a title on every method except test methods, but most existing tests omit them, and agents follow the surrounding code over the written rule, so new tests keep reproducing the gap until review catches it. Make the change as one approved sweep (short imperative titles only), then add an automated check, such as a PHPStan rule, so the code and the rule cannot drift apart again. Decide whether the check also covers `src/`. - Add native return types to the `test*` methods that lack them: about 5,600 methods in 490 files. `AGENTS.md` requires `: void` on test methods; a test that returns a value for `#[Depends]` takes its actual return type instead. As with method titles, untyped methods in existing files lead new tests to copy them, so make the change as one approved sweep and enforce it with the same automated check. diff --git a/skeletons/hypervel/composer.json b/skeletons/hypervel/composer.json index de862e879..97501c50c 100644 --- a/skeletons/hypervel/composer.json +++ b/skeletons/hypervel/composer.json @@ -23,7 +23,7 @@ "mockery/mockery": "^1.6.15", "nunomaduro/collision": "^8.9", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.3.5", "phpunit/phpunit": "~13.4.3", "swoole/ide-helper": "^6.0.2" }, diff --git a/src/data/src/Eloquent/DataCollectionEloquentCast.php b/src/data/src/Eloquent/DataCollectionEloquentCast.php index 2bc3f34ea..bfa750cb0 100644 --- a/src/data/src/Eloquent/DataCollectionEloquentCast.php +++ b/src/data/src/Eloquent/DataCollectionEloquentCast.php @@ -132,7 +132,7 @@ public function set(Model $model, string $key, mixed $value, array $attributes): $itemPayload = $item->transform($context); $payload[$itemKey] = $isAbstractClassCast - ? $this->createMorphEnvelope($item, $itemPayload) + ? $this->createMorphEnvelope($item, $itemPayload) // @phpstan-ignore argument.type (@TODO: Remove once PHPStan keeps class-string instanceof narrowing inside foreach.) : $itemPayload; } diff --git a/src/database/composer.json b/src/database/composer.json index b0f3a2dfb..3eced1fa2 100644 --- a/src/database/composer.json +++ b/src/database/composer.json @@ -59,7 +59,7 @@ }, "require-dev": { "fakerphp/faker": "^1.24", - "phpstan/phpstan": "2.3.1" + "phpstan/phpstan": "^2.3.5" }, "suggest": { "fakerphp/faker": "Required to use Eloquent model factories (^1.24)." diff --git a/src/foundation/composer.json b/src/foundation/composer.json index ad92e0d11..aed5ec02a 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -85,7 +85,7 @@ "hypervel/websocket-server": "^0.4" }, "require-dev": { - "phpstan/phpstan": "2.3.1" + "phpstan/phpstan": "^2.3.5" }, "suggest": { "algolia/algoliasearch-client-php": "Required to use the InteractsWithAlgolia trait (^4.49.0).", From 710b943b4575c2f6db932ee3c4a95130ffa5fbe8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:15:00 +0000 Subject: [PATCH 006/303] Allow the PHPStan service-container test base Add PHPStanTestCase to the existing permitted test-base table for PHPStan tests requiring its analysis service container. This covers upstream reflection and helper tests without introducing a custom harness or applying the coroutine wrapper to analyzer-only tests. Keep runtime integration tests on the existing Hypervel bases. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index daddff452..a0daf7413 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -458,6 +458,7 @@ Package-specific tests that require one database driver go in `tests/Integration | `Hypervel\Testbench\TestCase` | Integration tests (needs container for facades, config, DB, etc.) **or any test that needs a full app skeleton / writes through `BASE_PATH`** — testbench clones a disposable runtime skeleton per run and exposes its path via `BASE_PATH` (and `TESTBENCH_BASE_PATH` for subprocesses), deleted on shutdown. Committed source is never mutated. | | `PHPStan\Testing\RuleTestCase` | PHPStan rule tests in `tests/PHPStan`. | | `PHPStan\Testing\TypeInferenceTestCase` | PHPStan type-inference tests in `tests/PHPStan`. | +| `PHPStan\Testing\PHPStanTestCase` | PHPStan tests in `tests/PHPStan` that need PHPStan's service container. | Always call `parent::setUp()` in your setUp method. From d73d25873f56be1c3f2123b3670fe93ef643cf3d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:28:28 +0000 Subject: [PATCH 007/303] Settle PHPStan validation and queue authoring policies Record the PHPStan 2.3.5 prerequisite work and approved analysis test bases. Retain deprecated validation contracts for Laravel compatibility with narrow internal deprecation ignores. Specify default-enabled queue property-to-attribute guidance with concrete mappings, dynamic and inherited exceptions, consumer migration and focused tests. Remove the remaining policy approval gates and unnecessary rule-coordination requirement. Require attribute-aware chain precedence and consistent fake dispatch before migrating queue declarations. Preserve job overrides, empty-string fallback, zero identifiers and zero delays, and retain inherited debounce maxWait values. --- docs/plans/2026-11-04-hypervel-phpstan.md | 52 ++++++++++++++++------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 83df266b2..6683f23b5 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -6,11 +6,11 @@ Build `hypervel/phpstan` as a tracked Larastan port in `src/phpstan`, published Worktree: `/home/binaryfire/workspace/contrib/hypervel/components-phpstan`; branch: `feature/phpstan-package`, based on `0.4`. This worktree has its own Composer installation and a copy of Components' `.env`. Do not share vendor directories between worktrees. Private monorepo changes belong on its `main`, under its own instructions. -This document specifies the final design and implementation work. Read it, the root monorepo `CLAUDE.md`, Components' `AGENTS.md`, and relevant package READMEs after compaction. Follow their approval, porting and testing rules. Planning authorizes no commits, pushes, publication, or instruction-file edits. The implementation needs the owner's approval after plan review. +This document specifies the final design and implementation work. Read it, the root monorepo `CLAUDE.md`, Components' `AGENTS.md`, and relevant package READMEs after compaction. Follow their approval, porting and testing rules and the owner's authorized implementation workflow. Instruction-file edits and publication require explicit owner approval. Remaining work: -- Obtain owner implementation approval and the validation-contract decision specified below. +- The root, Database, Foundation and base skeleton require PHPStan `^2.3.5`; regression ignores, corrected split types and the DataTypeFactory parent-resolution fix are complete. Apply the same minimum to the new analyzer manifest when creating it. - Complete the implementation and verification sections below, keeping a single remaining-file checklist during porting. ## Design decisions @@ -77,9 +77,10 @@ Omit `checkOctaneCompatibility`, `generalizeEnvReturnType` and `redisDriver` wit | `noRawSleepCalls` | `true` | `hypervel.rawSleep` | | `noLegacyModelScopes` | `true` | `hypervel.legacyModelScope` | | `noLegacyModelProperties` | `true` | `hypervel.legacyModelProperty` | +| `noLegacyQueueProperties` | `true` | `hypervel.legacyQueueProperty` | | `noPipeSeparatedValidationRules` | `true` | `hypervel.pipeSeparatedValidationRules` | -The behavior and soundness limits of each new check are specified under Hypervel rules; a public name does not authorize speculative detection. Report one actionable diagnostic for the same underlying write rather than duplicating worker/static/config errors. No queue-property preference switch is defined before owner approval. +The behavior and soundness limits of each new check are specified under Hypervel rules; a public name does not authorize speculative detection. Report one actionable diagnostic for the same underlying write rather than duplicating worker/static/config errors. Map upstream-owned identifiers exactly as follows. Core PHPStan offset/argument/type identifiers are unchanged, including when emitted by a ported rule. The two omitted diagnostics have no new identifier. @@ -125,7 +126,7 @@ Local upstream: `/home/binaryfire/workspace/references/larastan` (`3.x`). Read i | Larastan `src/Support/ConfigParser.php` | Uses PHPStan scopes and `NodeScopeResolver`, including imports and preceding statements. Preserve the full parser, recursion protection, lazy evaluation and dependency tracking. | | Larastan `src/Properties/{ModelPropertyHelper,MigrationHelper,SquashedMigrationHelper,MigrationCache}.php`, `src/SQL/` | Model schema inference and parsed-schema caching; parser/database support needs Hypervel adaptation. | | Larastan `extension.neon`, `src/Rules/`, `src/LarastanStubFilesExtension.php`, `stubs/`, `tests/`, `e2e/` | Complete configuration, rule, stub and test inventory; do not infer coverage from documentation alone. | -| Components `docs/todo.md`, Static Analysis and Container sections of `AGENTS.md` | Cancellation examples, lifetime semantics, unmatched ignores, PHPStan version regression and intended eventual legacy removals. | +| Components `docs/todo.md`, Static Analysis and Container sections of `AGENTS.md` | Cancellation examples, lifetime semantics, unmatched ignores and intended eventual legacy removals. | | `src/testbench/src/Foundation/{Application,Config}.php`, `src/testbench/src/Concerns/CreatesApplication.php` | `testbench.yaml`, `createFromConfig()`, discovery and the real per-process runtime skeleton. | | `src/di/src/Bootstrap/GenerateProxies.php`, `src/di/src/Aop/ProxyManager.php` | Prepended proxy loader, original-source map, registry sealing, fingerprint reuse and atomic replacement. | | `src/support/src/ServiceProvider.php`, `src/foundation/src/Bootstrap/LoadConfiguration.php` | Shallow top-level merging; named mergeable groups replace an entire matching entry. | @@ -142,7 +143,7 @@ PHPStan documentation: [extension testing](https://phpstan.org/developing-extens 2. Preserve the upstream package-root layout: `bootstrap.php`, `extension.neon`, `src/`, and necessary `stubs/`. Use existing `Methods`, `Properties`, `Reflection`, `ReturnTypes`, `Rules`, `Support`, `Types`, `Collectors`, `SQL` and `Concerns` directories. Put new model rules at `Rules/` root beside upstream's model rules; use `Rules/Coroutine` and `Rules/Validation` for those genuinely new domains. Do not introduce separate engines for Hypervel additions. 3. Declare direct production dependencies of the analyzer in its own manifest, even though consumers install the analyzer with `--dev`. Require `phpstan/phpstan-deprecation-rules`: included deprecation reporting is part of the supported installation contract, not an unrelated suggested tool. Check the latest compatible stable release on Packagist before adding it; the verified 2.0.5 release requires PHPStan `^2.1.39`, compatible with the planned analyzer range. Translate required Illuminate components to their actual Hypervel owners. Do not depend on `hypervel/components`, the entire framework metapackage, Head, Scout, private packages or every optional integration. Suggest `hypervel/testbench` for package analysis. Guard optional integrations using existing reflection/stub-extension mechanisms; preserve an absent-component installation test. 4. Add root autoloading and `replace` metadata for `hypervel/phpstan`. The root replaces the split package, so it must declare the analyzer's external dependencies itself; add `phpstan/phpstan-deprecation-rules` to root `require-dev` with `composer require --dev` at the selected compatible constraint. Use Composer commands for other root dependency changes; subpackage manifests without lockfiles may be edited directly. Do not add a runtime service provider just to register PHPStan services. Register them in PHPStan's container. -5. Resolve the PHPStan version requirement before importing APIs: Larastan requires `^2.3.5`, while root, Database and Foundation pin `2.3.1` for the recorded `DataCollectionEloquentCast::set()` loop-narrowing regression. Check the current compatible stable release on Packagist, reproduce the narrowed `class-string` case, and run source/type fixtures against the candidate. Restore a supported range only after that regression passes. Do not loosen source types, add vendor patches or silently discard DI attributes to evade it. If still broken, follow the dependency-bug owner decision workflow with a concrete reproduction. Check compatible stable versions for every added dependency, including `iamcal/sql-parser`, optional phpMyAdmin support and any other SQL parser. +5. Require PHPStan `^2.3.5` in the root, Database, Foundation and new analyzer manifests, retaining current Larastan's supported APIs and DI registration. Use local identifier-specific ignores with adjacent code-comment `@TODO`s for confirmed PHPStan regressions: generic narrowing in `DataCollectionEloquentCast::set()` and factory native-return generics in Collection's `split()`/`chunkWhile()`. Do not add NEON ignores or `docs/todo.md` entries for them, alter correct runtime code/types, or add compatibility machinery for 2.3.1. Inspect new diagnostics individually and fix real defects before suppressing confirmed false positives. Check compatible stable versions for every added dependency, including `iamcal/sql-parser`, optional phpMyAdmin support and any other SQL parser. 6. Components has no extension installer: in both `phpstan.neon.dist` and `phpstan.types.neon.dist`, replace the four old includes with explicit `src/phpstan/extension.neon` and `vendor/phpstan/phpstan-deprecation-rules/rules.neon` includes, and update type-fixture scan directories. Check each skeleton/private consumer's actual installer and plugin configuration before choosing its loading mode; use discovery or an explicit include for each extension without duplication. Keep source analysis and the dedicated `types/` configuration distinct; do not run normal source analysis over `tests/`. 7. Update the base skeleton, other maintained skeletons and Testbench package examples to install `hypervel/phpstan` as a dev dependency and use the standard setup. Inspect each current manifest and analysis script; preserve existing additional Carbon/Swoole support where still necessary. 8. `bin/split.sh` discovers packages from manifests. Ensure the new package passes existing publishing checks; do not introduce a second publishing system. Prepare the split repository/Packagist setup instructions. Creating external repositories, publishing and pushing require owner authorization at the actual publication step. @@ -338,7 +339,7 @@ Use actual container semantics: unbound buildable classes auto-singleton unless Start from evidenced container candidates: registered `bind`/`singleton`/`scoped`/`instance` targets; statically resolved `make(X::class)`/`app(X::class)` calls; and dependencies injected into framework entry points actually constructed by the container (registered middleware/controllers/listeners/commands, after verifying their construction path). Track explicit and auto-singleton lifetimes separately from scoped/fresh/parameterized resolution. A class appearing only in `new X` is not a shared candidate. Use registration collectors for cross-file diagnostics and the booted container's existing metadata where available, honoring aliases, contextual bindings and mixed/unknown resolution paths. Do not recursively assume every referenced class is container-built. -1. Detect known request/operation state retained on worker-shared objects, including constructor capture and writes during known request/job handling. Start from the todo's property-write candidates, then classify their ownership using types, registration and the existing `Boot-only.`/`Tests only.` contracts. Intentional driver caches, registries and resolved config are valid worker state. Measure candidate findings and false positives across Components and both private packages before choosing an exemption mechanism. Reach peer consensus on the smallest adequate mechanism, using existing contracts and reasoned local suppressions where sufficient; a new public annotation requires an exact proposal and owner approval. Do not preselect per-write suppression or a whole-class exemption before measuring real cases. +1. Detect known request/operation state retained on worker-shared objects, including constructor capture and writes during known request/job handling. Start from the todo's property-write candidates, then classify their ownership using types, registration and the existing `Boot-only.`/`Tests only.` contracts. Intentional driver caches, registries and resolved config are valid worker state. Measure candidate findings and false positives across Components and both private packages before choosing an exemption mechanism. Reach peer consensus on the smallest adequate mechanism, using existing contracts and reasoned local suppressions where sufficient. Do not preselect per-write suppression or a whole-class exemption before measuring real cases. 2. Detect process-global configuration writes and static state mutation in known request execution contexts; distinguish boot registrations and `flushState()` cleanup. Detect direct request-derived cache keys only where the source establishes an unbounded worker-lifetime cache. Do not pretend to prove arbitrary taint propagation or boundedness across unknown calls. 3. Detect supported child-coroutine spawning APIs inside transaction callbacks when work is being moved off the transaction-owning coroutine. Explain that pooled database ownership belongs to the parent and the child does not inherit its transaction. Preserve established transaction-preserving test infrastructure and calls that do not actually spawn children. 4. Diagnose raw source `sleep()`/`usleep()` where the documented replacement is `Hypervel\Support\Sleep`. Preserve real-time test/infrastructure waits and other explicitly justified timing contracts. @@ -353,9 +354,29 @@ Add configurable, default-enabled recommendations for the owner's modern convent - Model subclass/consumer-trait configuration property declarations with complete attribute equivalents -> the corresponding attributes listed in `docs/todo.md`: table/connection/key/incrementing/timestamps/date format/fillable/guarded/hidden/visible/appends/touches/refreshes. The base model and its owning storage traits still declare runtime storage; do not diagnose those declarations. Preserve runtime setters/assignments and properties without equivalent attributes, including casts, defaults, eager relations and pagination. Report actual replacements, including config-driven values that require runtime initialization rather than an attribute constant. - Pipe-separated validation lists -> arrays at verified validation entry points, FormRequest and Data rules. Preserve pipes inside individual regex rules and dynamic rule objects/closures. -Queue-property authoring diagnostics are a candidate requiring an owner policy decision, not an enabled rule in this plan. Before requesting approval, present the exact declaration-to-attribute mapping and dynamic exceptions (for example `Tries`, `Timeout`, `FailOnTimeout`, `Backoff`, `MaxExceptions`, `UniqueFor`, `Connection`, `Queue`). Do not infer blanket approval from the model-property policy or remove queue property support. If not approved for implementation, record the candidate in `docs/todo.md` with that mapping and its open policy questions; do not add dormant enforcement machinery. +Add `Rules/Queue/NoLegacyQueuePropertiesRule` under the default-enabled `noLegacyQueueProperties` switch. Follow Laravel 13's [documented queue attributes](https://laravel.com/framework/docs/13.x/queues) and Hypervel's actual attribute consumers. Diagnose fixed configuration-property declarations on recognized queued jobs, listeners, mailables, notifications and broadcast events only where that object's dispatch path reads the equivalent attribute; an unrelated class with a property named `queue` is not a match. This is an authoring-policy check, not a claim that supported properties are broken. -These diagnostics enforce authoring policy; messages must not say a currently supported API is unavailable. Keep current legacy type inference/runtime behavior until its removal is explicitly approved. Do not automatically execute the much larger model/validation/queue runtime removal todo as part of importing an analyzer. A runtime removal needs a concrete, owner-approved change covering source, consumers, fixtures, error messages and porting documentation together; its cached boot checks must not add work per hydrated model. This boundary preserves supported APIs while delivering the requested static guidance now. +| Fixed property declaration | Preferred queue attribute | +|---|---| +| `$tries` | `#[Tries(...)]` | +| `$timeout` | `#[Timeout(...)]` | +| `$maxExceptions` | `#[MaxExceptions(...)]` | +| `$backoff` containing integer seconds or an integer sequence | `#[Backoff(...)]` | +| `$uniqueFor` on a unique job, listener or broadcast event | `#[UniqueFor(...)]` | +| `$debounceFor` on a debounced job/listener | `#[DebounceFor(...)]`, retaining any inherited `maxWait` | +| `$connection`, `$queue` | `#[Connection(...)]`, `#[Queue(...)]` | +| `$delay` containing integer seconds | `#[Delay(...)]` | +| `$failOnTimeout = true` | `#[FailOnTimeout]` | +| `$countCrashesAsExceptions = true` | `#[CountCrashesAsExceptions]` | +| `$deleteWhenMissingModels = true` | `#[DeleteWhenMissingModels]` | + +Preserve dynamic `tries()`, `backoff()`, `uniqueFor()` and `retryUntil()` methods, recipient/event-specific methods, and runtime setters/assignments such as `onConnection()`, `onQueue()` and `delay()`. Do not diagnose framework-owned storage declarations or wrapper jobs that copy settings from another object, promoted/otherwise per-instance state, declarations with no fixed value, or values the attribute cannot represent. Null defaults mean no fixed setting; false flags can override inherited marker attributes and must not be replaced with a true marker or silently removed. Respect `ReadsClassAttributes` inheritance/property precedence and method overrides. A child replacing `$debounceFor = 10` beneath `#[DebounceFor(30, maxWait: 300)]` needs `#[DebounceFor(10, maxWait: 300)]`: a nearer attribute replaces the inherited attribute as a whole. Suggest moving a trait property's setting onto that trait only when the target uses it directly; attribute lookup does not recurse into nested traits, so leave that unsupported conversion silent. Give the concrete replacement without an automatic rewrite. Cover positive mappings and negative dynamic/inheritance/storage cases through focused rule fixtures; keep runtime APIs and source types intact. + +Before applying the queue rule's connection/queue/delay conversions, fix the existing chain precedence gap. `PendingChain::dispatch()`, `Queueable::dispatchNextJobInChain()` and `ChainedBatch::attachRemainderOfChainToEndOfBatch()` read only job properties, so chain defaults can overwrite explicit attributes before Dispatcher resolves them. Resolve the job's effective setting using the existing attribute/property precedence before applying chain defaults. Preserve explicit runtime overrides, inheritance, the tested empty-string fallback and valid `'0'` identifiers; preserve a job's zero delay. Reuse the existing attribute-reading logic at framework-owned boundaries without adding lookup methods to user jobs, duplicating precedence rules or creating a registry/cache. Laravel has the same gap, so add a brief comment at each changed precedence decision explaining that the job's own attribute or property beats the chain default. Cover first and subsequent jobs, the remainder after a chained batch, and first-job delay in the relevant existing test files. + +Keep fake dispatch consistent with those paths. `QueueFake` pass-through must resolve `Connection` through its existing `ReadsQueueAttributes` support instead of reading only `$job->connection`. `PendingChainFake` currently overwrites first-job settings unconditionally through `allOnConnection()`, `allOnQueue()` and `delay()`; share first-job preparation with `PendingChain` so the fake preserves both job properties and attributes while still dispatching through its supplied fake bus. Add focused regressions for selective queue-fake forwarding and fake-chain first-job precedence. These are runtime correctness fixes required before converting consumers, not analyzer workarounds. + +These diagnostics enforce authoring policy; messages must not say a currently supported API is unavailable. Keep current legacy type inference/runtime behavior until its removal is explicitly approved. Do not automatically execute the model and validation runtime-removal todos as part of importing an analyzer. A runtime removal needs a concrete, owner-approved change covering source, consumers, fixtures, error messages and porting documentation together; its cached boot checks must not add work per hydrated model. This boundary preserves supported APIs while delivering the requested static guidance now. Retain PHPStan's autofix integration for verified local transformations, including unambiguous pipe lists and corrected `value()`/`with()` calls with preserved evaluation effects. Test fixes by reanalysis and runtime equivalence where relevant. Do not offer scope-renaming or model-property-to-attribute autofixes: cross-file direct calls and inherited precedence cannot be established by the local fix. Give an exact replacement in the diagnostic instead. Exclude `regex`/`not_regex` cases from mechanical pipe splitting. Audit other retained upstream fixes by the same criterion. Never run `--fix` automatically during ordinary analysis. @@ -363,11 +384,11 @@ Retain PHPStan's autofix integration for verified local transformations, includi ### Harness and fixture ownership -Port upstream rule/type/reflection/unit/integration tests under `tests/PHPStan`, preserving relative upstream placement and all applicable cases. Use PHPStan's `RuleTestCase` and `TypeInferenceTestCase` for rule and type-inference tests, as listed in `AGENTS.md` under Base classes. These analysis tests do not need Hypervel's coroutine wrapper. Runtime integration tests retain the appropriate Hypervel base class. No further test-base instruction edit or approval is required. +Port upstream rule/type/reflection/unit/integration tests under `tests/PHPStan`, preserving relative upstream placement and all applicable cases. Use PHPStan's `RuleTestCase` and `TypeInferenceTestCase` for rule and type-inference tests, and `PHPStanTestCase` for other PHPStan tests needing its service container, as listed in `AGENTS.md` under Base classes. These analysis tests do not need Hypervel's coroutine wrapper or a custom container harness. Runtime integration tests retain the appropriate Hypervel base class. Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime instead of modifying its committed skeleton or always-loading package routes. Keep existing `types/` fixtures as public typing regressions and update moved references. -Keep type fixtures that intentionally exercise still-supported APIs. In `phpstan.types.neon.dist`, set `noLegacyModelScopes`, `noLegacyModelProperties` and `noPipeSeparatedValidationRules` to `false`, with one comment that these fixtures assert supported types rather than authoring policy. Also set `checkModelMethodVisibility` and `noUnnecessaryEnumerableToArrayCalls` to `false` in this harness: `CustomBuilderForwarding.php` deliberately declares public scopes, and Collection/LazyCollection fixtures assert conversion return types. These preferences retain their normal defaults in source/consumer analysis and their dedicated `RuleTestCase` coverage. Preserve type assertions; do not delete compatibility cases. For upstream always-registered diagnostics without a switch, use a local identifier suppression with a fixture-specific reason only at the intentional call (for example the existing `value('foo', 42)` and `with(new User)` helper fixtures). Do not invent public switches or a second rule-registration system solely for the harness. +Keep type fixtures that intentionally exercise still-supported APIs. In `phpstan.types.neon.dist`, set `noLegacyModelScopes`, `noLegacyModelProperties`, `noLegacyQueueProperties` and `noPipeSeparatedValidationRules` to `false`, with one comment that these fixtures assert supported types rather than authoring policy. Also set `checkModelMethodVisibility` and `noUnnecessaryEnumerableToArrayCalls` to `false` in this harness: `CustomBuilderForwarding.php` deliberately declares public scopes, and Collection/LazyCollection fixtures assert conversion return types. These preferences retain their normal defaults in source/consumer analysis and their dedicated `RuleTestCase` coverage. Preserve type assertions; do not delete compatibility cases. For upstream always-registered diagnostics without a switch, use a local identifier suppression with a fixture-specific reason only at the intentional call (for example the existing `value('foo', 42)` and `with(new User)` helper fixtures). Do not invent public switches or a second rule-registration system solely for the harness. Exclude mirrored analyzer `data/` directories and any intentionally preserved fixture-app files from `.php-cs-fixer.php` before running `lint:fix`; its current finder covers all tests and would rewrite deliberate syntax and error line numbers. Keep test classes and ordinary Hypervel support files formatted normally. Format new Hypervel-only fixtures before recording expected diagnostic lines, and rerun affected tests if a formatter changes those files. Convert bare `@phpstan-ignore-line`/`@phpstan-ignore-next-line` comments in ported source to accurate identifier-specific ignores with reasons, or fix the underlying type; the package itself is analyzed with the new ignore policy. @@ -404,7 +425,8 @@ For e2e baselines, preserve known intentional analyzer limitations with reasons ## Consumer migration and final cleanup -- Resolve the deprecated validation-contract decision below with the owner before auditing those internal usages; do not let the general suppression guidance silently decide it. +- Retain the deprecated validation contracts and audit their necessary compatibility usages as specified below. +- Convert declarations flagged by the default authoring rules in Components source, skeletons, generator stubs, private packages, apps and documentation to the recommended forms, preserving runtime behavior and inherited precedence. Complete the chain/fake dispatch fixes before converting queue settings. This includes Scout's fixed job options and unique expiry, framework model defaults and the queued-listener `$uniqueFor` example in `src/docs/events.md`, which should use `#[UniqueFor(3600)]`. Keep compatibility type/rule fixtures where they test supported APIs; use a reasoned local ignore only for a necessary compatibility case, never a baseline or blanket policy disablement. - Audit `packages/hypervel`, `packages/hypervel-dev` and affected `apps/` under `/home/binaryfire/workspace/monorepo`. Both private package configs currently override unmatched-ignore reporting to false. Add the analyzer as a dev dependency with Composer, update source/type/generated analysis configs where applicable, supply package Testbench configuration, and remove obsolete direct-extension includes/scans. - Private Composer repositories currently point at the main Components `src/*`. Validate against this worktree through a task-local Composer setup with its own installed dependencies, without permanently committing a worktree-specific path or disrupting another workload's vendor directory. Final private manifests reference the intended durable package source/version. - Audit every suppression affected by improved inference or the new ignore policy in Components, both private packages, skeletons and affected apps. Convert every reason-less identifier ignore and every blanket line/next-line ignore in their analyzed paths to a source fix or an accurate identifier-specific ignore with a parenthesized reason. Do not limit this to newly ported files or errors that became unmatched. Remove obsolete inline ignores/global patterns and correct stale explanations such as claiming a boot-registered macro is unknowable. Retain narrowly justified limitations; new broad ignores require the normal owner approval. @@ -416,15 +438,13 @@ For e2e baselines, preserve known intentional analyzer limitations with reasons - Search the affected source, tests, types, active docs, manifests and scripts for obsolete namespaces, old extension paths, duplicated services, unused helper classes/settings, inaccurate compatibility claims and stale comments. Remove them together with their consumers. Keep upstream references/license attribution and historical plans intact. - Before completion, review the final diff for source contract correctness, Laravel-style naming/ergonomics, coroutine ownership, analysis-only versus runtime cost, dependency isolation and unnecessary machinery. No custom service-container interpreter, universal lifetime annotation system, private PHPStan API patch or speculative registry should be needed. -### Owner decision: deprecated validation contracts +### Deprecated validation contracts `Hypervel\Contracts\Validation\Rule` and `ImplicitRule` are deprecated, but remain part of the supported validation implementation and public contract, matching current Laravel. Built-in `Email`, `Enum`, `Can`, `Password`, `File` and `AnyOf` expose `passes()`/`message()` through these interfaces. `InvokableValidationRule` also adapts the modern `ValidationRule::validate()` API into that execution path. A removal affects Validator, RuleCompiler, ValidationRuleParser, ClosureValidationRule, ConditionalRules, Rule's builder/type declarations, ClonesCustomRules, and Data's RuleNormalizer and validation Rule attribute, including their type signatures and tests. -Before implementation, obtain an explicit owner decision. The recommendation for this analyzer port is to retain the existing runtime contracts, keep their deprecation tags and user-facing diagnostics, and authorize only the specific framework compatibility suppressions needed until the separately approved legacy migration. This is a public-API decision, not an automatic response to an analysis error. Removing just `implements Rule` while keeping the methods still breaks interface/type compatibility; retaining both interfaces does not remove the deprecation diagnostic. Do not wrap or rewrite the validation engine merely to hide that fact. - -If the owner approves retention, add a named **Legacy validation rule contracts** entry under `docs/todo.md` Legacy removals, describing the eventual modern-contract conversion, affected adapters/built-ins/Data consumers, public `passes()`/`message()` and interface compatibility decisions, and ongoing upstream conversion requirements. Use the actual emitted deprecation identifier at each necessary framework boundary with one consistent reason: "Retains supported validation Rule/ImplicitRule compatibility; see docs/todo.md, Legacy validation rule contracts." Avoid a global ignore. Existing applications/private rules should use `ValidationRule` where their public contract permits migration. User code implementing the deprecated interfaces continues to receive diagnostics; do not exclude these interfaces in the analyzer. +Retain these runtime contracts and built-in implementations in line with Laravel, including their deprecation tags and user-facing diagnostics. Use narrow compatibility suppressions for the framework's necessary internal uses. Removing just `implements Rule` while keeping the methods would still break interface/type compatibility. Do not wrap or rewrite the validation engine to hide deprecation reports. -If the owner instead approves removal in this work, first replace this decision section with a concrete source/consumer/test/documentation migration that preserves required behavior and explicitly records the approved API departures. Execute that migration before completing the consumer audit. Do not leave a confirmed runtime defect unresolved under either choice; deprecation alone does not establish one. +Add a named **Legacy validation rule contracts** entry under `docs/todo.md` Legacy removals, recording that these contracts are retained for upstream parity and that any later removal must cover built-ins/adapters/Data consumers, public `passes()`/`message()` behavior and interface compatibility together. Use the actual emitted deprecation identifier at each necessary framework boundary with one consistent reason: "Retains supported validation Rule/ImplicitRule compatibility; see docs/todo.md, Legacy validation rule contracts." Avoid a global ignore. Existing applications/private rules should use `ValidationRule` where their public contract permits migration. User code implementing the deprecated interfaces continues to receive diagnostics; do not exclude these interfaces in the analyzer. Deprecation alone is not a runtime defect. ## Completion criteria From c9b51f3046ae68f5684eb0500534ed27c553a1dd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:37:10 +0000 Subject: [PATCH 008/303] Resolve Composer vendor paths for framework metadata discovery Add a shared vendor-path resolver that honors explicit Composer environment overrides, recognizes loaded custom installs, and keeps the default layout inexpensive. Use the resolver for root test-state registrar discovery. Cover relative and absolute overrides, custom registered loaders, and the unloaded-root fallback in the existing Composer tests. --- src/support/src/Composer.php | 47 +++++++++++ .../src/PHPUnit/TestStateRegistrars.php | 8 +- tests/Support/SupportComposerTest.php | 78 +++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/src/support/src/Composer.php b/src/support/src/Composer.php index 273030ea2..2cfcfeee5 100644 --- a/src/support/src/Composer.php +++ b/src/support/src/Composer.php @@ -297,6 +297,53 @@ protected static function findLoader(): ClassLoader throw new RuntimeException('Composer loader not found.'); } + /** + * Get the vendor directory of the Composer install rooted at the given path. + * + * The loaded install records its own root, so a vendor directory chosen through + * project or global configuration, or while installing, needs no Composer + * configuration parsing. A relative COMPOSER_VENDOR_DIR is relative to the root. + */ + public static function vendorPath(string $rootPath): string + { + $vendorPath = Env::get('COMPOSER_VENDOR_DIR'); + + if (is_string($vendorPath) && $vendorPath !== '') { + return str_starts_with($vendorPath, '/') ? $vendorPath : $rootPath . '/' . $vendorPath; + } + + $root = realpath($rootPath); + + if ($root !== false) { + $loaders = ClassLoader::getRegisteredLoaders(); + + // The default layout needs no installed.php read, which is large and runs + // whenever an application builds its package manifest. + $defaultVendorDirectory = realpath($rootPath . '/vendor'); + + if ($defaultVendorDirectory !== false && isset($loaders[$defaultVendorDirectory])) { + return $rootPath . '/vendor'; + } + + foreach (array_keys($loaders) as $vendorDirectory) { + $installedPath = $vendorDirectory . '/composer/installed.php'; + + if (! is_file($installedPath)) { + continue; + } + + /** @var array{root: array{install_path: string}} $installed */ + $installed = require $installedPath; + + if (realpath($installed['root']['install_path']) === $root) { + return $vendorDirectory; + } + } + } + + return $rootPath . '/vendor'; + } + /** * Flush all static state. */ diff --git a/src/testing/src/PHPUnit/TestStateRegistrars.php b/src/testing/src/PHPUnit/TestStateRegistrars.php index 75a63150f..f18f77a61 100644 --- a/src/testing/src/PHPUnit/TestStateRegistrars.php +++ b/src/testing/src/PHPUnit/TestStateRegistrars.php @@ -7,7 +7,7 @@ use Composer\InstalledVersions; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\PackageManifest; -use Hypervel\Support\Env; +use Hypervel\Support\Composer; use RuntimeException; class TestStateRegistrars @@ -34,12 +34,8 @@ public function __construct( public static function forRootInstall(): static { $basePath = static::installedRootPath(); - $vendorPath = Env::get('COMPOSER_VENDOR_DIR'); - return new static( - $basePath, - is_string($vendorPath) && $vendorPath !== '' ? $vendorPath : $basePath . '/vendor' - ); + return new static($basePath, Composer::vendorPath($basePath)); } /** diff --git a/tests/Support/SupportComposerTest.php b/tests/Support/SupportComposerTest.php index dad4119db..073df20f8 100644 --- a/tests/Support/SupportComposerTest.php +++ b/tests/Support/SupportComposerTest.php @@ -7,6 +7,8 @@ use Composer\Autoload\ClassLoader; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Composer; +use Hypervel\Support\Env; +use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\TestWith; @@ -18,6 +20,41 @@ class SupportComposerTest extends TestCase { + protected string $tempDir; + + protected ?string $originalComposerVendorDirectory = null; + + protected function setUp(): void + { + parent::setUp(); + + $originalComposerVendorDirectory = Env::get('COMPOSER_VENDOR_DIR'); + $this->originalComposerVendorDirectory = is_string($originalComposerVendorDirectory) + ? $originalComposerVendorDirectory + : null; + + Env::deleteMany(['COMPOSER_VENDOR_DIR']); + Env::flushRepository(); + + $this->tempDir = ParallelTesting::tempDir('SupportComposerTest'); + (new Filesystem)->deleteDirectory($this->tempDir); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->tempDir); + + Env::deleteMany(['COMPOSER_VENDOR_DIR']); + Env::flushRepository(); + + if ($this->originalComposerVendorDirectory !== null) { + Env::getRepository()->set('COMPOSER_VENDOR_DIR', $this->originalComposerVendorDirectory); + } + + parent::tearDown(); + } + public function testGetLoader(): void { $loader = Composer::getLoader(); @@ -54,6 +91,47 @@ public function testFindsTheApplicationLoaderAfterAnAuxiliaryLoaderIsPrepended() } } + public function testVendorPathUsesTheLoadedInstallOwningTheRoot(): void + { + $this->assertSame(dirname(__DIR__, 2) . '/vendor', Composer::vendorPath(dirname(__DIR__, 2))); + } + + public function testVendorPathUsesACustomVendorDirectoryOfTheLoadedInstall(): void + { + $vendorPath = $this->tempDir . '/libs'; + mkdir($vendorPath . '/composer', 0777, true); + file_put_contents( + $vendorPath . '/composer/installed.php', + " ['install_path' => __DIR__ . '/../../']];", + ); + + $loader = new ClassLoader($vendorPath); + $loader->register(); + + try { + $this->assertSame($vendorPath, Composer::vendorPath($this->tempDir)); + } finally { + $loader->unregister(); + } + } + + public function testVendorPathDefaultsToTheRootVendorDirectoryWithoutALoadedInstall(): void + { + $this->assertSame($this->tempDir . '/vendor', Composer::vendorPath($this->tempDir)); + } + + #[TestWith(['/custom/vendor', '/custom/vendor'])] + #[TestWith(['libs', '{root}/libs'])] + public function testVendorPathPrefersComposerVendorDirEnvironmentVariable(string $value, string $expected): void + { + Env::getRepository()->set('COMPOSER_VENDOR_DIR', $value); + + $this->assertSame( + str_replace('{root}', dirname(__DIR__, 2), $expected), + Composer::vendorPath(dirname(__DIR__, 2)), + ); + } + public function testDumpAutoloadRunsTheCorrectCommand(): void { $composer = $this->mockComposer(['composer', 'dump-autoload']); From f80cedf161c0e52472c480a9eb4708ef0dd7c858 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:37:10 +0000 Subject: [PATCH 009/303] Keep Testbench root package discovery consistent across manifest readers Add explicit root-package discovery for standalone Testbench applications and normalize root metadata both when building and reading cached manifests. Preserve discovery filters and allow unnamed roots when no root metadata is being discovered. Forward the parent's discovery decision to subprocesses borrowing its runtime and honor the manifest's configured cache path during refresh. Extend existing tests for cached and fresh discovery, borrowed runtimes, root metadata, and cache-path failures. --- src/foundation/src/PackageManifest.php | 29 ++- .../Actions/RefreshPackageDiscovery.php | 7 +- src/testbench/src/Foundation/Application.php | 20 +- .../src/Foundation/PackageManifest.php | 87 +++++++-- .../src/Foundation/Process/RemoteCommand.php | 12 ++ .../Bootstrap/CreateVendorSymlinkTest.php | 9 +- .../Foundation/PackageManifestTest.php | 179 +++++++++++++++++- .../Foundation/Process/RemoteCommandTest.php | 56 +++++- 8 files changed, 356 insertions(+), 43 deletions(-) diff --git a/src/foundation/src/PackageManifest.php b/src/foundation/src/PackageManifest.php index 623e31c6d..9c06f0469 100644 --- a/src/foundation/src/PackageManifest.php +++ b/src/foundation/src/PackageManifest.php @@ -7,7 +7,7 @@ use Exception; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Collection; -use Hypervel\Support\Env; +use Hypervel\Support\Composer; use Hypervel\Support\Json; use RuntimeException; use UnexpectedValueException; @@ -66,7 +66,7 @@ public function __construct(Filesystem $files, string $basePath, ?string $manife $this->files = $files; $this->basePath = $basePath; $this->manifestPath = $manifestPath; - $this->vendorPath = Env::get('COMPOSER_VENDOR_DIR') ?: $basePath . '/vendor'; + $this->vendorPath = Composer::vendorPath($basePath); } /** @@ -164,9 +164,17 @@ protected function getManifest(): array $this->build(); } - $this->rawManifest = is_file($this->manifestPath) + $manifest = is_file($this->manifestPath) ? $this->files->getRequire($this->manifestPath) : []; + + $this->rawManifest = $this->prepareManifest($manifest); + + // A cached manifest can predate the current discovery settings. Keep the + // file in step for other processes that read it. + if ($this->rawManifest !== $manifest) { + $this->write($this->rawManifest); + } } $ignore = static::$packagesToIgnore; @@ -266,11 +274,11 @@ public static function discoverInstalledPackages(Filesystem $files, string $vend */ public function build(): void { - $manifest = static::discoverInstalledPackages( + $manifest = $this->prepareManifest(static::discoverInstalledPackages( $this->files, $this->vendorPath, $this->packagesToIgnore() - ); + )); $this->write($manifest); @@ -278,6 +286,17 @@ public function build(): void $this->rawManifest = $manifest; } + /** + * Prepare a built or cached manifest before it is stored or used. + * + * @param array> $manifest + * @return array> + */ + protected function prepareManifest(array $manifest): array + { + return $manifest; + } + /** * Format the given package name. */ diff --git a/src/testbench/src/Foundation/Actions/RefreshPackageDiscovery.php b/src/testbench/src/Foundation/Actions/RefreshPackageDiscovery.php index 581479579..3c17c3ee2 100644 --- a/src/testbench/src/Foundation/Actions/RefreshPackageDiscovery.php +++ b/src/testbench/src/Foundation/Actions/RefreshPackageDiscovery.php @@ -21,12 +21,13 @@ public function handle(ApplicationContract $app): void { $filesystem = new Filesystem; - $cachedPath = $app->bootstrapPath('cache/packages.php'); + $manifest = $app->make(PackageManifest::class); + $cachedPath = $manifest->manifestPath; - if ($filesystem->exists($cachedPath) && ! $filesystem->delete($cachedPath)) { + if ($cachedPath !== null && $filesystem->exists($cachedPath) && ! $filesystem->delete($cachedPath)) { throw new RuntimeException("Unable to delete package manifest [{$cachedPath}]."); } - $app->make(PackageManifest::class)->build(); + $manifest->build(); } } diff --git a/src/testbench/src/Foundation/Application.php b/src/testbench/src/Foundation/Application.php index d5943f817..87c4319a1 100644 --- a/src/testbench/src/Foundation/Application.php +++ b/src/testbench/src/Foundation/Application.php @@ -22,7 +22,8 @@ * @phpstan-type TConfig array{ * extra?: TOptionalExtraConfig, * load_environment_variables?: bool, - * enables_package_discoveries?: bool + * enables_package_discoveries?: bool, + * discovers_root_package?: bool * } */ class Application @@ -69,6 +70,11 @@ class Application */ protected bool $loadEnvironmentVariables = false; + /** + * Discover the package's own Composer metadata outside the Testbench CLI. + */ + protected bool $discoversRootPackage = false; + /** * Create a new application resolver. * @@ -186,6 +192,10 @@ public function configure(array $options): static Arr::set($options, 'extra.dont-discover', $options['enables_package_discoveries'] ? [] : ['*']); } + if (isset($options['discovers_root_package']) && \is_bool($options['discovers_root_package'])) { + $this->discoversRootPackage = $options['discovers_root_package']; + } + $config = Arr::only($options['extra'] ?? [], array_keys($this->config)); /** @var array{ @@ -220,6 +230,14 @@ public function ignorePackageDiscoveriesFrom(): array return $this->config['dont-discover']; } + /** + * Determine if package discovery includes the package's own Composer metadata. + */ + public function discoversRootPackage(): bool + { + return $this->discoversRootPackage; + } + /** * Create the application instance. */ diff --git a/src/testbench/src/Foundation/PackageManifest.php b/src/testbench/src/Foundation/PackageManifest.php index edf39bd2d..255a4c793 100644 --- a/src/testbench/src/Foundation/PackageManifest.php +++ b/src/testbench/src/Foundation/PackageManifest.php @@ -9,6 +9,7 @@ use Hypervel\Foundation\PackageManifest as FoundationPackageManifest; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Hypervel\Testbench\Bootstrapper; use Override; use UnexpectedValueException; @@ -32,6 +33,13 @@ class PackageManifest extends FoundationPackageManifest */ protected array $requiredPackages = []; + /** + * The package's own manifest entry, keyed by its package name. + * + * @var null|array> + */ + protected ?array $rootManifest = null; + /** * Create a new package manifest instance. */ @@ -43,6 +51,11 @@ public function __construct( ) { parent::__construct($files, $basePath, $manifestPath); + // The base path is a disposable copy or a configured skeleton whose vendor link + // already points at the package's install. Resolving COMPOSER_VENDOR_DIR + // against the copy would miss it. + $this->vendorPath = $basePath . '/vendor'; + $this->setTestbench($testbench); } @@ -95,6 +108,38 @@ public function getManifestPath(): ?string return $this->manifestPath; } + /** + * Determine if the manifest includes the package's own Composer metadata. + */ + public function discoversRootPackage(): bool + { + return static::discoversRootPackageAt( + $this->basePath, + $this->testbench !== null + && method_exists($this->testbench, 'discoversRootPackage') + && $this->testbench->discoversRootPackage() === true, + ); + } + + /** + * Determine if a manifest for the given base path includes the package's own Composer metadata. + * + * Remote commands can start before an application exists, so the decision is also + * available without a manifest instance. + */ + public static function discoversRootPackageAt(string $basePath, bool $optedIn = false): bool + { + // A child sharing its parent's runtime follows the parent's decision, so its own + // CLI identity cannot change the manifest the parent and its subprocesses read. + if (Bootstrapper::isRuntimeCopy($basePath) && ! Bootstrapper::ownsRuntimePath($basePath)) { + return Env::get('TESTBENCH_DISCOVERS_ROOT_PACKAGE') === true; + } + + // PHPUnit workers are not Testbench CLI processes, but package:test workers + // still need the package root metadata. + return is_testbench_cli() || Env::has('TESTBENCH_PACKAGE_TESTER') || $optedIn; + } + /** * Get the current package manifest. */ @@ -141,17 +186,28 @@ protected function packagesToIgnore(): array */ protected function providersFromRoot(): array { + if ($this->rootManifest !== null) { + return $this->rootManifest; + } + $package = $this->providersFromTestbench(); if ($package === null) { - return []; + return $this->rootManifest = []; } $composerFile = package_path('composer.json'); $location = "root package in [{$composerFile}]"; + $extra = static::hypervelExtra($package, $location); + + // Composer only requires names for published packages. An unnamed root has no + // manifest entry to replace, so it needs a name only when its metadata is discovered. + if (! array_key_exists('name', $package) && ($extra === [] || ! $this->discoversRootPackage())) { + return $this->rootManifest = []; + } - return [ - static::packageName($package, $location, $this->vendorPath) => static::hypervelExtra($package, $location), + return $this->rootManifest = [ + static::packageName($package, $location, $this->vendorPath) => $extra, ]; } @@ -162,11 +218,7 @@ protected function providersFromRoot(): array */ protected function providersFromTestbench(): ?array { - // PHPUnit workers are not Testbench CLI processes, but package:test - // workers still need the package root metadata when they build a fresh - // clone manifest. - if ((is_testbench_cli() || Env::has('TESTBENCH_PACKAGE_TESTER')) - && is_file($composerFile = package_path('composer.json'))) { + if (is_file($composerFile = package_path('composer.json'))) { $package = $this->files->json($composerFile, JSON_THROW_ON_ERROR); if (! is_array($package)) { @@ -180,16 +232,19 @@ protected function providersFromTestbench(): ?array } /** - * Write the given manifest array to disk. + * Prepare a built or cached manifest before it is stored or used. */ #[Override] - protected function write(array $manifest): void + protected function prepareManifest(array $manifest): array { - parent::write( - (new Collection($manifest)) - ->merge($this->providersFromRoot()) - ->filter() - ->all() - ); + $root = $this->providersFromRoot(); + + // Replace any cached root entry, so the manifest follows the current discovery + // decision rather than whichever process last wrote the file. + $manifest = array_diff_key($manifest, $root); + + return $this->discoversRootPackage() + ? array_merge($manifest, array_filter($root)) + : $manifest; } } diff --git a/src/testbench/src/Foundation/Process/RemoteCommand.php b/src/testbench/src/Foundation/Process/RemoteCommand.php index 1ed4f2a55..ed64eeec7 100644 --- a/src/testbench/src/Foundation/Process/RemoteCommand.php +++ b/src/testbench/src/Foundation/Process/RemoteCommand.php @@ -6,8 +6,11 @@ use Closure; use Hypervel\Console\Application as ConsoleApplication; +use Hypervel\Container\Container; use Hypervel\Support\ProcessUtils; use Hypervel\Testbench\Bootstrapper; +use Hypervel\Testbench\Foundation\Env; +use Hypervel\Testbench\Foundation\PackageManifest; use Laravel\SerializableClosure\SerializableClosure; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\StringInput; @@ -66,6 +69,15 @@ public function handle(string $commander, Closure|array|string $command): Proces ? '(true)' : false; + // A child sharing this runtime follows this process's root discovery decision, + // so its own CLI identity cannot change the package manifest both processes read. + // Without an application yet, the decision comes from this process's context. + $env['TESTBENCH_DISCOVERS_ROOT_PACKAGE'] = $env['TESTBENCH_RUNTIME_COPY'] === '(true)' + ? Env::encode(Container::getInstance()->bound(PackageManifest::class) + ? Container::getInstance()->make(PackageManifest::class)->discoversRootPackage() + : PackageManifest::discoversRootPackageAt($env['TESTBENCH_BASE_PATH'])) + : false; + if (! array_key_exists('APP_ENV', $env)) { unset($definedEnvironmentVariables['APP_ENV']); } diff --git a/tests/Testbench/Foundation/Bootstrap/CreateVendorSymlinkTest.php b/tests/Testbench/Foundation/Bootstrap/CreateVendorSymlinkTest.php index efc27bc51..f6d3b8ee2 100644 --- a/tests/Testbench/Foundation/Bootstrap/CreateVendorSymlinkTest.php +++ b/tests/Testbench/Foundation/Bootstrap/CreateVendorSymlinkTest.php @@ -117,6 +117,7 @@ public function itRemovesItsVendorLinkWhenPackageDiscoveryFails(): void $application = $this->createApplication(); (new DeleteVendorSymlink)->handle($application); $manifest = m::mock(PackageManifest::class); + $manifest->manifestPath = $application->getCachedPackagesPath(); $manifest->shouldReceive('build') ->once() ->andThrow(new RuntimeException('Package discovery failed.')); @@ -176,15 +177,13 @@ public function itDoesNotRebuildDiscoveryWhenTheManifestCannotBeDeleted(): void { $application = $this->createApplication(); $filesystem = new Filesystem; - $cachedPath = $application->bootstrapPath('cache/packages.php'); - - if ($filesystem->exists($cachedPath)) { - $filesystem->delete($cachedPath); - } + // A custom path, as from APP_PACKAGES_CACHE, proves the manifest's own path is used. + $cachedPath = $application->bootstrapPath('cache/custom-packages.php'); $filesystem->ensureDirectoryExists($cachedPath); $this->manifestDirectory = $cachedPath; $manifest = m::mock(PackageManifest::class); + $manifest->manifestPath = $cachedPath; $manifest->shouldNotReceive('build'); $application->instance(PackageManifest::class, $manifest); diff --git a/tests/Testbench/Foundation/PackageManifestTest.php b/tests/Testbench/Foundation/PackageManifestTest.php index 616b6c8b1..5c234757e 100644 --- a/tests/Testbench/Foundation/PackageManifestTest.php +++ b/tests/Testbench/Foundation/PackageManifestTest.php @@ -7,11 +7,13 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\PackageManifest as FoundationPackageManifest; use Hypervel\Testbench\Attributes\WithEnv; +use Hypervel\Testbench\Foundation\Env; use Hypervel\Testbench\Foundation\PackageManifest; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\Testbench\TestCase; use Override; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use UnexpectedValueException; class PackageManifestTest extends TestCase @@ -24,10 +26,25 @@ class PackageManifestTest extends TestCase private string $tempDirectory; + /** + * @var array{0: null|string, 1: null|string, 2: false|string} + */ + private array $originalPackageTester = [null, null, false]; + protected function setUp(): void { parent::setUp(); + // The fixture manifests decide root discovery from their Testbench stub, not + // from a package:test worker's inherited context. + $this->originalPackageTester = [ + $_SERVER['TESTBENCH_PACKAGE_TESTER'] ?? null, + $_ENV['TESTBENCH_PACKAGE_TESTER'] ?? null, + getenv('TESTBENCH_PACKAGE_TESTER'), + ]; + unset($_SERVER['TESTBENCH_PACKAGE_TESTER'], $_ENV['TESTBENCH_PACKAGE_TESTER']); + putenv('TESTBENCH_PACKAGE_TESTER'); + $this->filesystem = new Filesystem; $this->basePath = __DIR__ . '/Fixtures/PackageManifest'; $this->tempDirectory = ParallelTesting::tempDir('PackageManifestTest'); @@ -42,6 +59,20 @@ protected function tearDown(): void { $this->filesystem->deleteDirectory($this->tempDirectory); + [$server, $environment, $process] = $this->originalPackageTester; + + if ($server !== null) { + $_SERVER['TESTBENCH_PACKAGE_TESTER'] = $server; + } + + if ($environment !== null) { + $_ENV['TESTBENCH_PACKAGE_TESTER'] = $environment; + } + + if ($process !== false) { + putenv("TESTBENCH_PACKAGE_TESTER={$process}"); + } + FoundationPackageManifest::flushState(); parent::tearDown(); @@ -80,18 +111,19 @@ protected function providersFromTestbench(): ?array } /** - * Create a testbench stub with the given ignore list. + * Create a testbench stub with the given ignore list and root discovery setting. */ - private function makeTestbench(array $ignore): object + private function makeTestbench(array $ignore, bool $discoversRootPackage = true): object { - return new class($ignore) { + return new class($ignore, $discoversRootPackage) { /** * Create a new testbench stub. * * @param array $ignore */ public function __construct( - private readonly array $ignore + private readonly array $ignore, + private readonly bool $discoversRootPackage ) { } @@ -104,6 +136,14 @@ public function ignorePackageDiscoveriesFrom(): array { return $this->ignore; } + + /** + * Determine if package discovery includes the package's own Composer metadata. + */ + public function discoversRootPackage(): bool + { + return $this->discoversRootPackage; + } }; } @@ -189,6 +229,38 @@ public function itRejectsAnEmptyRootPackageName(): void $manifest->build(); } + #[Test] + #[TestWith([false, ['extra' => ['hypervel' => ['providers' => ['Testbench\Example\ServiceProvider']]]]])] + #[TestWith([true, ['require' => ['php' => '^8.4']]])] + public function itAllowsAnUnnamedRootPackageWithNothingToDiscover(bool $discoversRootPackage, array $rootPackage): void + { + $manifest = $this->makeManifest( + testbench: $this->makeTestbench([], $discoversRootPackage), + rootPackage: $rootPackage + ); + + $manifest->build(); + + $cached = require $this->manifestPath; + + $this->assertArrayHasKey('vendor-a/package-a', $cached); + $this->assertNotContains('Testbench\Example\ServiceProvider', $manifest->providers()); + } + + #[Test] + public function itRequiresANameToDiscoverRootPackageMetadata(): void + { + $manifest = $this->makeManifest( + testbench: $this->makeTestbench([]), + rootPackage: ['extra' => ['hypervel' => ['providers' => ['Testbench\Example\ServiceProvider']]]] + ); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessageIsOrContains('member [name] must be a non-empty string.'); + + $manifest->build(); + } + #[Test] public function itRejectsAnEmptyFormattedRootPackageName(): void { @@ -287,6 +359,105 @@ public function itFiltersPackagesWithMissingProviders(): void ); } + #[Test] + public function itIncludesRootMetadataInTheSameInstanceAfterAnExplicitBuild(): void + { + $manifest = $this->makeManifest( + testbench: $this->makeTestbench([]), + rootPackage: $this->rootPackageFixture() + ); + + $manifest->build(); + + $this->assertContains('Hypervel\Tests\Testbench\Fixtures\Providers\ParentServiceProvider', $manifest->providers()); + $this->assertSame('RootClass', $manifest->aliases()['RootAlias'] ?? null); + } + + #[Test] + #[TestWith([true, true])] + #[TestWith([true, false])] + #[TestWith([false, true])] + #[TestWith([false, false])] + public function itKeepsACachedManifestInStepWithTheRootDiscoveryDecision(bool $cachedWithRoot, bool $discoversRootPackage): void + { + $this->makeManifest( + testbench: $this->makeTestbench([], $cachedWithRoot), + rootPackage: $this->rootPackageFixture() + )->build(); + + $manifest = $this->makeManifest( + testbench: $this->makeTestbench([], $discoversRootPackage), + rootPackage: $this->rootPackageFixture() + ); + + $this->assertSame($discoversRootPackage, $manifest->hasPackage('testbench/example')); + + // Plain Foundation readers, such as the skeleton artisan run by cache commands, + // read the file the Testbench manifest keeps in step. + $plainReader = new FoundationPackageManifest($this->filesystem, $this->basePath, $this->manifestPath); + + $this->assertSame($discoversRootPackage, $plainReader->hasPackage('testbench/example')); + } + + #[Test] + #[TestWith([true])] + #[TestWith([false])] + public function itAppliesTheParentRootDiscoveryDecisionInABorrowedRuntime(bool $parentDiscoversRootPackage): void + { + $variables = [ + 'TESTBENCH_PACKAGE_REMOTE' => '(true)', + 'TESTBENCH_BASE_PATH' => $this->basePath, + 'TESTBENCH_RUNTIME_COPY' => '(true)', + ]; + + $original = array_map( + static fn (string $key): array => [$_SERVER[$key] ?? null, $_ENV[$key] ?? null], + array_combine(array_keys($variables), array_keys($variables)), + ); + + foreach ($variables as $key => $value) { + $_SERVER[$key] = $_ENV[$key] = $value; + } + + Env::set('TESTBENCH_DISCOVERS_ROOT_PACKAGE', Env::encode($parentDiscoversRootPackage)); + + try { + // The child's own context would make the opposite decision. + $makeChildManifest = fn (): PackageManifest => $this->makeManifest( + testbench: $this->makeTestbench([], ! $parentDiscoversRootPackage), + rootPackage: $this->rootPackageFixture() + ); + $cachedHasRoot = fn (): bool => str_contains((string) file_get_contents($this->manifestPath), "'testbench/example'"); + + // A missing file, as after clear-compiled, is rebuilt with the parent's decision. + $makeChildManifest()->providers(); + $this->assertSame($parentDiscoversRootPackage, $cachedHasRoot()); + + // An explicit build, as by package:discover, keeps the parent's decision. + $makeChildManifest()->build(); + $this->assertSame($parentDiscoversRootPackage, $cachedHasRoot()); + + // A cached read leaves the parent's file unchanged. + $contents = file_get_contents($this->manifestPath); + $makeChildManifest()->providers(); + $this->assertSame($contents, file_get_contents($this->manifestPath)); + } finally { + foreach ($original as $key => [$server, $environment]) { + unset($_SERVER[$key], $_ENV[$key]); + + if ($server !== null) { + $_SERVER[$key] = $server; + } + + if ($environment !== null) { + $_ENV[$key] = $environment; + } + } + + Env::forget('TESTBENCH_DISCOVERS_ROOT_PACKAGE'); + } + } + #[Test] public function itSwapsTheApplicationBindingToTheTestbenchPackageManifest(): void { diff --git a/tests/Testbench/Foundation/Process/RemoteCommandTest.php b/tests/Testbench/Foundation/Process/RemoteCommandTest.php index 03cd86670..b266b12f3 100644 --- a/tests/Testbench/Foundation/Process/RemoteCommandTest.php +++ b/tests/Testbench/Foundation/Process/RemoteCommandTest.php @@ -128,6 +128,22 @@ public function itDoesNotForwardTheParentRuntimeCopyToServeCommands(): void }); } + #[Test] + public function itForwardsTheRootDiscoveryDecisionOnlyToChildrenSharingTheRuntime(): void + { + $this->withoutSqliteDatabase(function (): void { + $this->withInheritedEnvironment(['TESTBENCH_PACKAGE_TESTER' => null], function (): void { + // An explicit false keeps a root-off decision; Process removes false values. + $this->assertSame('false', $this->processEnvironment(remote('about --json'))['TESTBENCH_DISCOVERS_ROOT_PACKAGE']); + $this->assertFalse($this->processEnvironment(remote('serve --help'))['TESTBENCH_DISCOVERS_ROOT_PACKAGE']); + }); + + $this->withInheritedEnvironment(['TESTBENCH_PACKAGE_TESTER' => '(true)'], function (): void { + $this->assertSame('true', $this->processEnvironment(remote('about --json'))['TESTBENCH_DISCOVERS_ROOT_PACKAGE']); + }); + }); + } + #[Test] public function itOnlyMarksABasePathItKnowsIsADisposableCopy(): void { @@ -202,9 +218,11 @@ public function itRestoresThePackageManifestAfterRemoteCommands(): void } /** - * Run the callback with the given $_SERVER values, restoring the originals afterwards. + * Run the callback with the given inherited values, restoring the originals afterwards. + * + * A null value removes an inherited variable, such as one a package:test worker received. * - * @param array $values + * @param array $values * @param Closure(): void $callback */ private function withInheritedEnvironment(array $values, Closure $callback): void @@ -212,18 +230,38 @@ private function withInheritedEnvironment(array $values, Closure $callback): voi $originals = []; foreach ($values as $key => $value) { - $originals[$key] = [array_key_exists($key, $_SERVER), $_SERVER[$key] ?? null]; - $_SERVER[$key] = $value; + $originals[$key] = [ + array_key_exists($key, $_SERVER), + $_SERVER[$key] ?? null, + array_key_exists($key, $_ENV), + $_ENV[$key] ?? null, + getenv($key), + ]; + + if ($value === null) { + unset($_SERVER[$key], $_ENV[$key]); + putenv($key); + } else { + $_SERVER[$key] = $value; + } } try { $callback(); } finally { - foreach ($originals as $key => [$existed, $original]) { - if ($existed) { - $_SERVER[$key] = $original; - } else { - unset($_SERVER[$key]); + foreach ($originals as $key => [$existedInServer, $server, $existedInEnvironment, $environment, $process]) { + unset($_SERVER[$key], $_ENV[$key]); + + if ($existedInServer) { + $_SERVER[$key] = $server; + } + + if ($existedInEnvironment) { + $_ENV[$key] = $environment; + } + + if ($process !== false) { + putenv("{$key}={$process}"); } } } From ba0fcd1af8de03be303a49f8ddb00f1e1b8eced0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:37:10 +0000 Subject: [PATCH 010/303] Preserve Composer install paths across Testbench runtime boundaries Resolve vendor links from the package's actual Composer install and run the installed Testbench script with an explicit consumer autoloader. Support custom vendor and binary directories without duplicating Composer configuration parsing. Normalize relative vendor-directory environment values against the package root before crossing runtime boundaries. Keep inherited process values and application-loaded values within their respective environment lifetimes, including disabled putenv and test restoration. Share the skeleton autoload entrypoint and correct the split-checkout lookup. Verify inherited and application-loaded values through child processes and retain the existing Testbench link ownership checks. --- src/testbench/bin/testbench | 3 +- src/testbench/hypervel/artisan | 22 +---------- src/testbench/hypervel/bootstrap/autoload.php | 27 +++++++++++--- src/testbench/src/Attributes/UsesVendor.php | 3 +- src/testbench/src/Bootstrapper.php | 27 +++++++++++++- .../src/Concerns/CreatesApplication.php | 28 ++++++++++++++ src/testbench/src/Console/Commander.php | 3 +- src/testbench/src/functions.php | 27 ++++++-------- tests/Testbench/BootstrapperTest.php | 35 ++++++++++++++++++ .../Testbench/Foundation/ApplicationTest.php | 37 +++++++++++++++++++ 10 files changed, 167 insertions(+), 45 deletions(-) diff --git a/src/testbench/bin/testbench b/src/testbench/bin/testbench index 633e8a3d9..fea022695 100755 --- a/src/testbench/bin/testbench +++ b/src/testbench/bin/testbench @@ -8,8 +8,9 @@ use Composer\InstalledVersions; // Load autoloader - check multiple locations for monorepo vs installed package $autoloadPaths = [ $_composer_autoload_path ?? null, + getenv('HYPERVEL_AUTOLOAD_PATH') ?: null, // remote(): the consumer package's autoloader __DIR__ . '/../../../vendor/autoload.php', // monorepo: src/testbench/bin -> components/vendor - __DIR__ . '/../../vendor/autoload.php', // installed: testbench/bin -> testbench/vendor + __DIR__ . '/../vendor/autoload.php', // split checkout: testbench/bin -> testbench/vendor ]; foreach (array_filter($autoloadPaths) as $autoloadPath) { diff --git a/src/testbench/hypervel/artisan b/src/testbench/hypervel/artisan index 3d108b03f..33f9c878e 100644 --- a/src/testbench/hypervel/artisan +++ b/src/testbench/hypervel/artisan @@ -6,7 +6,6 @@ declare(strict_types=1); use Hypervel\Console\Application as ConsoleApplication; use Hypervel\Contracts\Console\Kernel as ConsoleKernelContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; -use Hypervel\Foundation\Application; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; @@ -18,26 +17,7 @@ error_reporting(E_ALL); ! defined('BASE_PATH') && define('BASE_PATH', __DIR__); -$autoloadPaths = array_filter([ - getenv('HYPERVEL_AUTOLOAD_PATH') ?: null, - BASE_PATH . '/vendor/autoload.php', - getcwd() . '/vendor/autoload.php', - is_string(getenv('TESTBENCH_WORKING_PATH')) - ? (string) getenv('TESTBENCH_WORKING_PATH') . '/vendor/autoload.php' - : null, -]); - -foreach ($autoloadPaths as $autoloadPath) { - if (is_file($autoloadPath)) { - require $autoloadPath; - break; - } -} - -if (! class_exists(Application::class)) { - fwrite(STDERR, "Unable to locate Composer autoloader for the Hypervel testbench workbench.\n"); - exit(1); -} +require __DIR__ . '/bootstrap/autoload.php'; $committedTestbenchRoot = realpath(__DIR__ . '/..'); diff --git a/src/testbench/hypervel/bootstrap/autoload.php b/src/testbench/hypervel/bootstrap/autoload.php index d8291beca..b78447e36 100644 --- a/src/testbench/hypervel/bootstrap/autoload.php +++ b/src/testbench/hypervel/bootstrap/autoload.php @@ -2,10 +2,27 @@ declare(strict_types=1); -if (! defined('TESTBENCH_WORKING_PATH') && is_string(getenv('TESTBENCH_WORKING_PATH'))) { - define('TESTBENCH_WORKING_PATH', getenv('TESTBENCH_WORKING_PATH')); -} +use Hypervel\Foundation\Application; + +// Cache commands pass the application's autoloader explicitly. A runtime copy links the +// package's vendor directory beside the application. +$autoloadPaths = array_filter([ + getenv('HYPERVEL_AUTOLOAD_PATH') ?: null, + dirname(__DIR__) . '/vendor/autoload.php', + getcwd() . '/vendor/autoload.php', + is_string(getenv('TESTBENCH_WORKING_PATH')) + ? (string) getenv('TESTBENCH_WORKING_PATH') . '/vendor/autoload.php' + : null, +]); -$workingPath = defined('TESTBENCH_WORKING_PATH') ? TESTBENCH_WORKING_PATH : realpath(__DIR__ . '/../'); +foreach ($autoloadPaths as $autoloadPath) { + if (is_file($autoloadPath)) { + require $autoloadPath; + break; + } +} -require $workingPath . '/vendor/autoload.php'; +if (! class_exists(Application::class)) { + fwrite(STDERR, "Unable to locate Composer autoloader for the Hypervel testbench workbench.\n"); + exit(1); +} diff --git a/src/testbench/src/Attributes/UsesVendor.php b/src/testbench/src/Attributes/UsesVendor.php index 733c6a66f..d4667e2dc 100644 --- a/src/testbench/src/Attributes/UsesVendor.php +++ b/src/testbench/src/Attributes/UsesVendor.php @@ -6,6 +6,7 @@ use Attribute; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Support\Composer; use Hypervel\Testbench\Contracts\Attributes\AfterEach; use Hypervel\Testbench\Contracts\Attributes\BeforeEach; use Hypervel\Testbench\Foundation\Actions\CreateVendorSymlink; @@ -28,7 +29,7 @@ public function beforeEach(ApplicationContract $app): void { $hypervel = clone $app; - (new CreateVendorSymlink(package_path('vendor')))->handle($hypervel); + (new CreateVendorSymlink(Composer::vendorPath(package_path())))->handle($hypervel); $this->vendorSymlinkCreated = $hypervel->make('TESTBENCH_VENDOR_SYMLINK'); } diff --git a/src/testbench/src/Bootstrapper.php b/src/testbench/src/Bootstrapper.php index 3859b17d0..485b43e89 100644 --- a/src/testbench/src/Bootstrapper.php +++ b/src/testbench/src/Bootstrapper.php @@ -7,6 +7,7 @@ use Composer\InstalledVersions; use FilesystemIterator; use Hypervel\Filesystem\Filesystem; +use Hypervel\Support\Composer; use Hypervel\Testbench\Contracts\Config as ConfigContract; use Hypervel\Testbench\Foundation\Config; use Hypervel\Testbench\Foundation\Env; @@ -43,6 +44,8 @@ public static function bootstrap(): void define('TESTBENCH_WORKING_PATH', $workingPath); } + static::exportAbsoluteComposerVendorDirectory($workingPath); + static::loadConfigFromYaml(static::resolveConfigurationPath($workingPath)); if (defined('BASE_PATH')) { @@ -206,6 +209,28 @@ protected static function hasConfigurationFile(string $workingPath, string $file return false; } + /** + * Make a relative COMPOSER_VENDOR_DIR absolute against the package root. + * + * Applications run from a runtime copy or skeleton, including child processes that + * inherit this environment such as the skeleton's artisan, resolve a relative value + * against their own base path rather than the package root. + */ + protected static function exportAbsoluteComposerVendorDirectory(string $workingPath): void + { + $vendorPath = Env::get('COMPOSER_VENDOR_DIR'); + + if (! is_string($vendorPath) || $vendorPath === '' || str_starts_with($vendorPath, '/')) { + return; + } + + $vendorPath = Composer::vendorPath($workingPath); + + putenv("COMPOSER_VENDOR_DIR={$vendorPath}"); + $_ENV['COMPOSER_VENDOR_DIR'] = $vendorPath; + $_SERVER['COMPOSER_VENDOR_DIR'] = $vendorPath; + } + /** * Create a disposable runtime copy of the skeleton directory. * @@ -242,7 +267,7 @@ protected static function createRuntimeCopy(string $sourcePath, string $workingP throw new RuntimeException("Unable to create runtime path [{$runtimePath}]."); } - if (! static::copySkeleton($filesystem, $sourcePath, $runtimePath, join_paths($workingPath, 'vendor'))) { + if (! static::copySkeleton($filesystem, $sourcePath, $runtimePath, Composer::vendorPath($workingPath))) { throw new RuntimeException("Unable to create the Testbench runtime copy at [{$runtimePath}]."); } diff --git a/src/testbench/src/Concerns/CreatesApplication.php b/src/testbench/src/Concerns/CreatesApplication.php index faf4877de..7ac4442b7 100644 --- a/src/testbench/src/Concerns/CreatesApplication.php +++ b/src/testbench/src/Concerns/CreatesApplication.php @@ -28,6 +28,7 @@ use Hypervel\RateLimiter\Limit; use Hypervel\Routing\Router; use Hypervel\Support\Collection; +use Hypervel\Support\Composer; use Hypervel\Support\Facades\Facade; use Hypervel\Support\Facades\RateLimiter; use Hypervel\Testbench\Attributes\DefineEnvironment; @@ -52,6 +53,7 @@ use function Hypervel\Testbench\after_resolving; use function Hypervel\Testbench\default_skeleton_path; +use function Hypervel\Testbench\package_path; use function Hypervel\Testbench\refresh_router_lookups; /** @@ -268,6 +270,7 @@ public function createApplication(): ApplicationContract $this->resolveApplicationBindings($app); $this->resolveApplicationExceptionHandler($app); $this->resolveApplicationEnvironmentVariables($app); + $this->resolveApplicationComposerVendorDirectory(); $app->make(ConfigureTerminalDimensions::class)->bootstrap($app); $this->resolveApplicationConfiguration($app); @@ -424,6 +427,31 @@ protected function resolveApplicationEnvironmentVariables(ApplicationContract $a } } + /** + * Make a relative COMPOSER_VENDOR_DIR from the application environment absolute. + * + * Child processes such as the skeleton's artisan resolve a relative value against + * their own base path rather than the package root. Bootstrapper already exported + * an inherited value; rewriting a loaded one through the repository keeps its + * putenv setting and restoration. + * + * @internal + */ + protected function resolveApplicationComposerVendorDirectory(): void + { + $repository = Env::getRepository(); + $vendorPath = $repository->get('COMPOSER_VENDOR_DIR'); + + if ($vendorPath === null || $vendorPath === '' || str_starts_with($vendorPath, '/')) { + return; + } + + $vendorPath = Composer::vendorPath(package_path()); + + $repository->clear('COMPOSER_VENDOR_DIR'); + $repository->set('COMPOSER_VENDOR_DIR', $vendorPath); + } + /** * Mask inherited framework APP_ENV for Testbench applications that opt out of loading env files. * diff --git a/src/testbench/src/Console/Commander.php b/src/testbench/src/Console/Commander.php index 174f3e00a..a37863a5d 100644 --- a/src/testbench/src/Console/Commander.php +++ b/src/testbench/src/Console/Commander.php @@ -12,6 +12,7 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application as HypervelApplication; use Hypervel\Foundation\Bootstrap\HandleExceptions; +use Hypervel\Support\Composer; use Hypervel\Support\ServiceProvider; use Hypervel\Testbench\Bootstrapper; use Hypervel\Testbench\Foundation\Application as Testbench; @@ -148,7 +149,7 @@ public function hypervel(): ApplicationContract { if (! $this->app instanceof HypervelApplication) { $appBasePath = $this->getApplicationBasePath(); - $vendorPath = package_path('vendor'); + $vendorPath = Composer::vendorPath(package_path()); TerminatingConsole::beforeWhen( ! is_symlink(join_paths($appBasePath, 'vendor')), diff --git a/src/testbench/src/functions.php b/src/testbench/src/functions.php index 1603cdbfb..977b86625 100644 --- a/src/testbench/src/functions.php +++ b/src/testbench/src/functions.php @@ -15,6 +15,7 @@ use Hypervel\Routing\Router; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Hypervel\Support\Composer; use Hypervel\Support\ProcessUtils; use Hypervel\Testbench\Contracts\Config as ConfigContract; use Hypervel\Testbench\Contracts\TestCase as TestCaseContract; @@ -399,7 +400,7 @@ function hypervel_vendor_exists(ApplicationContract $app, ?string $workingPath = $filesystem = new \Hypervel\Filesystem\Filesystem; $appVendorPath = $app->basePath('vendor'); - $workingPath ??= package_path('vendor'); + $workingPath ??= Composer::vendorPath(package_path()); return $filesystem->isFile(join_paths($appVendorPath, 'autoload.php')) && $filesystem->hash(join_paths($appVendorPath, 'autoload.php')) === $filesystem->hash(join_paths($workingPath, 'autoload.php')); @@ -744,20 +745,16 @@ function php_binary(bool $escape = false): string */ function remote(Closure|array|string $command, array|string $env = [], ?bool $tty = null): ProcessDecorator { - $remote = new RemoteCommand(package_path(), $env, $tty); + if (is_string($env)) { + $env = ['APP_ENV' => $env]; + } - // Look for testbench binary in order of preference: - // 1. vendor/bin/testbench (installed as dependency) - // 2. src/testbench/bin/testbench (monorepo structure) - // 3. Fall back to 'testbench' in PATH - $vendorBinary = package_path('vendor', 'bin', 'testbench'); - $srcBinary = package_path('src', 'testbench', 'bin', 'testbench'); - - $commander = match (true) { - is_file($vendorBinary) => $vendorBinary, - is_file($srcBinary) => $srcBinary, - default => 'testbench', - }; + // Run this Testbench's own binary with the package's autoloader. That works for any + // Composer vendor or bin directory, and for path-repository installs where the + // binary's own location resolves to the source checkout. + $env['HYPERVEL_AUTOLOAD_PATH'] ??= Composer::vendorPath(package_path()) . '/autoload.php'; + + $remote = new RemoteCommand(package_path(), $env, $tty); - return $remote->handle($commander, $command); + return $remote->handle(testbench_path('bin/testbench'), $command); } diff --git a/tests/Testbench/BootstrapperTest.php b/tests/Testbench/BootstrapperTest.php index c8229cc0a..d716a85e2 100644 --- a/tests/Testbench/BootstrapperTest.php +++ b/tests/Testbench/BootstrapperTest.php @@ -15,10 +15,12 @@ use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use ReflectionMethod; use ReflectionProperty; use RuntimeException; +use Symfony\Component\Process\Process; use UnexpectedValueException; use function Hypervel\Testbench\testbench_path; @@ -778,6 +780,39 @@ public function itReloadsConfigurationWithoutReplacingAPredefinedRuntime(): void } } + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[Test] + #[TestWith(['libs', '{working}/libs'])] + #[TestWith(['/custom/vendor', '/custom/vendor'])] + public function itGivesChildProcessesAnAbsoluteComposerVendorDirectory(string $value, string $expected): void + { + $workingPath = $this->temporaryDirectory('composer-vendor-working'); + $runtimePath = $this->temporaryDirectory('composer-vendor-runtime'); + + mkdir($workingPath, 0777, true); + mkdir($runtimePath, 0777, true); + + try { + define('TESTBENCH_WORKING_PATH', $workingPath); + define('BASE_PATH', $runtimePath); + + Env::deleteMany(['COMPOSER_VENDOR_DIR']); + Env::flushRepository(); + Env::getRepository()->set('COMPOSER_VENDOR_DIR', $value); + + Bootstrapper::bootstrap(); + + $process = new Process([PHP_BINARY, '-r', 'echo getenv("COMPOSER_VENDOR_DIR");'], $runtimePath); + $process->mustRun(); + + $this->assertSame(str_replace('{working}', $workingPath, $expected), $process->getOutput()); + } finally { + $this->deleteDirectory($workingPath); + $this->deleteDirectory($runtimePath); + } + } + // Isolation gives the fixture a distinct live parent PID without sharing // the PHPUnit worker's process state with the stale sweep. #[PreserveGlobalState(false)] diff --git a/tests/Testbench/Foundation/ApplicationTest.php b/tests/Testbench/Foundation/ApplicationTest.php index 38d2772da..eb820cbd2 100644 --- a/tests/Testbench/Foundation/ApplicationTest.php +++ b/tests/Testbench/Foundation/ApplicationTest.php @@ -24,6 +24,7 @@ use Override; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Symfony\Component\Process\Process; use Throwable; @@ -188,6 +189,33 @@ public static function defaultMigrationEnvironments(): iterable yield 'without default migrations' => [['TESTBENCH_WITHOUT_DEFAULT_MIGRATIONS=(true)'], false]; } + #[Test] + public function itGivesChildProcessesAnAbsoluteComposerVendorDirectoryFromTheApplicationEnvironment(): void + { + $app = TestbenchApplication::create( + (string) default_skeleton_path(), + options: ['extra' => ['env' => ['COMPOSER_VENDOR_DIR=libs']]], + ); + + try { + $process = new Process([php_binary(), '-r', 'echo getenv("COMPOSER_VENDOR_DIR");']); + $process->mustRun(); + + $this->assertSame(package_path('libs'), $process->getOutput()); + } finally { + // The rewritten value keeps the application environment's lifetime. + $forgotten = Env::forget('COMPOSER_VENDOR_DIR'); + + try { + $app->terminate(); + } finally { + $app->flush(); + } + } + + $this->assertTrue($forgotten); + } + /** * @param array $options * @param array $expected @@ -218,6 +246,15 @@ public static function packageDiscoveryOptions(): array ]; } + #[Test] + #[TestWith([[], false])] + #[TestWith([['discovers_root_package' => true], true])] + #[TestWith([['discovers_root_package' => false], false])] + public function itAppliesTheRootPackageDiscoveryOption(array $options, bool $expected): void + { + $this->assertSame($expected, TestbenchApplication::make(options: $options)->discoversRootPackage()); + } + #[Test] public function itScopesBootstrapFileSelectionToEachApplicationObject(): void { From 08b3d8765cc2d234e5b626a814b25ad7bad98b95 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:37:10 +0000 Subject: [PATCH 011/303] Use application manifest paths for cache subprocesses and Tinker Resolve cache subprocess autoloaders and the Tinker classmap from the application's selected package manifest vendor directory. Remove obsolete repository-relative autoload guesses and the Tinker test environment override that concealed incorrect runtime-copy paths. Preserve missing-autoloader diagnostics and existing cache-command execution behavior. --- .../src/Console/ConfigCacheCommand.php | 18 ++++--------- .../src/Console/RouteCacheCommand.php | 18 ++++--------- src/tinker/src/Console/TinkerCommand.php | 6 ++--- tests/Tinker/TinkerCommandTest.php | 25 ------------------- 4 files changed, 12 insertions(+), 55 deletions(-) diff --git a/src/foundation/src/Console/ConfigCacheCommand.php b/src/foundation/src/Console/ConfigCacheCommand.php index 43e7bd793..e5f207eb1 100644 --- a/src/foundation/src/Console/ConfigCacheCommand.php +++ b/src/foundation/src/Console/ConfigCacheCommand.php @@ -6,9 +6,9 @@ use Hypervel\Console\Command; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\PackageManifest; use Hypervel\Support\Arr; use LogicException; -use ReflectionClass; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\ArgvInput; @@ -256,20 +256,12 @@ protected function buildFreshConfigurationCacheContents(): string */ protected function resolveSubprocessAutoloadPath(): string { - $componentRoot = dirname((new ReflectionClass(Filesystem::class))->getFileName(), 4); + $autoloadPath = $this->hypervel->make(PackageManifest::class)->vendorPath . '/autoload.php'; - $candidates = array_unique([ - $this->hypervel->basePath('vendor/autoload.php'), - $componentRoot . '/vendor/autoload.php', - dirname($componentRoot, 2) . '/autoload.php', - ]); - - foreach ($candidates as $candidate) { - if (is_file($candidate)) { - return $candidate; - } + if (! is_file($autoloadPath)) { + throw new LogicException('Unable to locate the Composer autoloader for the configuration cache subprocess.'); } - throw new LogicException('Unable to locate the Composer autoloader for the configuration cache subprocess.'); + return $autoloadPath; } } diff --git a/src/foundation/src/Console/RouteCacheCommand.php b/src/foundation/src/Console/RouteCacheCommand.php index 07b5dcecf..17c0cc31a 100644 --- a/src/foundation/src/Console/RouteCacheCommand.php +++ b/src/foundation/src/Console/RouteCacheCommand.php @@ -6,9 +6,9 @@ use Hypervel\Console\Command; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\PackageManifest; use Hypervel\Routing\RouteCollection; use LogicException; -use ReflectionClass; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\ArgvInput; @@ -207,20 +207,12 @@ protected function buildRouteCacheFile(array $compiled): string */ protected function resolveSubprocessAutoloadPath(): string { - $componentRoot = dirname((new ReflectionClass(Filesystem::class))->getFileName(), 4); + $autoloadPath = $this->hypervel->make(PackageManifest::class)->vendorPath . '/autoload.php'; - $candidates = array_unique([ - $this->hypervel->basePath('vendor/autoload.php'), - $componentRoot . '/vendor/autoload.php', - dirname($componentRoot, 2) . '/autoload.php', - ]); - - foreach ($candidates as $candidate) { - if (is_file($candidate)) { - return $candidate; - } + if (! is_file($autoloadPath)) { + throw new LogicException('Unable to locate the Composer autoloader for the route cache subprocess.'); } - throw new LogicException('Unable to locate the Composer autoloader for the route cache subprocess.'); + return $autoloadPath; } } diff --git a/src/tinker/src/Console/TinkerCommand.php b/src/tinker/src/Console/TinkerCommand.php index 1c469c9c5..c1993a7f7 100644 --- a/src/tinker/src/Console/TinkerCommand.php +++ b/src/tinker/src/Console/TinkerCommand.php @@ -5,7 +5,7 @@ namespace Hypervel\Tinker\Console; use Hypervel\Console\Command; -use Hypervel\Support\Env; +use Hypervel\Foundation\PackageManifest; use Hypervel\Tinker\ClassAliasAutoloader; use Psy\Configuration; use Psy\Exception\BreakException; @@ -68,9 +68,7 @@ public function handle(): int $shell->addCommands($this->getCommands()); $shell->setIncludes($this->argument('include')); - $path = Env::get('COMPOSER_VENDOR_DIR', $this->getHypervel()->basePath() . DIRECTORY_SEPARATOR . 'vendor'); - - $path .= '/composer/autoload_classmap.php'; + $path = $this->getHypervel()->make(PackageManifest::class)->vendorPath . '/composer/autoload_classmap.php'; $loader = ClassAliasAutoloader::register( $shell, diff --git a/tests/Tinker/TinkerCommandTest.php b/tests/Tinker/TinkerCommandTest.php index 9775333d9..db27f36c3 100644 --- a/tests/Tinker/TinkerCommandTest.php +++ b/tests/Tinker/TinkerCommandTest.php @@ -10,7 +10,6 @@ use Hypervel\Contracts\Foundation\Application; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\ClassInvoker; -use Hypervel\Support\Env; use Hypervel\Support\ServiceProvider; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; @@ -28,22 +27,12 @@ class TinkerCommandTest extends TestCase { - private ?string $originalComposerVendorDirectory = null; - private Filesystem $filesystem; private string $temporaryDirectory; protected function setUp(): void { - $originalComposerVendorDirectory = Env::get('COMPOSER_VENDOR_DIR'); - $this->originalComposerVendorDirectory = is_string($originalComposerVendorDirectory) - ? $originalComposerVendorDirectory - : null; - - Env::deleteMany(['COMPOSER_VENDOR_DIR']); - Env::flushRepository(); - parent::setUp(); $this->filesystem = new Filesystem; @@ -56,13 +45,6 @@ protected function tearDown(): void { try { $this->filesystem->deleteDirectory($this->temporaryDirectory); - - Env::deleteMany(['COMPOSER_VENDOR_DIR']); - Env::flushRepository(); - - if ($this->originalComposerVendorDirectory !== null) { - Env::getRepository()->set('COMPOSER_VENDOR_DIR', $this->originalComposerVendorDirectory); - } } finally { parent::tearDown(); } @@ -73,12 +55,6 @@ protected function getPackageProviders(Application $app): array return [TinkerServiceProvider::class]; } - protected function defineEnvironment(Application $app): void - { - // Point to the real vendor directory so the classmap file is found. - Env::getRepository()->set('COMPOSER_VENDOR_DIR', dirname(__DIR__, 2) . '/vendor'); - } - public function testExecuteSuccess(): void { $this->assertFalse($this->app->resolved(TinkerCommand::class)); @@ -238,7 +214,6 @@ public function testFalseyExecuteValuesUseDirectExecution(string $code): void command: [PHP_BINARY, BASE_PATH . '/artisan', 'tinker', '--execute=' . $code], cwd: dirname(__DIR__, 2), env: array_merge($_ENV, [ - 'COMPOSER_VENDOR_DIR' => (string) Env::get('COMPOSER_VENDOR_DIR'), 'HYPERVEL_AUTOLOAD_PATH' => dirname(__DIR__, 2) . '/vendor/autoload.php', ]), timeout: 10, From 31d3cc51fee5f853e7adec23d250caef4cf123f6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:37:17 +0000 Subject: [PATCH 012/303] Accept cache repository contracts for password reset tokens Allow CacheTokenRepository to consume any cache repository implementation promised by CacheManager, including custom drivers. Exercise the contract directly in the existing token repository tests. Document the protected property type difference: subclasses redeclaring the cache property must use the contract type. Keep the clear-resets command's concrete broker capability explicit without expanding the public broker interface. --- src/auth/README.md | 1 + src/auth/src/Console/ClearResetsCommand.php | 6 +++++- src/auth/src/Passwords/CacheTokenRepository.php | 2 +- tests/Auth/CacheTokenRepositoryTest.php | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/auth/README.md b/src/auth/README.md index 89418ae84..b2ae2ed95 100644 --- a/src/auth/README.md +++ b/src/auth/README.md @@ -13,5 +13,6 @@ Documentation: https://hypervel.org/docs/authentication - `guest:{guard}` selects the first named guard as the request's default guard on pass-through, mirroring how `auth:{guard}` selects on success. - Password confirmation is scoped to the current guard, and each guard may define its own `password_timeout`. - Remember cookies require a password HMAC; the legacy raw-password-hash fallback is intentionally omitted. +- `CacheTokenRepository` accepts any `Hypervel\Contracts\Cache\Repository`, because cache stores, including custom drivers, only promise that contract. Subclasses that redeclare its promoted `$cache` property must use the contract type. Ported from: https://github.com/laravel/framework diff --git a/src/auth/src/Console/ClearResetsCommand.php b/src/auth/src/Console/ClearResetsCommand.php index 8131d02c0..d73a4e940 100644 --- a/src/auth/src/Console/ClearResetsCommand.php +++ b/src/auth/src/Console/ClearResetsCommand.php @@ -4,6 +4,7 @@ namespace Hypervel\Auth\Console; +use Hypervel\Auth\Passwords\PasswordBroker; use Hypervel\Console\Command; use Symfony\Component\Console\Attribute\AsCommand; @@ -25,7 +26,10 @@ class ClearResetsCommand extends Command */ public function handle(): void { - $this->hypervel->make('auth.password')->broker($this->argument('name'))->getRepository()->deleteExpired(); + /** @var PasswordBroker $broker */ + $broker = $this->hypervel->make('auth.password')->broker($this->argument('name')); + + $broker->getRepository()->deleteExpired(); $this->components->info('Expired reset tokens cleared successfully.'); } diff --git a/src/auth/src/Passwords/CacheTokenRepository.php b/src/auth/src/Passwords/CacheTokenRepository.php index ad66c7652..e9c9357fd 100644 --- a/src/auth/src/Passwords/CacheTokenRepository.php +++ b/src/auth/src/Passwords/CacheTokenRepository.php @@ -4,8 +4,8 @@ namespace Hypervel\Auth\Passwords; -use Hypervel\Cache\Repository; use Hypervel\Contracts\Auth\CanResetPassword as CanResetPasswordContract; +use Hypervel\Contracts\Cache\Repository; use Hypervel\Contracts\Hashing\Hasher as HasherContract; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Str; diff --git a/tests/Auth/CacheTokenRepositoryTest.php b/tests/Auth/CacheTokenRepositoryTest.php index 2edf63f72..7cf8e5657 100644 --- a/tests/Auth/CacheTokenRepositoryTest.php +++ b/tests/Auth/CacheTokenRepositoryTest.php @@ -5,8 +5,8 @@ namespace Hypervel\Tests\Auth; use Hypervel\Auth\Passwords\CacheTokenRepository; -use Hypervel\Cache\Repository; use Hypervel\Contracts\Auth\CanResetPassword; +use Hypervel\Contracts\Cache\Repository; use Hypervel\Contracts\Hashing\Hasher; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; From 4fc748a61377c9fcb4c471039e6dce4ae252bb6d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:37:17 +0000 Subject: [PATCH 013/303] Read paused queues through the cache contract and preserve keyed inputs Use the cache repository contract's array get operation instead of the concrete many method when fetching queue pause states. Normalize requested queue names to a list before constructing cache keys, preventing string keys from being interpreted as cache defaults. Cover keyed input and a contract-only repository while retaining batched cache access. --- src/queue/src/QueueManager.php | 5 +++-- tests/Queue/QueuePauseResumeTest.php | 27 ++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index 4ec0b7844..a46d82190 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -360,12 +360,13 @@ public function getPausedQueues(array $queues, ?string $connection = null): arra return array_values($queues); } + // A list keeps keyed input from being read as key/default pairs. $keys = array_map( static fn (string $queue): string => "illuminate:queue:paused:{$connection}:{$queue}", - $queues, + array_values($queues), ); - $states = $cache->many($keys); + $states = $cache->get($keys); return array_values(array_filter( $queues, diff --git a/tests/Queue/QueuePauseResumeTest.php b/tests/Queue/QueuePauseResumeTest.php index 1072e675e..a0e7ac916 100644 --- a/tests/Queue/QueuePauseResumeTest.php +++ b/tests/Queue/QueuePauseResumeTest.php @@ -8,6 +8,7 @@ use Hypervel\Cache\Repository as CacheRepository; use Hypervel\Config\Repository as ConfigRepository; use Hypervel\Container\Container; +use Hypervel\Contracts\Cache\Repository as CacheContract; use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; use Hypervel\Events\Dispatcher; use Hypervel\Queue\Console\Concerns\ParsesQueue; @@ -18,6 +19,7 @@ use Hypervel\Queue\QueueManager; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; +use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use UnitEnum; @@ -45,7 +47,7 @@ protected function setUp(): void /** * Create a queue manager using the given cache repository. */ - protected function createManager(CacheRepository $cache): QueueManager + protected function createManager(CacheContract $cache): QueueManager { $container = new Container; $this->events = new Dispatcher($container); @@ -64,14 +66,14 @@ protected function createManager(CacheRepository $cache): QueueManager * Create a cache manager fixture. */ public function __construct( - private readonly CacheRepository $repository, + private readonly CacheContract $repository, ) { } /** * Get the cache repository. */ - public function store(?string $name = null): CacheRepository + public function store(?string $name = null): CacheContract { return $this->repository; } @@ -265,6 +267,25 @@ public function testGetPausedQueues(): void ['emails', 'notifications'], $this->manager->getPausedQueues(['default', 'emails', 'notifications'], 'redis'), ); + $this->assertSame( + ['emails'], + $this->manager->getPausedQueues(['mail' => 'emails', 'fallback' => 'default'], 'redis'), + ); + } + + public function testGetPausedQueuesUsesTheCacheRepositoryContract(): void + { + $cache = m::mock(CacheContract::class); + $cache->shouldReceive('get')->once()->with('illuminate:queues:paused', false)->andReturn(false); + $cache->shouldReceive('get')->once()->with([ + 'illuminate:queue:paused:redis:default', + 'illuminate:queue:paused:redis:emails', + ])->andReturn([ + 'illuminate:queue:paused:redis:default' => null, + 'illuminate:queue:paused:redis:emails' => true, + ]); + + $this->assertSame(['emails'], $this->createManager($cache)->getPausedQueues(['default', 'emails'], 'redis')); } public function testPauseAllPausesEveryQueueAndResumeAllResumesThem(): void From 2281d921161246c8d77ba09123d229740ce99e94 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:37:17 +0000 Subject: [PATCH 014/303] Clarify framework types exposed by booted container analysis Record the concrete capabilities used by view compilation, Passport client authentication and managed database connections without changing their public contracts or dispatch behavior. Document guard-helper capability requirements with narrow static-analysis ignores. Make config and session array setters return null explicitly after invoking their void setters, preserving helper behavior without using void expressions. --- src/foundation/src/Console/ViewCacheCommand.php | 8 +++++++- .../src/Testing/Concerns/InteractsWithAuthentication.php | 2 ++ .../src/Testing/Concerns/InteractsWithDatabase.php | 2 ++ src/foundation/src/helpers.php | 8 ++++++-- src/passport/src/Passport.php | 6 +++++- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/foundation/src/Console/ViewCacheCommand.php b/src/foundation/src/Console/ViewCacheCommand.php index 0465f38c0..57b1c16d0 100644 --- a/src/foundation/src/Console/ViewCacheCommand.php +++ b/src/foundation/src/Console/ViewCacheCommand.php @@ -6,6 +6,8 @@ use Hypervel\Console\Command; use Hypervel\Support\Collection; +use Hypervel\View\Engines\CompilerEngine; +use Hypervel\View\FileViewFinder; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Finder\Finder; @@ -43,7 +45,10 @@ public function handle(): void */ protected function compileViews(Collection $views): void { - $compiler = $this->hypervel->make('view')->getEngineResolver()->resolve('blade')->getCompiler(); + /** @var CompilerEngine $engine */ + $engine = $this->hypervel->make('view')->getEngineResolver()->resolve('blade'); + + $compiler = $engine->getCompiler(); $views->map(function (SplFileInfo $file) use ($compiler) { $this->components->task(' ' . $file->getRelativePathname(), null, OutputInterface::VERBOSITY_VERY_VERBOSE); @@ -81,6 +86,7 @@ protected function bladeFilesIn(array $paths): Collection */ protected function paths(): Collection { + /** @var FileViewFinder $finder */ $finder = $this->hypervel->make('view')->getFinder(); $paths = (new Collection($finder->getPaths()))->merge( diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php b/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php index 97fd24cfe..3dd3e56f6 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php @@ -23,6 +23,7 @@ public function actingAsGuest(?string $guard = null): static { $auth = $this->app->make('auth'); + // @phpstan-ignore method.notFound (Requires a guard with forgetUser(), such as one using GuardHelpers.) $auth->guard($guard)->forgetUser(); $auth->shouldUse($guard); @@ -131,6 +132,7 @@ public function assertInvalidCredentials(array $credentials, ?string $guard = nu */ protected function hasCredentials(array $credentials, ?string $guard = null): bool { + // @phpstan-ignore method.notFound (Requires a guard with a user provider, such as one using GuardHelpers.) $provider = $this->app->make('auth')->guard($guard)->getProvider(); $user = $provider->retrieveByCredentials($credentials); diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php index f7c880ce1..afb039411 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php @@ -331,6 +331,8 @@ protected function getConnection(UnitEnum|string|null $connection = null, Model| $connection = $database->getDefaultConnection(); } + // ConnectionFactory makes every managed connection, including extended drivers, a Connection. + /** @var Connection */ return $database->connection($connection); } diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index f9823da9a..cacd8b954 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -343,7 +343,9 @@ function config(mixed $key = null, mixed $default = null): mixed } if (is_array($key)) { - return $config->set($key); + $config->set($key); + + return null; } return $config->get($key, $default); @@ -904,7 +906,9 @@ function session(array|UnitEnum|string|null $key = null, mixed $default = null): } if (is_array($key)) { - return $session->put($key); + $session->put($key); + + return null; } return $session->get($key, $default); diff --git a/src/passport/src/Passport.php b/src/passport/src/Passport.php index 7a1568183..6cea839ce 100644 --- a/src/passport/src/Passport.php +++ b/src/passport/src/Passport.php @@ -19,6 +19,7 @@ use Hypervel\Passport\Contracts\DeviceUserCodeViewResponse; use Hypervel\Passport\Contracts\OAuthenticatable; use Hypervel\Passport\Contracts\ServerSettingsResolver; +use Hypervel\Passport\Guards\TokenGuard; use Hypervel\Passport\Http\Responses\SimpleViewResponse; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; @@ -667,7 +668,10 @@ public static function actingAsClient(Client $client, array $scopes = [], ?strin CoroutineContext::set(ServerFactory::RESOURCE_SERVER_CONTEXT_KEY, $mock); - app('auth')->guard($guard)->setClient($client); + /** @var TokenGuard $passportGuard */ + $passportGuard = app('auth')->guard($guard); + + $passportGuard->setClient($client); app('auth')->shouldUse($guard); From e5abd40ba9ea4ecea13783cdcd66c2edc57d40c0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:37:18 +0000 Subject: [PATCH 015/303] Introduce the Hypervel PHPStan package and analyzer test harness Create the PHPStan extension package and consolidate reusable Database, Foundation, Head and Scout extensions under its namespace. Replace component-owned registration files and metadata with centralized discovery while preserving the existing forwarding and macro behavior. Boot real applications or disposable Testbench applications for container-aware analysis. Infer constant service keys without confusing facade aliases with resolved objects, preserve generic class-string returns, and handle factory closures and unavailable classes conservatively. Report bootstrap failures with literal exception text and neutral diagnostics. Add isolated application/package worker fixtures, container typing assertions, and a dedicated analyzer suite that retains its shared application's handlers. Wire the suite into Composer and CI and preserve upstream licensing. --- .github/workflows/tests.yml | 3 + .php-cs-fixer.php | 2 + composer.json | 9 + phpstan.neon.dist | 5 +- phpstan.types.neon.dist | 17 +- phpunit.phpstan.xml.dist | 44 +++ phpunit.xml.dist | 2 + src/database/composer.json | 8 +- src/database/extension.neon | 27 -- src/foundation/composer.json | 8 - src/foundation/extension.neon | 5 - src/head/composer.json | 5 - src/head/extension.neon | 5 - src/phpstan/LICENSE.md | 23 ++ src/phpstan/README.md | 14 + src/phpstan/bootstrap.php | 47 ++++ src/phpstan/composer.json | 61 ++++ src/phpstan/extension.neon | 103 +++++++ src/phpstan/src/ApplicationResolver.php | 53 ++++ src/phpstan/src/Concerns/HasContainer.php | 45 +++ .../src/Methods}/BuilderMacroResolver.php | 7 +- .../ForwardedBuilderMethodExtension.php | 5 +- .../ForwardedModelMethodExtension.php | 5 +- .../src/Methods/Macros}/RoutingMacros.php | 2 +- .../src/Methods/Macros}/SearchableMacros.php | 2 +- .../Methods/Macros}/SoftDeletingMacros.php | 2 +- .../src/Methods}/ModelScopeMethodResolver.php | 4 +- .../src/Methods}/ModelScopeTypeResolver.php | 2 +- .../Methods}/NamedScopeMethodExtension.php | 5 +- .../src/Methods}/WithHeadExtension.php | 12 +- .../ForwardedFluentMethodReflection.php | 2 +- .../ForwardedModelMethodReflection.php | 2 +- .../ModelScopeParameterReflection.php | 3 +- .../NamedScopeMethodReflection.php | 3 +- .../Reflection}/WithHeadMethodReflection.php | 2 +- .../AppMakeDynamicReturnTypeExtension.php | 55 ++++ src/phpstan/src/ReturnTypes/AppMakeHelper.php | 94 +++++++ ...ontainerMakeDynamicReturnTypeExtension.php | 61 ++++ .../src/ReturnTypes/Helpers/AppExtension.php | 46 +++ .../HelperFunctionReturnTypeExtension.php | 4 +- .../src/Support/BootstrapErrorHandler.php | 261 ++++++++++++++++++ src/scout/composer.json | 5 - src/scout/extension.neon | 3 - tests/PHPStan/BootstrapTest.php | 217 +++++++++++++++ .../Fixtures/Bootstrap/AspectGreeter.php | 24 ++ .../BootstrapFixtureServiceProvider.php | 28 ++ .../Fixtures/Bootstrap/GreetingAspect.php | 21 ++ .../Fixtures/Bootstrap/ReplacedGreeter.php | 16 ++ tests/PHPStan/Fixtures/Bootstrap/Usage.php | 18 ++ .../Bootstrap/application/bootstrap/app.php | 10 + tests/PHPStan/Fixtures/Bootstrap/evidence.php | 22 ++ .../Bootstrap/replacement/ReplacedGreeter.php | 24 ++ .../ForwardedBuilderMethodExtensionTest.php | 12 +- tests/PHPStan/TestCase.php | 20 ++ tests/PHPStan/Type/GeneralTypeTest.php | 35 +++ tests/PHPStan/Type/data/FailsAtRuntime.php | 13 + tests/PHPStan/Type/data/app-make.php | 25 ++ tests/PHPStan/Type/data/application-make.php | 30 ++ tests/PHPStan/Type/data/container-make.php | 54 ++++ .../Support/BootstrapErrorHandlerTest.php | 147 ++++++++++ tests/PHPStan/phpstan-tests.neon | 5 + tests/PHPStan/phpunit-bootstrap.php | 10 + types/Foundation/Helpers.php | 5 + 63 files changed, 1709 insertions(+), 100 deletions(-) create mode 100644 phpunit.phpstan.xml.dist delete mode 100644 src/database/extension.neon delete mode 100644 src/foundation/extension.neon delete mode 100644 src/head/extension.neon create mode 100644 src/phpstan/LICENSE.md create mode 100644 src/phpstan/README.md create mode 100644 src/phpstan/bootstrap.php create mode 100644 src/phpstan/composer.json create mode 100644 src/phpstan/extension.neon create mode 100644 src/phpstan/src/ApplicationResolver.php create mode 100644 src/phpstan/src/Concerns/HasContainer.php rename src/{database/src/PHPStan => phpstan/src/Methods}/BuilderMacroResolver.php (91%) rename src/{database/src/PHPStan => phpstan/src/Methods}/ForwardedBuilderMethodExtension.php (98%) rename src/{database/src/PHPStan => phpstan/src/Methods}/ForwardedModelMethodExtension.php (96%) rename src/{head/src/PHPStan => phpstan/src/Methods/Macros}/RoutingMacros.php (98%) rename src/{scout/src/PHPStan => phpstan/src/Methods/Macros}/SearchableMacros.php (91%) rename src/{database/src/PHPStan => phpstan/src/Methods/Macros}/SoftDeletingMacros.php (96%) rename src/{database/src/PHPStan => phpstan/src/Methods}/ModelScopeMethodResolver.php (94%) rename src/{database/src/PHPStan => phpstan/src/Methods}/ModelScopeTypeResolver.php (95%) rename src/{database/src/PHPStan => phpstan/src/Methods}/NamedScopeMethodExtension.php (97%) rename src/{head/src/PHPStan => phpstan/src/Methods}/WithHeadExtension.php (80%) rename src/{database/src/PHPStan => phpstan/src/Reflection}/ForwardedFluentMethodReflection.php (98%) rename src/{database/src/PHPStan => phpstan/src/Reflection}/ForwardedModelMethodReflection.php (98%) rename src/{database/src/PHPStan => phpstan/src/Reflection}/ModelScopeParameterReflection.php (94%) rename src/{database/src/PHPStan => phpstan/src/Reflection}/NamedScopeMethodReflection.php (98%) rename src/{head/src/PHPStan => phpstan/src/Reflection}/WithHeadMethodReflection.php (98%) create mode 100644 src/phpstan/src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/AppMakeHelper.php create mode 100644 src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/AppExtension.php rename src/{foundation/src/PHPStan => phpstan/src/ReturnTypes/Helpers}/HelperFunctionReturnTypeExtension.php (93%) create mode 100644 src/phpstan/src/Support/BootstrapErrorHandler.php delete mode 100644 src/scout/extension.neon create mode 100644 tests/PHPStan/BootstrapTest.php create mode 100644 tests/PHPStan/Fixtures/Bootstrap/AspectGreeter.php create mode 100644 tests/PHPStan/Fixtures/Bootstrap/BootstrapFixtureServiceProvider.php create mode 100644 tests/PHPStan/Fixtures/Bootstrap/GreetingAspect.php create mode 100644 tests/PHPStan/Fixtures/Bootstrap/ReplacedGreeter.php create mode 100644 tests/PHPStan/Fixtures/Bootstrap/Usage.php create mode 100644 tests/PHPStan/Fixtures/Bootstrap/application/bootstrap/app.php create mode 100644 tests/PHPStan/Fixtures/Bootstrap/evidence.php create mode 100644 tests/PHPStan/Fixtures/Bootstrap/replacement/ReplacedGreeter.php rename tests/{Database/PHPStan => PHPStan/Methods}/ForwardedBuilderMethodExtensionTest.php (68%) create mode 100644 tests/PHPStan/TestCase.php create mode 100644 tests/PHPStan/Type/GeneralTypeTest.php create mode 100644 tests/PHPStan/Type/data/FailsAtRuntime.php create mode 100644 tests/PHPStan/Type/data/app-make.php create mode 100644 tests/PHPStan/Type/data/application-make.php create mode 100644 tests/PHPStan/Type/data/container-make.php create mode 100644 tests/PHPStan/Unit/Support/BootstrapErrorHandlerTest.php create mode 100644 tests/PHPStan/phpstan-tests.neon create mode 100644 tests/PHPStan/phpunit-bootstrap.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b704c0f88..a8c20e153 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,6 +53,9 @@ jobs: - name: Run Permission suite with the database cache store run: CACHE_STORE=database vendor/bin/paratest -c phpunit.xml.dist tests/Permission + - name: Run PHPStan analyzer suite + run: vendor/bin/paratest -c phpunit.phpstan.xml.dist + - name: Run Testbench package-mode suite run: php src/testbench/bin/testbench package:test --parallel tests/Testbench diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 8cf1b31ca..5a921e613 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -124,6 +124,8 @@ ->notPath('src/data/src/Support/Creation/NativeScalar.php') ->notPath('tests/Data/Fixtures/PhpDocTypeContext.php') ->notPath('tests/Foundation/Fixtures/fake-compiled-view.php') + // Analyzer data mirrors upstream Larastan fixtures, whose syntax and line numbers are deliberate. + ->notPath('#^tests/PHPStan/(.+/)?data/#') ->name('hypervel-test-profile') ->in(__DIR__) ) diff --git a/composer.json b/composer.json index f2ed998f6..431ef922a 100644 --- a/composer.json +++ b/composer.json @@ -134,6 +134,7 @@ "opis/json-schema": "^2.6.0", "pda/pheanstalk": "^8.0.2", "phpstan/phpstan": "^2.3.5", + "phpstan/phpstan-deprecation-rules": "^2.0.5", "phpunit/phpunit": "~13.4.3", "pusher/pusher-php-server": "^7.2", "resend/resend-php": "^1.0", @@ -205,6 +206,7 @@ "hypervel/passport": "self.version", "hypervel/pennant": "self.version", "hypervel/permission": "self.version", + "hypervel/phpstan": "self.version", "hypervel/pipeline": "self.version", "hypervel/process": "self.version", "hypervel/prompts": "self.version", @@ -301,6 +303,7 @@ "Hypervel\\Passport\\Database\\Factories\\": "src/passport/database/factories/", "Hypervel\\Pennant\\": "src/pennant/src/", "Hypervel\\Permission\\": "src/permission/src/", + "Hypervel\\PHPStan\\": "src/phpstan/src/", "Hypervel\\Pipeline\\": "src/pipeline/src/", "Hypervel\\Process\\": "src/process/src/", "Hypervel\\Prompts\\": "src/prompts/src/", @@ -476,6 +479,7 @@ "check": [ "@check:skeleton-config", "@test", + "@test:phpstan", "@test:testbench", "@test:dogfood", "@analyse", @@ -488,6 +492,7 @@ "@sync:skeleton-config", "@analyse", "@test:parallel", + "@test:phpstan", "@test:testbench", "@test:dogfood" ], @@ -519,6 +524,10 @@ "Composer\\Config::disableProcessTimeout", "paratest" ], + "test:phpstan": [ + "Composer\\Config::disableProcessTimeout", + "paratest -c phpunit.phpstan.xml.dist" + ], "test:profile": [ "Composer\\Config::disableProcessTimeout", "@php src/testing/bin/hypervel-test-profile" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 99d2d1bf8..406803375 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,10 +4,7 @@ # limitations. includes: - - src/database/extension.neon - - src/foundation/extension.neon - - src/head/extension.neon - - src/scout/extension.neon + - src/phpstan/extension.neon parameters: tmpDir: .cache/phpstan diff --git a/phpstan.types.neon.dist b/phpstan.types.neon.dist index 8f75c3d25..43ec38305 100644 --- a/phpstan.types.neon.dist +++ b/phpstan.types.neon.dist @@ -1,16 +1,17 @@ includes: - - src/database/extension.neon - - src/foundation/extension.neon - - src/head/extension.neon - - src/scout/extension.neon + - src/phpstan/extension.neon parameters: level: max tmpDir: .cache/phpstan-types scanDirectories: - - src/database/src/PHPStan - - src/foundation/src/PHPStan - - src/head/src/PHPStan - - src/scout/src/PHPStan + - src/phpstan/src paths: - types + # These fixtures assert the types of supported APIs, not authoring policy. + noLegacyModelScopes: false + noLegacyModelProperties: false + noLegacyQueueProperties: false + noPipeSeparatedValidationRules: false + checkModelMethodVisibility: false + noUnnecessaryEnumerableToArrayCalls: false diff --git a/phpunit.phpstan.xml.dist b/phpunit.phpstan.xml.dist new file mode 100644 index 000000000..7715070db --- /dev/null +++ b/phpunit.phpstan.xml.dist @@ -0,0 +1,44 @@ + + + + + ./tests/PHPStan + + + + + + + + + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 758d55952..facf0d6b1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,6 +17,8 @@ ./tests + + ./tests/PHPStan diff --git a/src/database/composer.json b/src/database/composer.json index 3eced1fa2..c008e9112 100644 --- a/src/database/composer.json +++ b/src/database/composer.json @@ -58,8 +58,7 @@ "symfony/process": "^8.1" }, "require-dev": { - "fakerphp/faker": "^1.24", - "phpstan/phpstan": "^2.3.5" + "fakerphp/faker": "^1.24" }, "suggest": { "fakerphp/faker": "Required to use Eloquent model factories (^1.24)." @@ -80,11 +79,6 @@ "providers": [ "Hypervel\\Database\\DatabaseServiceProvider" ] - }, - "phpstan": { - "includes": [ - "extension.neon" - ] } } } diff --git a/src/database/extension.neon b/src/database/extension.neon deleted file mode 100644 index 421ee2836..000000000 --- a/src/database/extension.neon +++ /dev/null @@ -1,27 +0,0 @@ -parametersSchema: - eloquentBuilderMacros: arrayOf(string(), string()) - -parameters: - # Builder macros added by model traits' global scopes: trait => interface declaring the macros. - eloquentBuilderMacros: - Hypervel\Database\Eloquent\SoftDeletes: Hypervel\Database\PHPStan\SoftDeletingMacros - -services: - - - class: Hypervel\Database\PHPStan\BuilderMacroResolver - arguments: - macros: %eloquentBuilderMacros% - - - class: Hypervel\Database\PHPStan\ModelScopeMethodResolver - - - class: Hypervel\Database\PHPStan\ForwardedBuilderMethodExtension - tags: - - phpstan.broker.methodsClassReflectionExtension - - - class: Hypervel\Database\PHPStan\ForwardedModelMethodExtension - tags: - - phpstan.broker.methodsClassReflectionExtension - - - class: Hypervel\Database\PHPStan\NamedScopeMethodExtension - tags: - - phpstan.broker.methodsClassReflectionExtension diff --git a/src/foundation/composer.json b/src/foundation/composer.json index aed5ec02a..38234b0af 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -84,9 +84,6 @@ "hypervel/view": "^0.4", "hypervel/websocket-server": "^0.4" }, - "require-dev": { - "phpstan/phpstan": "^2.3.5" - }, "suggest": { "algolia/algoliasearch-client-php": "Required to use the InteractsWithAlgolia trait (^4.49.0).", "composer/semver": "Required to use PackageManifest::satisfies() for version constraint checking (^3.0).", @@ -108,11 +105,6 @@ "Hypervel\\Foundation\\Providers\\FoundationServiceProvider" ] }, - "phpstan": { - "includes": [ - "extension.neon" - ] - }, "branch-alias": { "dev-main": "0.4-dev" } diff --git a/src/foundation/extension.neon b/src/foundation/extension.neon deleted file mode 100644 index d19a913b0..000000000 --- a/src/foundation/extension.neon +++ /dev/null @@ -1,5 +0,0 @@ -services: - - - class: Hypervel\Foundation\PHPStan\HelperFunctionReturnTypeExtension - tags: - - phpstan.broker.dynamicFunctionReturnTypeExtension diff --git a/src/head/composer.json b/src/head/composer.json index fde356bcd..a04792695 100644 --- a/src/head/composer.json +++ b/src/head/composer.json @@ -63,11 +63,6 @@ "aliases": { "Head": "Hypervel\\Head\\Facades\\Head" } - }, - "phpstan": { - "includes": [ - "extension.neon" - ] } } } diff --git a/src/head/extension.neon b/src/head/extension.neon deleted file mode 100644 index 392e16dc3..000000000 --- a/src/head/extension.neon +++ /dev/null @@ -1,5 +0,0 @@ -services: - - - class: Hypervel\Head\PHPStan\WithHeadExtension - tags: - - phpstan.broker.methodsClassReflectionExtension diff --git a/src/phpstan/LICENSE.md b/src/phpstan/LICENSE.md new file mode 100644 index 000000000..818ce5f41 --- /dev/null +++ b/src/phpstan/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Can Vural , Nuno Maduro , + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/phpstan/README.md b/src/phpstan/README.md new file mode 100644 index 000000000..086f46ae4 --- /dev/null +++ b/src/phpstan/README.md @@ -0,0 +1,14 @@ +PHPStan for Hypervel +=== + +Documentation: https://hypervel.org/docs/phpstan + +## Differences From Larastan + +- Package-owned diagnostic identifiers use the `hypervel.*` prefix instead of `larastan.*` (and `rules.modelAppends`). PHPStan's own identifiers are unchanged. +- Several checks are enabled by default, and Hypervel adds coroutine, worker-lifetime and authoring-policy checks. See the [configuration defaults](https://hypervel.org/docs/phpstan#configuration). +- There is no Octane compatibility rule or `checkOctaneCompatibility` setting; Hypervel's worker-lifetime checks replace it. There is no deferred service provider rule, because Hypervel has no deferred providers. +- Container ArrayAccess return types are not provided, because the Hypervel container does not support ArrayAccess. +- The `generalizeEnvReturnType` and `redisDriver` settings are omitted. An `env()` default does not decide the type of the value it reads, and Redis types always describe Hypervel's phpredis connection. + +Ported from: https://github.com/larastan/larastan diff --git a/src/phpstan/bootstrap.php b/src/phpstan/bootstrap.php new file mode 100644 index 000000000..ed81621d8 --- /dev/null +++ b/src/phpstan/bootstrap.php @@ -0,0 +1,47 @@ +make(Kernel::class)->bootstrap(); +} catch (Throwable $throwable) { + $argv = $_SERVER['argv'] ?? []; + $decorated = null; + + if (in_array('--no-ansi', $argv, true)) { + $decorated = false; + } elseif (in_array('--ansi', $argv, true)) { + $decorated = true; + } + + (new BootstrapErrorHandler(decorated: $decorated))->handle($throwable); + exit(1); +} diff --git a/src/phpstan/composer.json b/src/phpstan/composer.json new file mode 100644 index 000000000..50b5e36bc --- /dev/null +++ b/src/phpstan/composer.json @@ -0,0 +1,61 @@ +{ + "name": "hypervel/phpstan", + "type": "phpstan-extension", + "description": "Framework-aware PHPStan type inference and rules for Hypervel applications and packages.", + "license": "MIT", + "keywords": [ + "php", + "swoole", + "phpstan", + "static analysis", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\PHPStan\\": "src/" + } + }, + "require": { + "php": "^8.4", + "composer-runtime-api": "^2.2", + "hypervel/container": "^0.4", + "hypervel/contracts": "^0.4", + "hypervel/database": "^0.4", + "hypervel/http": "^0.4", + "hypervel/routing": "^0.4", + "hypervel/support": "^0.4", + "phpstan/phpstan": "^2.3.5", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "symfony/console": "^8.1.10" + }, + "suggest": { + "hypervel/testbench": "Required to analyse packages with a Testbench-booted application (^0.4)." + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + } +} diff --git a/src/phpstan/extension.neon b/src/phpstan/extension.neon new file mode 100644 index 000000000..282592db4 --- /dev/null +++ b/src/phpstan/extension.neon @@ -0,0 +1,103 @@ +attributeServicesDirectories: + - src + +parameters: + universalObjectCratesClasses: + - Hypervel\Http\Request + - Hypervel\Support\Optional + # abort() and dd() declare native never returns, so they need no earlyTerminatingFunctionCalls entries. + mixinExcludeClasses: + - Eloquent + bootstrapFiles: + - bootstrap.php + noEnvCallsOutsideOfConfig: true + noModelMake: true + noImplicitQueryBuilderCall: false + noUnnecessaryCollectionCall: true + noUnnecessaryCollectionCallOnly: [] + noUnnecessaryCollectionCallExcept: [] + noUnnecessaryEnumerableToArrayCalls: true + squashedMigrationsPath: [] + databaseMigrationsPath: [] + disableMigrationScan: false + disableSchemaScan: false + configDirectories: [] + viewDirectories: [] + translationDirectories: [] + checkModelProperties: true + checkUnusedViews: false + checkMissingTranslations: true + checkModelAppends: true + checkModelMethodVisibility: true + checkConfigTypes: true + checkAuthCallsWhenInRequestScope: false + parseModelCastsMethod: true + enableMigrationCache: true + checkUniqueJobUniqueFor: false + checkUniqueJobUniqueId: true + noBatchedUniqueJob: true + checkJobSerializesModels: true + checkBatchedJobIsBatchable: true + checkBatchableJobChecksCancellation: false + checkDispatchInTransactionAfterCommit: true + checkCancellationHandling: true + checkCancellationPrecedence: true + checkWorkerSharedState: true + checkStaticStateMutation: true + checkUnboundedWorkerCaches: true + noRuntimeConfigMutation: true + noCoroutinesInTransactions: true + noRawSleepCalls: true + noLegacyModelScopes: true + noLegacyModelProperties: true + noLegacyQueueProperties: true + noPipeSeparatedValidationRules: true + # Builder macros added by model traits' global scopes: trait => interface declaring the macros. + eloquentBuilderMacros: + Hypervel\Database\Eloquent\SoftDeletes: Hypervel\PHPStan\Methods\Macros\SoftDeletingMacros + Hypervel\Scout\Searchable: Hypervel\PHPStan\Methods\Macros\SearchableMacros + +parametersSchema: + noEnvCallsOutsideOfConfig: bool() + noModelMake: bool() + noImplicitQueryBuilderCall: bool() + noUnnecessaryCollectionCall: bool() + noUnnecessaryCollectionCallOnly: listOf(string()) + noUnnecessaryCollectionCallExcept: listOf(string()) + noUnnecessaryEnumerableToArrayCalls: bool() + databaseMigrationsPath: listOf(string()) + disableMigrationScan: bool() + configDirectories: listOf(string()) + viewDirectories: listOf(string()) + translationDirectories: listOf(string()) + squashedMigrationsPath: listOf(string()) + disableSchemaScan: bool() + checkModelProperties: bool() + checkUnusedViews: bool() + checkMissingTranslations: bool() + checkModelAppends: bool() + checkModelMethodVisibility: bool() + checkConfigTypes: bool() + checkAuthCallsWhenInRequestScope: bool() + parseModelCastsMethod: bool() + enableMigrationCache: bool() + checkUniqueJobUniqueFor: bool() + checkUniqueJobUniqueId: bool() + noBatchedUniqueJob: bool() + checkJobSerializesModels: bool() + checkBatchedJobIsBatchable: bool() + checkBatchableJobChecksCancellation: bool() + checkDispatchInTransactionAfterCommit: bool() + checkCancellationHandling: bool() + checkCancellationPrecedence: bool() + checkWorkerSharedState: bool() + checkStaticStateMutation: bool() + checkUnboundedWorkerCaches: bool() + noRuntimeConfigMutation: bool() + noCoroutinesInTransactions: bool() + noRawSleepCalls: bool() + noLegacyModelScopes: bool() + noLegacyModelProperties: bool() + noLegacyQueueProperties: bool() + noPipeSeparatedValidationRules: bool() + eloquentBuilderMacros: arrayOf(string(), string()) diff --git a/src/phpstan/src/ApplicationResolver.php b/src/phpstan/src/ApplicationResolver.php new file mode 100644 index 000000000..99d76db4d --- /dev/null +++ b/src/phpstan/src/ApplicationResolver.php @@ -0,0 +1,53 @@ + $config->getExtraAttributes(), + 'discovers_root_package' => true, + ]); + } +} diff --git a/src/phpstan/src/Concerns/HasContainer.php b/src/phpstan/src/Concerns/HasContainer.php new file mode 100644 index 000000000..cc35b4665 --- /dev/null +++ b/src/phpstan/src/Concerns/HasContainer.php @@ -0,0 +1,45 @@ +container = $container; + } + + /** + * Return the current container. + */ + public function getContainer(): ContainerContract + { + return $this->container ?? Container::getInstance(); + } + + /** + * Resolve the given type from the container. + */ + public function resolve(string $abstract): mixed + { + try { + $concrete = $this->getContainer()->make($abstract); + } catch (Throwable) { + return null; + } + + return $concrete; + } +} diff --git a/src/database/src/PHPStan/BuilderMacroResolver.php b/src/phpstan/src/Methods/BuilderMacroResolver.php similarity index 91% rename from src/database/src/PHPStan/BuilderMacroResolver.php rename to src/phpstan/src/Methods/BuilderMacroResolver.php index cf40df2f7..5e086d723 100644 --- a/src/database/src/PHPStan/BuilderMacroResolver.php +++ b/src/phpstan/src/Methods/BuilderMacroResolver.php @@ -2,9 +2,12 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Methods; +use Hypervel\PHPStan\Reflection\ForwardedFluentMethodReflection; use PHPStan\Analyser\OutOfClassScope; +use PHPStan\DependencyInjection\AutowiredParameter; +use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ReflectionProvider; @@ -19,6 +22,7 @@ * Packages map each trait to an interface declaring its macros through the * eloquentBuilderMacros parameter. A generic interface receives the model type. */ +#[AutowiredService] class BuilderMacroResolver { private readonly OutOfClassScope $scope; @@ -30,6 +34,7 @@ class BuilderMacroResolver */ public function __construct( private readonly ReflectionProvider $reflectionProvider, + #[AutowiredParameter(ref: '%eloquentBuilderMacros%')] private readonly array $macros, ) { $this->scope = new OutOfClassScope; diff --git a/src/database/src/PHPStan/ForwardedBuilderMethodExtension.php b/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php similarity index 98% rename from src/database/src/PHPStan/ForwardedBuilderMethodExtension.php rename to src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php index 3effdda4f..9571d9dc1 100644 --- a/src/database/src/PHPStan/ForwardedBuilderMethodExtension.php +++ b/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php @@ -2,12 +2,14 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Methods; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\PHPStan\Reflection\ForwardedFluentMethodReflection; use LogicException; use PHPStan\Analyser\OutOfClassScope; +use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MethodsClassReflectionExtension; @@ -20,6 +22,7 @@ /** * Resolve declared builders while preserving Eloquent and relation forwarding. */ +#[AutowiredService] class ForwardedBuilderMethodExtension implements MethodsClassReflectionExtension { /** @var list */ diff --git a/src/database/src/PHPStan/ForwardedModelMethodExtension.php b/src/phpstan/src/Methods/ForwardedModelMethodExtension.php similarity index 96% rename from src/database/src/PHPStan/ForwardedModelMethodExtension.php rename to src/phpstan/src/Methods/ForwardedModelMethodExtension.php index cea4eee04..d3601c37d 100644 --- a/src/database/src/PHPStan/ForwardedModelMethodExtension.php +++ b/src/phpstan/src/Methods/ForwardedModelMethodExtension.php @@ -2,11 +2,13 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Methods; use Hypervel\Database\Eloquent\Model; +use Hypervel\PHPStan\Reflection\ForwardedModelMethodReflection; use LogicException; use PHPStan\Analyser\OutOfClassScope; +use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MethodsClassReflectionExtension; @@ -16,6 +18,7 @@ /** * Describe Eloquent builder methods forwarded through models. */ +#[AutowiredService] class ForwardedModelMethodExtension implements MethodsClassReflectionExtension { /** @var array */ diff --git a/src/head/src/PHPStan/RoutingMacros.php b/src/phpstan/src/Methods/Macros/RoutingMacros.php similarity index 98% rename from src/head/src/PHPStan/RoutingMacros.php rename to src/phpstan/src/Methods/Macros/RoutingMacros.php index d5cd54926..759f40c13 100644 --- a/src/head/src/PHPStan/RoutingMacros.php +++ b/src/phpstan/src/Methods/Macros/RoutingMacros.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Head\PHPStan; +namespace Hypervel\PHPStan\Methods\Macros; use Hypervel\Head\Enums\RobotsRule; diff --git a/src/scout/src/PHPStan/SearchableMacros.php b/src/phpstan/src/Methods/Macros/SearchableMacros.php similarity index 91% rename from src/scout/src/PHPStan/SearchableMacros.php rename to src/phpstan/src/Methods/Macros/SearchableMacros.php index 3371190a5..06a465666 100644 --- a/src/scout/src/PHPStan/SearchableMacros.php +++ b/src/phpstan/src/Methods/Macros/SearchableMacros.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Scout\PHPStan; +namespace Hypervel\PHPStan\Methods\Macros; /** * Declare the builder macros that SearchableScope adds, for static analysis. diff --git a/src/database/src/PHPStan/SoftDeletingMacros.php b/src/phpstan/src/Methods/Macros/SoftDeletingMacros.php similarity index 96% rename from src/database/src/PHPStan/SoftDeletingMacros.php rename to src/phpstan/src/Methods/Macros/SoftDeletingMacros.php index 6271c56d4..9d6add8b6 100644 --- a/src/database/src/PHPStan/SoftDeletingMacros.php +++ b/src/phpstan/src/Methods/Macros/SoftDeletingMacros.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Methods\Macros; use Hypervel\Database\Eloquent\Model; diff --git a/src/database/src/PHPStan/ModelScopeMethodResolver.php b/src/phpstan/src/Methods/ModelScopeMethodResolver.php similarity index 94% rename from src/database/src/PHPStan/ModelScopeMethodResolver.php rename to src/phpstan/src/Methods/ModelScopeMethodResolver.php index bf3d8d3cb..ff213cb5e 100644 --- a/src/database/src/PHPStan/ModelScopeMethodResolver.php +++ b/src/phpstan/src/Methods/ModelScopeMethodResolver.php @@ -2,15 +2,17 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Methods; use Hypervel\Database\Eloquent\Attributes\Scope; +use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; /** * Resolve model methods exposed as named Eloquent scopes. */ +#[AutowiredService] class ModelScopeMethodResolver { /** @var array> */ diff --git a/src/database/src/PHPStan/ModelScopeTypeResolver.php b/src/phpstan/src/Methods/ModelScopeTypeResolver.php similarity index 95% rename from src/database/src/PHPStan/ModelScopeTypeResolver.php rename to src/phpstan/src/Methods/ModelScopeTypeResolver.php index 49dd9c6d2..e1f022b8a 100644 --- a/src/database/src/PHPStan/ModelScopeTypeResolver.php +++ b/src/phpstan/src/Methods/ModelScopeTypeResolver.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Methods; use PHPStan\Reflection\ClassReflection; use PHPStan\Type\StaticType; diff --git a/src/database/src/PHPStan/NamedScopeMethodExtension.php b/src/phpstan/src/Methods/NamedScopeMethodExtension.php similarity index 97% rename from src/database/src/PHPStan/NamedScopeMethodExtension.php rename to src/phpstan/src/Methods/NamedScopeMethodExtension.php index a04ec93bf..88eece711 100644 --- a/src/database/src/PHPStan/NamedScopeMethodExtension.php +++ b/src/phpstan/src/Methods/NamedScopeMethodExtension.php @@ -2,13 +2,15 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Methods; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\PHPStan\Reflection\NamedScopeMethodReflection; use LogicException; use PHPStan\Analyser\OutOfClassScope; +use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MethodsClassReflectionExtension; @@ -19,6 +21,7 @@ /** * Describe model scopes exposed through models, builders, and relations. */ +#[AutowiredService] class NamedScopeMethodExtension implements MethodsClassReflectionExtension { /** @var array */ diff --git a/src/head/src/PHPStan/WithHeadExtension.php b/src/phpstan/src/Methods/WithHeadExtension.php similarity index 80% rename from src/head/src/PHPStan/WithHeadExtension.php rename to src/phpstan/src/Methods/WithHeadExtension.php index bc4edc3e9..20d59878a 100644 --- a/src/head/src/PHPStan/WithHeadExtension.php +++ b/src/phpstan/src/Methods/WithHeadExtension.php @@ -2,14 +2,18 @@ declare(strict_types=1); -namespace Hypervel\Head\PHPStan; +namespace Hypervel\PHPStan\Methods; +use Hypervel\Head\Routing\HeadRouteMacros; +use Hypervel\PHPStan\Methods\Macros\RoutingMacros; +use Hypervel\PHPStan\Reflection\WithHeadMethodReflection; use Hypervel\Routing\PendingResourceRegistration; use Hypervel\Routing\PendingSingletonResourceRegistration; use Hypervel\Routing\Route; use Hypervel\Routing\Router; use Hypervel\Routing\RouteRegistrar; use Hypervel\Support\Facades\Route as RouteFacade; +use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MethodsClassReflectionExtension; @@ -17,6 +21,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\ThisType; +#[AutowiredService] class WithHeadExtension implements MethodsClassReflectionExtension { /** @@ -31,7 +36,10 @@ public function __construct(protected readonly ReflectionProvider $reflectionPro */ public function hasMethod(ClassReflection $classReflection, string $methodName): bool { - if ($methodName !== 'withHead' || $classReflection->hasNativeMethod($methodName)) { + // Head is optional; without it, no withHead() macro is registered. + if ($methodName !== 'withHead' + || $classReflection->hasNativeMethod($methodName) + || ! $this->reflectionProvider->hasClass(HeadRouteMacros::class)) { return false; } diff --git a/src/database/src/PHPStan/ForwardedFluentMethodReflection.php b/src/phpstan/src/Reflection/ForwardedFluentMethodReflection.php similarity index 98% rename from src/database/src/PHPStan/ForwardedFluentMethodReflection.php rename to src/phpstan/src/Reflection/ForwardedFluentMethodReflection.php index 835b3ed7f..e3779cad0 100644 --- a/src/database/src/PHPStan/ForwardedFluentMethodReflection.php +++ b/src/phpstan/src/Reflection/ForwardedFluentMethodReflection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Reflection; use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; diff --git a/src/database/src/PHPStan/ForwardedModelMethodReflection.php b/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php similarity index 98% rename from src/database/src/PHPStan/ForwardedModelMethodReflection.php rename to src/phpstan/src/Reflection/ForwardedModelMethodReflection.php index e124e626d..7ea829ea4 100644 --- a/src/database/src/PHPStan/ForwardedModelMethodReflection.php +++ b/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Reflection; use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; diff --git a/src/database/src/PHPStan/ModelScopeParameterReflection.php b/src/phpstan/src/Reflection/ModelScopeParameterReflection.php similarity index 94% rename from src/database/src/PHPStan/ModelScopeParameterReflection.php rename to src/phpstan/src/Reflection/ModelScopeParameterReflection.php index fdda0520c..bf2370d4c 100644 --- a/src/database/src/PHPStan/ModelScopeParameterReflection.php +++ b/src/phpstan/src/Reflection/ModelScopeParameterReflection.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Reflection; +use Hypervel\PHPStan\Methods\ModelScopeTypeResolver; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\PassedByReference; diff --git a/src/database/src/PHPStan/NamedScopeMethodReflection.php b/src/phpstan/src/Reflection/NamedScopeMethodReflection.php similarity index 98% rename from src/database/src/PHPStan/NamedScopeMethodReflection.php rename to src/phpstan/src/Reflection/NamedScopeMethodReflection.php index c95426459..cc697c91a 100644 --- a/src/database/src/PHPStan/NamedScopeMethodReflection.php +++ b/src/phpstan/src/Reflection/NamedScopeMethodReflection.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Reflection; +use Hypervel\PHPStan\Methods\ModelScopeTypeResolver; use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; diff --git a/src/head/src/PHPStan/WithHeadMethodReflection.php b/src/phpstan/src/Reflection/WithHeadMethodReflection.php similarity index 98% rename from src/head/src/PHPStan/WithHeadMethodReflection.php rename to src/phpstan/src/Reflection/WithHeadMethodReflection.php index 85d3792b4..78cfe02ed 100644 --- a/src/head/src/PHPStan/WithHeadMethodReflection.php +++ b/src/phpstan/src/Reflection/WithHeadMethodReflection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Head\PHPStan; +namespace Hypervel\PHPStan\Reflection; use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; diff --git a/src/phpstan/src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php new file mode 100644 index 000000000..6d08b76f0 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php @@ -0,0 +1,55 @@ +getName(), ['make', 'makeWith', 'makeTransient', 'get', 'factory'], true); + } + + /** + * Get the type of the resolved container entry. + */ + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + return $methodReflection->getName() === 'factory' + ? $this->appMakeHelper->resolveFactoryTypeFromCall($methodCall, $scope) + : $this->appMakeHelper->resolveTypeFromCall($methodCall, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/AppMakeHelper.php b/src/phpstan/src/ReturnTypes/AppMakeHelper.php new file mode 100644 index 000000000..fa9941a43 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AppMakeHelper.php @@ -0,0 +1,94 @@ +getArgs(); + + if (count($args) === 0) { + return null; + } + + // PHPStan reorders named arguments for extensions but leaves unpacked ones + // as-is, and its own evaluation of an unpacked key would apply a facade alias. + if ($args[0]->unpack) { + return new MixedType; + } + + $argType = $scope->getType($args[0]->value); + + $constantStrings = $argType->getConstantStrings(); + + if (count($constantStrings) === 0) { + return null; + } + + $types = []; + + foreach ($constantStrings as $constantString) { + $resolved = $this->resolve($constantString->getValue()); + + if (is_object($resolved)) { + $types[] = new ObjectType($resolved::class); + + continue; + } + + // Not every class-string has a reflection; PHPStan reports a missing class itself. + $class = $constantString->getClassStringObjectType()->getObjectClassReflections()[0] ?? null; + + // A class whose binding needs runtime context keeps its declared type. Upstream + // falls back for any class-string, but booted facade aliases make service keys + // such as 'session' class names, so only a key naming the class itself qualifies. + // Unknown keys, unknown classes and scalar bindings are mixed, as the container declares. + if ($class === null || strcasecmp(ltrim($constantString->getValue(), '\\'), $class->getName()) !== 0) { + return new MixedType; + } + + $types[] = new ObjectType($class->getName()); + } + + return TypeCombinator::union(...$types); + } + + /** + * Resolve the type of the closure returned by a container factory() call. + */ + public function resolveFactoryTypeFromCall(MethodCall|StaticCall $call, Scope $scope): ?Type + { + $type = $this->resolveTypeFromCall($call, $scope); + + return $type === null ? null : new ClosureType([], $type, false); + } +} diff --git a/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php new file mode 100644 index 000000000..7c911d258 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php @@ -0,0 +1,61 @@ +getName(), ['make', 'makeWith', 'makeTransient', 'get', 'resolve', 'factory'], true); + } + + /** + * Get the type of the resolved container entry. + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + return $methodReflection->getName() === 'factory' + ? $this->appMakeHelper->resolveFactoryTypeFromCall($methodCall, $scope) + : $this->appMakeHelper->resolveTypeFromCall($methodCall, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/AppExtension.php b/src/phpstan/src/ReturnTypes/Helpers/AppExtension.php new file mode 100644 index 000000000..526f4aed2 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/AppExtension.php @@ -0,0 +1,46 @@ +getName() === 'app' || $functionReflection->getName() === 'resolve'; + } + + /** + * Get the type of the resolved container entry. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + // A missing or null abstract keeps the helper's declared Application return, + // so upstream's separate no-argument branch is unnecessary. + return $this->appMakeHelper->resolveTypeFromCall($functionCall, $scope); + } +} diff --git a/src/foundation/src/PHPStan/HelperFunctionReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php similarity index 93% rename from src/foundation/src/PHPStan/HelperFunctionReturnTypeExtension.php rename to src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php index 6cc023ae6..310ddd896 100644 --- a/src/foundation/src/PHPStan/HelperFunctionReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Foundation\PHPStan; +namespace Hypervel\PHPStan\ReturnTypes\Helpers; use Hypervel\Contracts\Routing\ResponseFactory; use Hypervel\Contracts\Validation\Factory as ValidationFactory; @@ -10,12 +10,14 @@ use Hypervel\Http\Response; use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; +use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +#[AutowiredService] class HelperFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { /** diff --git a/src/phpstan/src/Support/BootstrapErrorHandler.php b/src/phpstan/src/Support/BootstrapErrorHandler.php new file mode 100644 index 000000000..0fbde6817 --- /dev/null +++ b/src/phpstan/src/Support/BootstrapErrorHandler.php @@ -0,0 +1,261 @@ +output ?? new ConsoleOutput; + $errorOutput = $output instanceof ConsoleOutput ? $output->getErrorOutput() : $output; + + if ($this->decorated !== null) { + $output->setDecorated($this->decorated); + $errorOutput->setDecorated($this->decorated); + } + + $decorated = $errorOutput->isDecorated(); + + if ($decorated && $this->renderWithCollision($throwable, $errorOutput)) { + return; + } + + $this->renderWithSymfonyStyle($throwable, $errorOutput); + } + + /** + * Render the failure with Collision when it is installed. + */ + private function renderWithCollision(Throwable $throwable, OutputInterface $output): bool + { + if (! class_exists(Provider::class) || ! function_exists('Termwind\render')) { + return false; + } + + try { + $message = $this->formatError($throwable, includeErrorDetails: false); + $title = self::TITLE; + + $body = $this->buildCollisionBody($message); + + renderUsing($output); + + render(<< +
+ {$title} +
+
+ {$body} +
+
Exception Details
+
+ + + HTML); + + $provider = new Provider; + $handler = $provider->register() + ->getHandler() + ->setOutput($output); + + $inspector = match (true) { + class_exists(Inspector::class) => new Inspector($throwable), + class_exists(InspectorFactory::class) => (new InspectorFactory)->create($throwable), + default => null, + }; + + if ($inspector !== null) { + $handler->setInspector($inspector); + } + + $handler->handle($throwable); + + return true; + } catch (Throwable) { + return false; + } + } + + /** + * Render the failure with Symfony console styling. + */ + private function renderWithSymfonyStyle(Throwable $throwable, OutputInterface $output): void + { + $message = $this->formatError($throwable, decorated: $output->isDecorated()); + + $io = new SymfonyStyle(new ArrayInput([]), $output); + + $io->block(self::TITLE, null, 'fg=white;bg=red', ' ', true); + + foreach ($this->splitLines($message) as $line) { + // Exception messages and traces are literal text, not console markup. + $line = OutputFormatter::escape($line); + + match (true) { + $line === '' => $io->newLine(), + str_starts_with($line, 'Error:') => $io->writeln('' . $line . ''), + str_starts_with($line, 'Stack trace:') => $io->writeln('' . $line . ''), + preg_match('/^#\d+/', $line) === 1 => $io->writeln('' . $line . ''), + default => $io->writeln($line), + }; + } + } + + /** + * Format a bootstrap failure. + * + * Upstream blames the application or the framework depending on whether the failing + * file is under a /vendor/ path. That misreports custom vendor directories and + * path-repository installs, and a dependency's exception rarely means the + * framework is at fault, so every failure gets the same guidance. + */ + private function formatError(Throwable $throwable, bool $includeErrorDetails = true, bool $decorated = true): string + { + $parts = [ + 'PHPStan was unable to bootstrap your application.', + '', + 'Hypervel PHPStan boots your Hypervel application during analysis to provide smarter results.', + 'The application reported an error while booting, so the analysis could not continue.', + '', + sprintf('%s Try the following:', $this->getTipSymbol($decorated)), + ' - Check your environment variables in the .env file', + ' - Check the Hypervel logs in storage/logs/hypervel.log for more details', + ' - Run composer dump-autoload to ensure classes are indexed', + ' - Verify service provider registration and configuration', + ' - Verify the application boots by running php artisan about, or vendor/bin/testbench about for a package', + ]; + + return $this->appendErrorDetails($parts, $throwable, $includeErrorDetails); + } + + /** + * Append the error message and stack trace when requested. + * + * @param list $parts + */ + private function appendErrorDetails(array $parts, Throwable $throwable, bool $includeErrorDetails): string + { + if ($includeErrorDetails) { + $parts = array_merge($parts, [ + '', + sprintf('Error: %s', $this->formatErrorMessage($throwable)), + '', + 'Stack trace:', + $this->formatStackTrace($throwable), + ]); + } + + return implode(PHP_EOL, $parts); + } + + /** + * Format the exception message. + */ + private function formatErrorMessage(Throwable $throwable): string + { + $message = trim($throwable->getMessage()); + + return $message !== '' ? $message : sprintf('Unhandled %s with no message', $throwable::class); + } + + /** + * Format the stack trace relative to the project root. + */ + private function formatStackTrace(Throwable $throwable): string + { + $projectRoot = getcwd(); + $trace = $throwable->getTraceAsString(); + + // Upstream converts every backslash to normalize Windows paths, which also + // rewrites namespace separators. Swoole has no native Windows support, so + // paths already use forward slashes. + return $projectRoot !== false + ? str_replace($projectRoot . '/', '', $trace) + : $trace; + } + + /** + * Split a message into lines. + * + * @return array + */ + private function splitLines(string $message): array + { + return preg_split('/\r\n|\r|\n/', $message) ?: []; + } + + /** + * Build the Termwind markup for a message. + */ + private function buildCollisionBody(string $message): string + { + return implode('', array_map(fn (string $line): string => match (true) { + $line === '' => '
', + str_starts_with($line, ' - ') => '
• ' . $this->escapeForHtml(substr($line, 3)) . '
', + default => '
' . $this->escapeForHtml($line) . '
', + }, $this->splitLines($message))); + } + + /** + * Escape a value for Termwind markup. + */ + private function escapeForHtml(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } + + /** + * Return the tip prefix for the output mode. + */ + private function getTipSymbol(bool $decorated): string + { + return $decorated ? "\u{1F4A1}" : 'Tip:'; + } +} diff --git a/src/scout/composer.json b/src/scout/composer.json index 341dee17b..1bbe85a00 100644 --- a/src/scout/composer.json +++ b/src/scout/composer.json @@ -74,11 +74,6 @@ "providers": [ "Hypervel\\Scout\\ScoutServiceProvider" ] - }, - "phpstan": { - "includes": [ - "extension.neon" - ] } } } diff --git a/src/scout/extension.neon b/src/scout/extension.neon deleted file mode 100644 index 7c92ff5bf..000000000 --- a/src/scout/extension.neon +++ /dev/null @@ -1,3 +0,0 @@ -parameters: - eloquentBuilderMacros: - Hypervel\Scout\Searchable: Hypervel\Scout\PHPStan\SearchableMacros diff --git a/tests/PHPStan/BootstrapTest.php b/tests/PHPStan/BootstrapTest.php new file mode 100644 index 000000000..96ebdb197 --- /dev/null +++ b/tests/PHPStan/BootstrapTest.php @@ -0,0 +1,217 @@ +tempDir = ParallelTesting::tempDir('PHPStanBootstrapTest'); + (new Filesystem)->deleteDirectory($this->tempDir); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + public function testPackageAnalysisBootsTestbenchInEveryProcess(): void + { + $packagePath = $this->tempDir . '/package'; + mkdir($packagePath); + file_put_contents($packagePath . '/testbench.yaml', 'env: []'); + // The provider is declared only in the package's own Composer metadata, as an + // installing application would discover it. + file_put_contents($packagePath . '/composer.json', json_encode([ + 'name' => 'hypervel-phpstan/bootstrap-fixture', + 'extra' => ['hypervel' => ['providers' => [BootstrapFixtureServiceProvider::class]]], + ], JSON_THROW_ON_ERROR)); + + $process = $this->analyse($packagePath, ['AspectGreeter.php', 'ReplacedGreeter.php', 'Usage.php']); + + // Aspect targets report against their original source, and the class-map + // replacement supplies replacementOnly(). + $this->assertSame([ + 'AspectGreeter.php:22 return.type', + 'Usage.php:14 argument.type', + ], $this->diagnostics($process)); + + $evidence = $this->evidence(); + + // The main process and its workers each boot their own application. + $this->assertGreaterThanOrEqual(2, count($evidence)); + $this->assertCount(count($evidence), array_unique(array_column($evidence, 'pid'))); + + foreach ($evidence as $boot) { + $this->assertTrue($boot['booted']); + $this->assertTrue($boot['macro']); + $this->assertStringContainsString('/hypervel-components-testbench-', $boot['basePath']); + $this->assertDirectoryDoesNotExist($boot['basePath']); + } + } + + public function testApplicationAnalysisBootsTheApplication(): void + { + $applicationPath = $this->tempDir . '/application'; + (new Filesystem)->copyDirectory(__DIR__ . '/Fixtures/Bootstrap/application', $applicationPath); + mkdir($applicationPath . '/bootstrap/cache'); + + $process = $this->analyse($applicationPath, ['Usage.php']); + + $this->assertSame(['Usage.php:14 argument.type'], $this->diagnostics($process)); + + foreach ($this->evidence() as $boot) { + $this->assertTrue($boot['booted']); + $this->assertTrue($boot['macro']); + $this->assertSame($applicationPath, $boot['basePath']); + } + } + + public function testInvalidApplicationBootstrapFails(): void + { + $applicationPath = $this->tempDir . '/application'; + mkdir($applicationPath . '/bootstrap', 0777, true); + file_put_contents($applicationPath . '/bootstrap/app.php', 'runBootstrap($applicationPath); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringContainsString( + "The application bootstrap file [{$applicationPath}/bootstrap/app.php] must return the Hypervel application.", + $process->getErrorOutput(), + ); + } + + public function testBootFailureReportsTheRuntimeRequirement(): void + { + $packagePath = $this->tempDir . '/package'; + mkdir($packagePath); + + $process = $this->runBootstrap($packagePath, ['-d', 'swoole.use_shortname=On']); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringContainsString( + 'Swoole short function names must be disabled. Set swoole.use_shortname=Off in your php.ini.', + $process->getErrorOutput(), + ); + } + + /** + * Run the analyzer's bootstrap file from the working path. + * + * PHPStan only requires this file, so its failure handling needs no analysis run + * or container compilation. + * + * @param list $phpOptions + */ + protected function runBootstrap(string $workingPath, array $phpOptions = []): Process + { + $repositoryPath = dirname(__DIR__, 2); + + $process = new Process( + [PHP_BINARY, ...$phpOptions, '-r', 'require $argv[1]; require $argv[2];', $repositoryPath . '/vendor/autoload.php', $repositoryPath . '/src/phpstan/bootstrap.php'], + $workingPath, + timeout: 60, + ); + $process->run(); + + return $process; + } + + /** + * Run PHPStan from the working path over the given bootstrap fixtures. + * + * @param list $files + */ + protected function analyse(string $workingPath, array $files): Process + { + $repositoryPath = dirname(__DIR__, 2); + $fixturesPath = __DIR__ . '/Fixtures/Bootstrap'; + $paths = implode('', array_map( + static fn (string $file): string => "\n - {$fixturesPath}/{$file}", + $files, + )); + + file_put_contents($workingPath . '/phpstan.neon', << $this->tempDir . '/evidence.jsonl'], + timeout: 300, + ); + $process->run(); + + return $process; + } + + /** + * Get the file diagnostics as "file:line identifier" entries. + * + * @return list + */ + protected function diagnostics(Process $process): array + { + $this->assertSame(1, $process->getExitCode(), $process->getErrorOutput()); + + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame([], $result['errors']); + + $diagnostics = []; + + foreach ($result['files'] as $file => $errors) { + foreach ($errors['messages'] as $message) { + $diagnostics[] = basename($file) . ":{$message['line']} {$message['identifier']}"; + } + } + + sort($diagnostics); + + return $diagnostics; + } + + /** + * Get the boot evidence recorded by each PHPStan process. + * + * @return list + */ + protected function evidence(): array + { + $lines = file($this->tempDir . '/evidence.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + + $this->assertNotEmpty($lines); + + return array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + $lines, + ); + } +} diff --git a/tests/PHPStan/Fixtures/Bootstrap/AspectGreeter.php b/tests/PHPStan/Fixtures/Bootstrap/AspectGreeter.php new file mode 100644 index 000000000..48ccc3d11 --- /dev/null +++ b/tests/PHPStan/Fixtures/Bootstrap/AspectGreeter.php @@ -0,0 +1,24 @@ +aspects(GreetingAspect::class); + $this->classMap([ReplacedGreeter::class => __DIR__ . '/replacement/ReplacedGreeter.php']); + } + + /** + * Register the fixture macro. + */ + public function boot(): void + { + Str::macro('phpstanBootstrapFixture', static fn (): string => 'booted'); + } +} diff --git a/tests/PHPStan/Fixtures/Bootstrap/GreetingAspect.php b/tests/PHPStan/Fixtures/Bootstrap/GreetingAspect.php new file mode 100644 index 000000000..4f220838d --- /dev/null +++ b/tests/PHPStan/Fixtures/Bootstrap/GreetingAspect.php @@ -0,0 +1,21 @@ +process(); + } +} diff --git a/tests/PHPStan/Fixtures/Bootstrap/ReplacedGreeter.php b/tests/PHPStan/Fixtures/Bootstrap/ReplacedGreeter.php new file mode 100644 index 000000000..234354031 --- /dev/null +++ b/tests/PHPStan/Fixtures/Bootstrap/ReplacedGreeter.php @@ -0,0 +1,16 @@ +greet(42); + + return $replacedGreeter->replacementOnly(); + } +} diff --git a/tests/PHPStan/Fixtures/Bootstrap/application/bootstrap/app.php b/tests/PHPStan/Fixtures/Bootstrap/application/bootstrap/app.php new file mode 100644 index 000000000..bf6b4bcd0 --- /dev/null +++ b/tests/PHPStan/Fixtures/Bootstrap/application/bootstrap/app.php @@ -0,0 +1,10 @@ +withProviders([BootstrapFixtureServiceProvider::class]) + ->create(); diff --git a/tests/PHPStan/Fixtures/Bootstrap/evidence.php b/tests/PHPStan/Fixtures/Bootstrap/evidence.php new file mode 100644 index 000000000..088852dab --- /dev/null +++ b/tests/PHPStan/Fixtures/Bootstrap/evidence.php @@ -0,0 +1,22 @@ + getmypid(), + 'application' => $app instanceof Application, + 'basePath' => $app instanceof Application ? $app->basePath() : null, + 'booted' => $app instanceof Application && $app->isBooted(), + 'macro' => Str::hasMacro('phpstanBootstrapFixture'), + ], JSON_THROW_ON_ERROR) . PHP_EOL, + FILE_APPEND | LOCK_EX, +); diff --git a/tests/PHPStan/Fixtures/Bootstrap/replacement/ReplacedGreeter.php b/tests/PHPStan/Fixtures/Bootstrap/replacement/ReplacedGreeter.php new file mode 100644 index 000000000..fddde216b --- /dev/null +++ b/tests/PHPStan/Fixtures/Bootstrap/replacement/ReplacedGreeter.php @@ -0,0 +1,24 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/app-make.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/application-make.php'); + // REMOVED: container-array-access.php; container ArrayAccess is unsupported. + yield from self::gatherAssertTypes(__DIR__ . '/data/container-make.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Type/data/FailsAtRuntime.php b/tests/PHPStan/Type/data/FailsAtRuntime.php new file mode 100644 index 000000000..58f62399e --- /dev/null +++ b/tests/PHPStan/Type/data/FailsAtRuntime.php @@ -0,0 +1,13 @@ + $model */ +function test(Application $app, ApplicationContract $app2, string $model): void +{ + assertType('Hypervel\Config\Repository', $app->make(Repository::class)); + assertType('Hypervel\Config\Repository', $app->makeWith(Repository::class)); + assertType('Hypervel\Config\Repository', $app->resolve(Repository::class)); + assertType('Hypervel\Tests\PHPStan\Type\data\FailsAtRuntime', $app->resolve(FailsAtRuntime::class)); + + assertType('Hypervel\Config\Repository', $app2->make(Repository::class)); + // REMOVED: makeWith() and resolve() on the contract; they are concrete container + // methods, and contracts are not given implementation methods. + assertType('Hypervel\Tests\PHPStan\Type\data\FailsAtRuntime', $app2->make(FailsAtRuntime::class)); + + // Upstream asserts mixed here; Hypervel keeps the declared class-string result. + assertType('Hypervel\Database\Eloquent\Model', $app->make($model)); + assertType('Hypervel\Database\Eloquent\Model', $app->makeWith($model)); + assertType('Hypervel\Database\Eloquent\Model', $app->resolve($model)); +} diff --git a/tests/PHPStan/Type/data/container-make.php b/tests/PHPStan/Type/data/container-make.php new file mode 100644 index 000000000..10fd18fcc --- /dev/null +++ b/tests/PHPStan/Type/data/container-make.php @@ -0,0 +1,54 @@ + $model */ +function test( + Container $container, + ContainerContract $container2, + string $model, +): void { + assertType('Hypervel\Config\Repository', $container->make(Repository::class)); + assertType('Hypervel\Config\Repository', $container->makeWith(Repository::class)); + assertType('Hypervel\Config\Repository', $container->resolve(Repository::class)); + + assertType('Hypervel\Config\Repository', $container2->make(Repository::class)); + // REMOVED: makeWith() and resolve() on the contract; they are concrete container + // methods, and contracts are not given implementation methods. + + assertType('mixed', $container->make($foo)); + assertType('mixed', $container->makeWith($foo)); + assertType('mixed', $container->resolve($foo)); + + // Hypervel's other resolution methods. + assertType('Hypervel\Config\Repository', $container2->get(Repository::class)); + assertType('Hypervel\Config\Repository', $container2->makeTransient(Repository::class)); + assertType('Closure(): Hypervel\Config\Repository', $container2->factory(Repository::class)); + + // Service keys resolve to their bindings rather than to facade aliases with the same name. + assertType('Hypervel\Config\Repository', $container2->make('config')); + assertType('Hypervel\Http\Request', $container2->make('request')); + assertType('Closure(): Hypervel\Config\Repository', $container2->factory('config')); + assertType('mixed', $container2->make('eloquent')); + + // A class keeps its declared type when resolution fails or needs runtime context. + assertType('Hypervel\Contracts\Auth\Authenticatable', $container2->make(Authenticatable::class)); + assertType('Hypervel\Tests\PHPStan\Type\data\FailsAtRuntime', $container2->make('\Hypervel\Tests\PHPStan\Type\data\FailsAtRuntime')); + + // Unknown keys and scalar bindings are mixed, as the container declares. + assertType('mixed', $container2->make('not-bound')); + assertType('mixed', $container2->make('path')); + assertType('mixed', $container2->make(\MissingContainerClass::class)); + + assertType('Hypervel\Database\Eloquent\Model', $container2->make($model)); + assertType('Hypervel\Config\Repository', $container2->make(parameters: [], abstract: 'config')); + assertType('mixed', $container2->make(...['config'])); +} diff --git a/tests/PHPStan/Unit/Support/BootstrapErrorHandlerTest.php b/tests/PHPStan/Unit/Support/BootstrapErrorHandlerTest.php new file mode 100644 index 000000000..13e55ac58 --- /dev/null +++ b/tests/PHPStan/Unit/Support/BootstrapErrorHandlerTest.php @@ -0,0 +1,147 @@ +getProperty('file'); + $fileProperty->setValue($exception, getcwd() . '/app/Providers/AppServiceProvider.php'); + + $handler->handle($exception); + + $result = $output->fetch(); + + $this->assertStringContainsString('Application bootstrap failed', $result); + $this->assertStringContainsString('PHPStan was unable to bootstrap your application.', $result); + $this->assertStringContainsString('Check your environment variables in the .env file', $result); + $this->assertStringContainsString('Error: User code error', $result); + $this->assertStringContainsString('Stack trace:', $result); + } + + public function testHandleFrameworkError(): void + { + $output = new BufferedOutput; + $handler = new BootstrapErrorHandler($output); + + $exception = new Exception('Framework error'); + + // Mock getFile to return a path that looks like framework code (in vendor) + $reflection = new ReflectionClass($exception); + $fileProperty = $reflection->getProperty('file'); + $fileProperty->setValue($exception, getcwd() . '/vendor/hypervel/foundation/src/Application.php'); + + $handler->handle($exception); + + $result = $output->fetch(); + + // Upstream blames the framework for failures under /vendor/; Hypervel gives + // every failure the same guidance. + $this->assertStringContainsString('Application bootstrap failed', $result); + $this->assertStringContainsString('PHPStan was unable to bootstrap your application.', $result); + $this->assertStringContainsString('Check your environment variables in the .env file', $result); + $this->assertStringContainsString('Error: Framework error', $result); + $this->assertStringContainsString('Stack trace:', $result); + } + + public function testHandleTreatsErrorTextAsLiteral(): void + { + $output = new BufferedOutput; + $handler = new BootstrapErrorHandler($output, decorated: false); + + $handler->handle(new Exception('Missing config value')); + + $this->assertStringContainsString('Error: Missing config value', $output->fetch()); + } + + public function testStackTraceUsesRelativePaths(): void + { + $output = new BufferedOutput; + $handler = new BootstrapErrorHandler($output); + + $exception = new Exception('Path error'); + + $handler->handle($exception); + + $result = $output->fetch(); + + $projectRoot = getcwd(); + // The stack trace should not contain the absolute project root if getcwd() worked + $this->assertStringNotContainsString($projectRoot . '/', $result); + } + + public function testStackTracePreservesNamespaceSeparators(): void + { + $output = new BufferedOutput; + $handler = new BootstrapErrorHandler($output); + + $handler->handle(new Exception('Namespace error')); + + $result = $output->fetch(); + + $this->assertStringContainsString(self::class . '->testStackTracePreservesNamespaceSeparators()', $result); + $this->assertStringNotContainsString(str_replace('\\', '/', self::class), $result); + } + + public function testHandleNoAnsi(): void + { + $output = new BufferedOutput; + $handler = new BootstrapErrorHandler($output, decorated: false); + + $exception = new Exception('User code error'); + + // Mock getFile to return a path that looks like user code + $reflection = new ReflectionClass($exception); + $fileProperty = $reflection->getProperty('file'); + $fileProperty->setValue($exception, getcwd() . '/app/Providers/AppServiceProvider.php'); + + $handler->handle($exception); + + $result = $output->fetch(); + + $this->assertStringContainsString('Tip: Try the following', $result); + $this->assertStringNotContainsString("\u{1F4A1}", $result); + $this->assertFalse($output->isDecorated()); + } + + public function testHandleAnsi(): void + { + $output = new BufferedOutput; + $output->setDecorated(true); + $handler = new BootstrapErrorHandler($output, decorated: true); + + $exception = new Exception('User code error'); + + // Mock getFile to return a path that looks like user code + $reflection = new ReflectionClass($exception); + $fileProperty = $reflection->getProperty('file'); + $fileProperty->setValue($exception, getcwd() . '/app/Providers/AppServiceProvider.php'); + + $handler->handle($exception); + + $result = $output->fetch(); + + $this->assertStringContainsString("\u{1F4A1} Try the following", $result); + $this->assertStringNotContainsString('Tip: Try the following', $result); + $this->assertTrue($output->isDecorated()); + } +} diff --git a/tests/PHPStan/phpstan-tests.neon b/tests/PHPStan/phpstan-tests.neon new file mode 100644 index 000000000..b0eadea1d --- /dev/null +++ b/tests/PHPStan/phpstan-tests.neon @@ -0,0 +1,5 @@ +includes: + - ../../src/phpstan/extension.neon +parameters: + checkExplicitMixed: true + diff --git a/tests/PHPStan/phpunit-bootstrap.php b/tests/PHPStan/phpunit-bootstrap.php new file mode 100644 index 000000000..0cdbafc15 --- /dev/null +++ b/tests/PHPStan/phpunit-bootstrap.php @@ -0,0 +1,10 @@ + Date: Wed, 4 Nov 2026 14:37:18 +0000 Subject: [PATCH 016/303] Record PHPStan bootstrap design and remaining tracked-port work Update the implementation plan to describe the settled container inference, bootstrap diagnostics, analyzer lifecycle and Composer environment handling. Record completed foundational work and maintain one remaining-file checklist for the upstream port, framework-specific rules, integration fixtures and consumer rollout. Identify the next implementation slice without retaining superseded designs. --- .../2026-11-04-hypervel-phpstan-checklist.md | 72 +++++++++++++++++++ docs/plans/2026-11-04-hypervel-phpstan.md | 39 +++++++--- 2 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 docs/plans/2026-11-04-hypervel-phpstan-checklist.md diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md new file mode 100644 index 000000000..6ad0a2587 --- /dev/null +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -0,0 +1,72 @@ +# Hypervel PHPStan remaining-file checklist + +Working checklist for [2026-11-04-hypervel-phpstan.md](2026-11-04-hypervel-phpstan.md). Remove entries once handled. Paths are relative to `/home/binaryfire/workspace/references/larastan` unless they start with `src/` or `tests/` in this repository. + +Actions: **copy** (cp, read, adapt), **move** (mv existing Hypervel file), **merge** (combine into the named file), **add** (new Hypervel file), **omit** (record per the porting policy). + +Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/`; `stubs/` -> `src/phpstan/stubs/`; `tests/` -> `tests/PHPStan/`. + +## Package root + +- `extension.neon` services: restore upstream's `AbortIfFunctionTypeSpecifyingExtension` (abort/throw), `AppEnvironmentReturnTypeExtension`, `CheckDispatchArgumentTypesCompatibleWithClassConstructorRule` (bus/events `Dispatchable`), `Filesystem` and `migrationsParser` entries with their classes; omit the container ArrayAccess entries +- `extension.neon` and Components integration: set `reportIgnoresWithoutComments: true`; add `phpstan-deprecation-rules` includes to `phpstan.neon.dist`/`phpstan.types.neon.dist` +- omit: `phpcs.xml.dist`, `phpstan.neon.dist`, `phpstan-baseline.neon`, `phpunit.xml.dist`, `phpunit-bootstrap.php`, `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, `RELEASE.md`, `UPGRADE.md`, `assets/` (repository tooling; Components owns its own) +- merge into `src/docs/phpstan.md`: `docs/custom-config-parameters.md`, `docs/custom-types.md`, `docs/errors-to-ignore.md`, `docs/features.md`, `docs/rules.md` +- omit: `e2e/` Laravel project baselines (replaced by the plan's real-project checks) + +## Existing Hypervel extensions + +- merge: `src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php` (moved from Foundation) -> `ReturnTypes/Helpers/{ResponseExtension,ValidatorExtension}.php` + +## Runtime prerequisites + +- `src/bus/src/PendingChain.php`, `src/bus/src/Queueable.php`, `src/bus/src/ChainedBatch.php`, `src/support/src/Testing/Fakes/{QueueFake,PendingChainFake}.php` and their existing tests (chain/fake precedence) + +## src + +- copy: `LarastanStubFilesExtension.php` +- copy: `ClosureTypes/RelationshipQueryCallbackExtension.php` +- copy: `Collectors/*` (12 files) +- copy: `Concerns/{InspectsQueuedJobs,LoadsAuthModel}.php` +- copy: `Internal/{ConfigHelper,ConsoleApplicationHelper,ConsoleApplicationResolver,RecursionGuard}.php` +- omit: `Internal/ComposerHelper.php` (`Support\Composer::vendorPath()` resolves the actual install) +- copy: `Methods/{AuthsMethodsExtension,BuilderHelper,FacadesMethodsExtension,HigherOrderCollectionProxyExtension,HigherOrderTapProxyExtension,Macro,MacroMethodsClassReflectionExtension,ManagersMethodsExtension,ModelFactoryMethodsClassReflectionExtension,RedirectResponseMethodsClassReflectionExtension,StorageMethodsClassReflectionExtension,ViewWithMethodsClassReflectionExtension}.php` +- merge into moved forwarding extensions: `Methods/{EloquentBuilderForwardsCallsExtension,ModelForwardsCallsExtension,RelationForwardsCallsExtension}.php` +- omit: `Methods/ContractsMethodsExtension.php` (interfaces stay honest) +- copy: `Properties/*` (17 files, including `Schema/MySqlDataTypeToPhpTypeConverter.php`) +- copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection,ReflectionHelper,StaticMethodReflection}.php` +- merge into moved scope reflections: `Reflection/ModelScopeMethodReflection.php` +- copy: `ReturnTypes/*` except below and the ported container typing files (46 files, including `ConsoleCommand/` and `Helpers/`) +- omit: `ReturnTypes/ContainerArrayAccessDynamicMethodReturnTypeExtension.php` (container ArrayAccess is unsupported), `ReturnTypes/ApplicationMakeDynamicReturnTypeExtension.php` (the container extension already covers the Application contract) +- copy: `Rules/*` except below (28 files, including `Queue/` and `UselessConstructs/`) +- omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` +- copy: `SQL/*` (7 files) +- copy: `Support/{ArrKeyLookup,CollectionHelper,ConfigParser,HigherOrderCollectionProxyHelper,RecursiveDirectoryIterator,ViewFileHelper,ViewParser}.php` +- copy: `Types/*` (15 files) + +## New Hypervel source + +- add: `Rules/Coroutine/` cancellation, worker-lifetime, static-state, unbounded-cache, runtime-config, transaction-coroutine and raw-sleep rules +- add: `Rules/` legacy model scope and legacy model property rules; `Rules/Queue/NoLegacyQueuePropertiesRule.php` +- add: `Rules/Validation/` pipe-separated validation rule +- add: PostgreSQL and SQLite schema parser adapters under `SQL/` + +## stubs + +- copy: `common/*` (89 files) except below +- omit: `common/Redis/{PredisConnection,PredisFacade}.stub` +- merge into `common/`: `12.20.0/Enumerable.stub`, `12.41.0/Http/PendingRequest.stub`, `13.3.0/{EloquentBuilder,Model}.stub`, `13.35.0/{BelongsToMany,HasOneOrMany}.stub`, `13.37.0/HasOneOrManyThrough.stub` + +## tests + +- copy: `bootstrap.php`, `enum-definition.php`, then add the test bootstrap to `tests/PHPStan/phpstan-tests.neon`; omit `laravel-test.sh` +- copy: `application/` fixture app (138 files: app, config, factories, migrations, schema dump, lang, views) +- copy: `Integration/{IntegrationTest,BleedingEdgeIntegrationTest}.php` with `Integration/data/*` (49 files) +- copy: `Reflection/{MacroMethodsClassReflectionExtensionTest,RedirectResponseMethodsClassReflectionExtensionTest}.php` +- copy: `Rules/*Test.php` except below (26 classes), `Rules/phpstan-rules.neon`, `Rules/data/*`, `Rules/Queue/data/*`, `Rules/UselessConstructs/data/*` +- omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` +- copy: `Type/*Test.php` except below and `GeneralTypeTest.php` (11 classes) with the remaining `Type/data/*` (153 files) +- merge: the remaining `Type/GeneralTypeTest.php` data entries, and its `data/config-with-migrations.neon` configuration once migrations are ported +- merge: `Type/{RedisDriverNullTypeTest,RedisDriverPhpRedisTypeTest}.php` and their data into one phpredis test; omit `Type/RedisDriverPredisTypeTest.php` with `redis-driver-predis.*` +- omit: `Type/data/container-array-access.php`; investigate `Type/data/contracts.php` +- copy: `Unit/*Test.php` except `Support/BootstrapErrorHandlerTest.php` (12 classes, including `SQL/` and `Support/`), `Unit/Support/BrokenRewindDirectoryStream.php`, `Unit/data/*` diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 6683f23b5..781554d10 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -10,8 +10,10 @@ This document specifies the final design and implementation work. Read it, the r Remaining work: -- The root, Database, Foundation and base skeleton require PHPStan `^2.3.5`; regression ignores, corrected split types and the DataTypeFactory parent-resolution fix are complete. Apply the same minimum to the new analyzer manifest when creating it. -- Complete the implementation and verification sections below, keeping a single remaining-file checklist during porting. +- Completed: PHPStan `^2.3.5` minimum (root, base skeleton, analyzer manifest; Database and Foundation no longer declare PHPStan), regression ignores, corrected split types, the DataTypeFactory parent-resolution fix, package metadata and analyzer test harness, moved extensions, and bootstrap/error reporting with `tests/PHPStan/BootstrapTest.php`. +- Also completed: the package-mode boot and Composer-install fixes, container typing (Bootstrap and runtime discovery) with the source typing fixes it exposed, and the initial `GeneralTypeTest`. +- Next: port the checklist's remaining `src/` files alphabetically with their tests, beginning with `ClosureTypes/` and `Collectors/`. +- Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -143,7 +145,7 @@ PHPStan documentation: [extension testing](https://phpstan.org/developing-extens 2. Preserve the upstream package-root layout: `bootstrap.php`, `extension.neon`, `src/`, and necessary `stubs/`. Use existing `Methods`, `Properties`, `Reflection`, `ReturnTypes`, `Rules`, `Support`, `Types`, `Collectors`, `SQL` and `Concerns` directories. Put new model rules at `Rules/` root beside upstream's model rules; use `Rules/Coroutine` and `Rules/Validation` for those genuinely new domains. Do not introduce separate engines for Hypervel additions. 3. Declare direct production dependencies of the analyzer in its own manifest, even though consumers install the analyzer with `--dev`. Require `phpstan/phpstan-deprecation-rules`: included deprecation reporting is part of the supported installation contract, not an unrelated suggested tool. Check the latest compatible stable release on Packagist before adding it; the verified 2.0.5 release requires PHPStan `^2.1.39`, compatible with the planned analyzer range. Translate required Illuminate components to their actual Hypervel owners. Do not depend on `hypervel/components`, the entire framework metapackage, Head, Scout, private packages or every optional integration. Suggest `hypervel/testbench` for package analysis. Guard optional integrations using existing reflection/stub-extension mechanisms; preserve an absent-component installation test. 4. Add root autoloading and `replace` metadata for `hypervel/phpstan`. The root replaces the split package, so it must declare the analyzer's external dependencies itself; add `phpstan/phpstan-deprecation-rules` to root `require-dev` with `composer require --dev` at the selected compatible constraint. Use Composer commands for other root dependency changes; subpackage manifests without lockfiles may be edited directly. Do not add a runtime service provider just to register PHPStan services. Register them in PHPStan's container. -5. Require PHPStan `^2.3.5` in the root, Database, Foundation and new analyzer manifests, retaining current Larastan's supported APIs and DI registration. Use local identifier-specific ignores with adjacent code-comment `@TODO`s for confirmed PHPStan regressions: generic narrowing in `DataCollectionEloquentCast::set()` and factory native-return generics in Collection's `split()`/`chunkWhile()`. Do not add NEON ignores or `docs/todo.md` entries for them, alter correct runtime code/types, or add compatibility machinery for 2.3.1. Inspect new diagnostics individually and fix real defects before suppressing confirmed false positives. Check compatible stable versions for every added dependency, including `iamcal/sql-parser`, optional phpMyAdmin support and any other SQL parser. +5. Require PHPStan `^2.3.5` in the root and analyzer manifests (Database and Foundation no longer use PHPStan directly once their extensions move), retaining current Larastan's supported APIs and DI registration. Use local identifier-specific ignores with adjacent code-comment `@TODO`s for confirmed PHPStan regressions: generic narrowing in `DataCollectionEloquentCast::set()` and factory native-return generics in Collection's `split()`/`chunkWhile()`. Do not add NEON ignores or `docs/todo.md` entries for them, alter correct runtime code/types, or add compatibility machinery for 2.3.1. Inspect new diagnostics individually and fix real defects before suppressing confirmed false positives. Check compatible stable versions for every added dependency, including `iamcal/sql-parser`, optional phpMyAdmin support and any other SQL parser. 6. Components has no extension installer: in both `phpstan.neon.dist` and `phpstan.types.neon.dist`, replace the four old includes with explicit `src/phpstan/extension.neon` and `vendor/phpstan/phpstan-deprecation-rules/rules.neon` includes, and update type-fixture scan directories. Check each skeleton/private consumer's actual installer and plugin configuration before choosing its loading mode; use discovery or an explicit include for each extension without duplication. Keep source analysis and the dedicated `types/` configuration distinct; do not run normal source analysis over `tests/`. 7. Update the base skeleton, other maintained skeletons and Testbench package examples to install `hypervel/phpstan` as a dev dependency and use the standard setup. Inspect each current manifest and analysis script; preserve existing additional Carbon/Swoole support where still necessary. 8. `bin/split.sh` discovers packages from manifests. Ensure the new package passes existing publishing checks; do not introduce a second publishing system. Prepare the split repository/Packagist setup instructions. Creating external repositories, publishing and pushing require owner authorization at the actual publication step. @@ -183,13 +185,26 @@ Remove each old `src/{database,foundation,head,scout}/extension.neon`, old `extr ## Bootstrap and runtime discovery - Port upstream app discovery and error reporting. Honor an explicitly bootstrapped application where supported, ordinary `bootstrap/app.php`, Composer's actual vendor directory, and the package/Testbench path. Do not hardcode this worktree or the Components root into the published package. -- For package mode, use Hypervel's `Foundation\Config::loadFromYaml()` and `Foundation\Application::createFromConfig()`/existing bootstrap APIs. Preserve providers, aliases, config, root package discovery, workbench configuration and the package's real Composer autoloader. Hypervel's YAML key is `hypervel`, not `laravel`. Reuse Testbench's disposable runtime and vendor-link actions; do not copy Larastan's obsolete manual symlink fallback or create a second skeleton lifecycle. +- For package mode, `ApplicationResolver` calls `Bootstrapper::bootstrap()` and boots `Testbench\Foundation\Application::create()` with the YAML's extra attributes and `discovers_root_package: true`. `createFromConfig()` would boot the raw configured `hypervel` path instead of Bootstrapper's disposable copy. Preserve providers, aliases, config, root package discovery, workbench configuration and the package's real Composer autoloader. Hypervel's YAML key is `hypervel`, not `laravel`. Reuse Testbench's disposable runtime and vendor-link actions; do not copy Larastan's obsolete manual symlink fallback or create a second skeleton lifecycle. Do not force `enables_package_discoveries` (the YAML's `dont-discover` decides), define `TESTBENCH_CORE` (CLI mode boots a configured skeleton directly) or set `TESTBENCH_PACKAGE_TESTER` (it copies `.env` and changes `APP_ENV` handling). - Boot once per PHPStan process, not per file or model. Parallel analysis boots independently in worker processes. Do not start HTTP/queue workers, run migrations, or probe backend services merely for analysis. Provider code can itself perform I/O; do not hide real boot failures or claim boot is free of side effects. -- Retain `BootstrapErrorHandler`'s clear CLI failure and nonzero exit. Adapt optional renderer integrations to supported Hypervel packages; do not add Whoops solely to preserve an unsupported Laravel integration. Do not continue with an empty container after a failed boot. +- Retain `BootstrapErrorHandler`'s clear CLI failure and nonzero exit. Adapt optional renderer integrations to supported Hypervel packages; do not add Whoops solely to preserve an unsupported Laravel integration. Do not continue with an empty container after a failed boot. Every failure gets one neutral title and tip list with the real exception and trace (upstream's `/vendor/` substring classification misreports custom vendor directories and path repositories), and exception text is escaped as literal console text. `bootstrap.php` also fails through the handler when there is no `bootstrap/app.php` and Testbench is absent (install message), or when `bootstrap/app.php` returns a non-Application; the consumer-installation fixture covers the missing-Testbench case. - Document the actual CLI boot requirements: Swoole (not OpenSwoole), `swoole.use_shortname=Off`, and Swoole built with OpenSSL and native cURL support. `Application::checkEnvironment()` already throws specific corrective messages. Preserve those messages through `BootstrapErrorHandler` and test that they remain visible; do not duplicate the checks or add exception-message matching to manufacture another tip. - Preserve runtime macro/reflection discovery, facade resolution, class-string/string-key container returns, configured authentication user types and console argument/option definitions. Keep interfaces honest: an arbitrary interface variable does not acquire implementation-only methods merely because the container currently binds one implementation. +- **Container typing.** Boot registers facade aliases eagerly (`RegisterFacades`, unchanged), and PHP class names are case-insensitive, so keys such as `'config'` and `'request'` become class-strings that the declared `class-string` returns would type as facades. `AppMakeHelper` types constant keys from the booted container for `make`/`makeWith`/`makeTransient`/`get`/`resolve`/`factory` on the container contract (which also covers the Application contract, so upstream's `ApplicationMakeDynamicReturnTypeExtension` is omitted), the same `App` facade methods except `resolve`, and `app()`/`resolve()`. `factory()` wraps the result as `Closure(): T`. A non-constant or null abstract returns null, so the declared return applies (generic class-strings keep `TClass`; `app()`/`app(null)` stay `Application`). When resolution fails or returns a non-object, a key keeps its class type only when it names that class exactly (ignoring a leading `\`); otherwise, including unknown keys, unknown class literals, scalar bindings and alias keys, the result is `mixed`. An unpacked first argument is `mixed`: PHPStan reorders named arguments for extensions but does not expand unpacked ones. The source fixes this exposed keep public APIs except one recorded Laravel difference: `CacheTokenRepository` accepts the cache contract that `CacheManager::store()` returns (auth README). - Before changing AOP behavior, add a focused app/package subprocess fixture with an aspect, a class-map replacement, a provider registering a macro, and parallel PHPStan workers. Check original source diagnostics, inferred signatures and registration behavior. `GenerateProxies` seals registries and exposes `proxySources()`; `ProxyManager` reuses fingerprints and writes atomically. Parallel writes alone do not justify bypassing it. Preserve ordinary boot unless the fixture proves a conflict. Resolve a proven source-lookup conflict using the existing original-source mapping and PHPStan's supported facilities; no blanket disabling of aspects, custom AST rewriting, or speculative alternative class loader. If a supported API cannot preserve both semantics, bring the concrete failure and simplest alternatives to the owner before a capability change. +### Package-mode boot and Composer installs + +These Testbench, Support and Foundation fixes make package analysis and Testbench itself honor the package's own metadata and actual Composer install: + +- **Root package discovery.** Add a `discovers_root_package` option (default `false`) to `Testbench\Foundation\Application::configure()` with a getter. The Testbench `PackageManifest`'s effective decision is CLI, package tester or that option. A child borrowing its parent's runtime (`Bootstrapper::isRuntimeCopy($base) && ! Bootstrapper::ownsRuntimePath($base)`) uses the parent's decision instead of its own CLI identity: `RemoteCommand` passes it per child like `TESTBENCH_RUNTIME_COPY`, as an `Env::encode()`d boolean only when borrowing (root-off stays an explicit false; non-borrowing and `serve` children get `false`, which unsets it). Without this, `remote('package:discover')` or a rebuild after `clear-compiled` would apply the child's policy to the shared worker manifest; Components' root declares 64 providers. +- **One root normalization.** Add one protected Foundation `PackageManifest` step applied to the raw dependency manifest at `build()` and at cached load; Foundation's default returns it unchanged. Testbench overrides it to add or remove the root entry (the full `extra.hypervel` block) for the effective decision, before ignore filtering, and writes the file only when the loaded root entry differs (including changed root metadata). This replaces the `write()` override, fixes the explicit-`build()` result that omitted the root from the same instance's `providers()`/`aliases()`, and keeps the file correct for plain Foundation readers: the skeleton `bootstrap/app.php` has no manifest swap, and `config:cache`/`route:cache` run its `artisan`. Cache the decoded root metadata lazily per instance; never persist a filtered result. Composer requires `name` only for published packages, so an unnamed root has no entry unless root discovery is on and it declares `extra.hypervel` metadata, which then requires a name; explicit empty names and malformed metadata still fail. +- **Manifest path.** `RefreshPackageDiscovery` deletes its manifest instance's `manifestPath`, honoring `APP_PACKAGES_CACHE`. +- **Vendor directory.** Add `Hypervel\Support\Composer::vendorPath(string $rootPath): string`: `COMPOSER_VENDOR_DIR` first, as Laravel's `PackageManifest` honors it (a relative value resolves against the root), else `{root}/vendor` when its realpath is a registered loader (no `installed.php` read), else the `ClassLoader::getRegisteredLoaders()` entry whose `{vendorDir}/composer/installed.php` root `install_path` realpath equals the root (as `InstalledVersions::getInstalled()` reads them), else `{root}/vendor`. Never select an unrelated first loader. A relative `COMPOSER_VENDOR_DIR` becomes its absolute path against the package root at two points: `Bootstrapper::bootstrap()` rewrites an inherited process value in `putenv`/`$_ENV`/`$_SERVER`, as `ServeCommand` exports `TESTBENCH_WORKING_PATH`; `CreatesApplication::createApplication()` rewrites one loaded from `.env`, YAML `env` or `#[WithEnv]` through the Env repository right after `resolveApplicationEnvironmentVariables()`, keeping that loader's putenv setting and restoration (covers TestCase, Commander and standalone/analyzer applications). Plain-skeleton children (`config:cache`/`route:cache`, `queue:listen`, Horizon, composer-package installers, Tinker) run a runtime copy's or custom skeleton's `artisan` and would otherwise resolve it against their own base path. No per-command vendor handling or skeleton-file hook. Every supported entry point loads the package's own install, so this covers project/global config and install-time env without parsing Composer configuration; runtime root metadata stays on `composer.json`, with no `COMPOSER` file-name handling. Use it in Foundation `PackageManifest` (application base), `TestStateRegistrars::forRootInstall()`, Bootstrapper's runtime vendor link, `UsesVendor`, `Commander` and the `hypervel_vendor_exists()` default. The Testbench `PackageManifest` uses `{basePath}/vendor` (the runtime link Bootstrapper selected, or a configured skeleton's own link); it must not resolve a package-relative env value against the clone. `ConfigCacheCommand`/`RouteCacheCommand` subprocess autoload and `TinkerCommand`'s classmap use the application's `PackageManifest::$vendorPath`; check the history of their remaining `$componentRoot` candidates and remove dead ones. Keep the existing skeleton vendor-link precedence and cleanup. Larastan's `Internal\ComposerHelper` is omitted: this resolver owns that job. +- **Remote binary.** `remote()` runs `testbench_path('bin', 'testbench')` with `HYPERVEL_AUTOLOAD_PATH ??= Composer::vendorPath(package_path()) . '/autoload.php'`, preserving caller overrides. This replaces the `vendor/bin`, source-binary and PATH candidates, and works for custom vendor/bin directories, direct PHPUnit/ParaTest runners and path-repository installs (running the real script alone would load Components' autoloader through symlink-resolved `__DIR__`). `bin/testbench` loads `$_composer_autoload_path`, then `HYPERVEL_AUTOLOAD_PATH`, then its checkout fallbacks; correct the split-checkout fallback to `__DIR__ . '/../vendor/autoload.php'`. +- **Skeleton autoload entrypoint.** Move the skeleton `artisan`'s existing autoload lookup (`HYPERVEL_AUTOLOAD_PATH`, `{base}/vendor`, `getcwd()`, `TESTBENCH_WORKING_PATH`) into `bootstrap/autoload.php`, which currently has no callers, and require it from `artisan` as Orchestra does. Keep the precedence, missing-autoloader message and exit status; call no Support helpers before autoloading. The application skeleton's own `artisan` stays application-owned. +- **Tests.** Focused `Composer::vendorPath()` tests (absolute/relative env, a temporarily registered loader with its own `installed.php`, no match) plus child-process assertions for an inherited value (`BootstrapperTest`) and a YAML `env` value after application creation (Testbench `ApplicationTest`). Extend the existing Testbench manifest tests: cached file with and without the root entry under the option on/off, also read through a plain Foundation `PackageManifest`; explicit `build()` then the same object's `providers()`/`aliases()`; named/wildcard ignores on the root entry; a borrowing child's explicit build and missing-file rebuild under owner root-on/off; a cached borrowed read leaving the file unchanged. Add a `BootstrapTest` case with providers only in the package `composer.json`. Use the consumer-installation fixture with `vendor-dir: libs` and a custom `bin-dir`, including the path-repository setup, for main/worker discovery, relative `COMPOSER_VENDOR_DIR` through a real Testbench boot, `remote()` and a skeleton `artisan` child (`config:cache`), and package analysis without Testbench; keep existing skeleton-link ownership/cleanup tests. + ## Type inference and stubs Port every applicable upstream inference feature, not just the rules in the next section. Audit `Methods`, `Properties`, `ReturnTypes`, `ClosureTypes`, `Reflection`, `Types`, collectors and stub files against the corresponding Hypervel API. @@ -259,7 +274,7 @@ Copy retained rule files first, then adapt them. Retain their relevant toggles a | `ConsoleCommand/UndefinedArgumentOrOptionRule` | Use booted command definitions, including signatures and inherited/configured definitions. | | `ModelAppendsRule` | Support `#[Appends]` and currently supported property declarations. A database-backed attribute can be appended with a getter/class cast. Distinguish readable accessors from setter-only `Attribute` definitions; preserve custom serialization. | | `ModelRelationDefaultsRule` | Validate `$with`/`$withCount` against real relations, nested paths, aliases and inherited definitions. | -| `NoEnvCallsOutsideOfConfigRule` | Retain the policy with actual package config paths and narrowly justified framework/bootstrap uses. Do not claim outside-config calls always return null. | +| `NoEnvCallsOutsideOfConfigRule` | Retain the policy with actual package config paths and narrowly justified framework/bootstrap uses. Do not claim outside-config calls always return null. Upstream's bare `str_starts_with()` lets `/app/config` permit `/app/config-sibling/x.php`; match the normalized directory itself or a path beneath it, and cover the sibling-prefix case in the existing test. | | `NoMissingTranslationsRule` | Reuse the booted translator, existing usage collectors and resource dependencies; do not rebuild lookup in a file scanner. Restrict default diagnostics to literal structured keys with a known group source and known standard file-backed resolution. See the translation predicate below. | | `NoModelMakeRule` | Use the conservative declaration/runtime-metadata exclusions below, without interpreting arbitrary model boot code. Unknown or customized construction stays silent. | | `NoPublicModelScopeAndAccessorRule` | Apply to the real Hypervel attributed-scope/accessor surface. Coordinate with the legacy-scope rule to avoid duplicate errors. Preserve inherited visibility and valid native methods. | @@ -384,9 +399,13 @@ Retain PHPStan's autofix integration for verified local transformations, includi ### Harness and fixture ownership -Port upstream rule/type/reflection/unit/integration tests under `tests/PHPStan`, preserving relative upstream placement and all applicable cases. Use PHPStan's `RuleTestCase` and `TypeInferenceTestCase` for rule and type-inference tests, and `PHPStanTestCase` for other PHPStan tests needing its service container, as listed in `AGENTS.md` under Base classes. These analysis tests do not need Hypervel's coroutine wrapper or a custom container harness. Runtime integration tests retain the appropriate Hypervel base class. +Port upstream rule/type/reflection/unit/integration tests under `tests/PHPStan`, preserving relative upstream placement and all applicable cases. Use PHPStan's `RuleTestCase` and `TypeInferenceTestCase` for rule and type-inference tests, and `PHPStanTestCase` for other PHPStan tests needing its service container, as listed in `AGENTS.md` under Base classes. These analysis tests do not need Hypervel's coroutine wrapper or a custom container harness. -Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime instead of modifying its committed skeleton or always-loading package routes. Keep existing `types/` fixtures as public typing regressions and update moved references. +`tests/PHPStan` runs as its own suite through root `phpunit.phpstan.xml.dist`, excluded from `phpunit.xml.dist`. PHPStan `require_once`s bootstrap files once per cached container, while `AfterEachTestExtension` clears the container instance, facades and macros after every test, so the analyzer app would vanish after the first test (and before later files' data providers in a reused ParaTest worker). The analyzer config omits that extension and keeps the main config's applicable failure/timeout settings. Add `composer test:phpstan` (ParaTest with that config) to the `check`/`fix` scripts and `tests.yml`; run one class with `vendor/bin/phpunit -c phpunit.phpstan.xml.dist --no-progress `. Do not add reboot subscribers, cleanup exemptions or a container simulator. + +The process keeps one shared analyzer app. Its PHPUnit bootstrap, `tests/PHPStan/phpunit-bootstrap.php`, loads the extension's bootstrap before any test, so the app's error and exception handlers predate PHPUnit's per-test handler checks (`failOnRisky`); unit-style analyzer tests extend `Hypervel\Tests\PHPStan\TestCase`, which keeps those handlers rather than flushing them through `HandleExceptions::flushState()`. Tests that deliberately change its config, paths, bindings or macros restore their own changes; no blanket flush. Do not run Hypervel Testbench test cases in this process: their teardown flushes the shared app. Boot, cache and install checks run as subprocess fixtures with their own bootstrap/autoload; source runtime fixes stay in their owning framework suites. Every `setUp()` override calls the PHPStan parent initializer. Drop upstream `NoEnvCallsOutsideOfConfigRuleTest::setUp()`/`overrideConfigPath()`: it skips that initializer and permanently changes the app config path, which its explicit `configDirectories` never consume. Verify multiple tests and files in one reused process with existing macro/container cases (a focused ParaTest `-p1` run); no generic lifecycle test framework. + +Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime for its assets instead of modifying its committed skeleton, writing into vendor, or always-loading package routes. Keep upstream's `App\`, `Database\Factories\` and `Database\Migrations\` namespaces: `Factory::resolveFactoryName()` derives factories from the app namespace that `Application::getNamespace()` reads from the Testbench skeleton (`App\`). Register those prefixes on Composer's existing loader from `tests/PHPStan/phpunit-bootstrap.php` before PHPStan reflection starts; PHPStan's `AutoloadSourceLocator` resolves them through `ClassLoader::findFile()`. No root `autoload-dev` mapping or custom autoloader. Keep existing `types/` fixtures as public typing regressions and update moved references. Keep type fixtures that intentionally exercise still-supported APIs. In `phpstan.types.neon.dist`, set `noLegacyModelScopes`, `noLegacyModelProperties`, `noLegacyQueueProperties` and `noPipeSeparatedValidationRules` to `false`, with one comment that these fixtures assert supported types rather than authoring policy. Also set `checkModelMethodVisibility` and `noUnnecessaryEnumerableToArrayCalls` to `false` in this harness: `CustomBuilderForwarding.php` deliberately declares public scopes, and Collection/LazyCollection fixtures assert conversion return types. These preferences retain their normal defaults in source/consumer analysis and their dedicated `RuleTestCase` coverage. Preserve type assertions; do not delete compatibility cases. For upstream always-registered diagnostics without a switch, use a local identifier suppression with a fixture-specific reason only at the intentional call (for example the existing `value('foo', 42)` and `with(new User)` helper fixtures). Do not invent public switches or a second rule-registration system solely for the harness. @@ -394,8 +413,6 @@ Exclude mirrored analyzer `data/` directories and any intentionally preserved fi Do not add repository-housekeeping rules for test method title docblocks or missing test return types to this reusable package. General `tests/` analysis remains excluded; the separate formatting/convention work in the todo is unchanged. Follow the current conventions for all tests touched by this port. -PHPStan's DI container may live across tests while Hypervel cleanup flushes framework globals. Verify setup/teardown ordering and reset only package-owned static/runtime discovery caches that actually persist; use the standard cleanup registrar/subscriber. Do not blindly replace every upstream test base or require analyzer rule fixtures to execute inside coroutines. Framework runtime integration tests continue using the appropriate Hypervel base class. - ### Work order 1. Inventory all upstream source, stubs, tests and current Hypervel extensions. Make one remaining-file checklist recording copy, move, merge or justified omission; do not duplicate it in this plan. Set up metadata, dependencies and the test harness. @@ -419,7 +436,7 @@ PHPStan's DI container may live across tests while Hypervel cleanup flushes fram | Runtime behavior | Focused existing framework tests for every source typing/correctness change. No weakened contracts or runtime casts/branches solely for PHPStan. | | Real projects | Components, base/maintained skeletons, both private monopackages and affected private apps. Adapt upstream's e2e workflow; public CI uses public/sanitized fixtures. Private sources and their baselines are never copied into the public repository. | -Use bounded sequential commands from the correct repository directory. Run `./vendor/bin/phpunit --no-progress ` immediately after each test file. For multiple affected framework classes use `composer test:parallel -- --processes=4` with relevant selectors. Run `composer test:testbench` if Testbench changes. At a coherent completion boundary run `composer analyse` (source, `types/`, skeleton checks); it requires the skeleton's own dependencies. Run `composer lint:fix` before scheduled review. Do not run PHPStan on general `tests/` or pass source/test paths to `phpstan.types.neon.dist`. Keep parallelism inside the chosen tool, not overlapping verification commands. Leave full matrices to CI unless a concrete unresolved issue requires them locally. +Use bounded sequential commands from the correct repository directory. Run `./vendor/bin/phpunit --no-progress ` (analyzer tests: `-c phpunit.phpstan.xml.dist`) immediately after each test file; use `composer test:phpstan` for multiple analyzer files. For multiple affected framework classes use `composer test:parallel -- --processes=4` with relevant selectors. Run `composer test:testbench` if Testbench changes. At a coherent completion boundary run `composer analyse` (source, `types/`, skeleton checks); it requires the skeleton's own dependencies. Run `composer lint:fix` before scheduled review. Do not run PHPStan on general `tests/` or pass source/test paths to `phpstan.types.neon.dist`. Keep parallelism inside the chosen tool, not overlapping verification commands. Leave full matrices to CI unless a concrete unresolved issue requires them locally. For e2e baselines, preserve known intentional analyzer limitations with reasons and inspect baseline changes. Do not treat adding a new baseline entry as a fix for a demonstrated source defect. New Hypervel regression fixtures should have exact expected results. From f2656b657c12d4f21651d9ced574b505e6fa504a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:48:20 +0000 Subject: [PATCH 017/303] Remove redundant analyzer bootstrap comments Remove branch labels that repeat the bootstrap conditions and test narration that repeats the exception-file setup. Retain the comments explaining runtime discovery, neutral error reporting and analyzer lifecycle constraints. Normalize the application resolver's spelling without changing behavior. --- src/phpstan/bootstrap.php | 4 ++-- src/phpstan/src/ApplicationResolver.php | 2 +- tests/PHPStan/Unit/Support/BootstrapErrorHandlerTest.php | 4 ---- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/phpstan/bootstrap.php b/src/phpstan/bootstrap.php index ed81621d8..ff168cf25 100644 --- a/src/phpstan/bootstrap.php +++ b/src/phpstan/bootstrap.php @@ -11,14 +11,14 @@ try { $applicationPath = getcwd() . '/bootstrap/app.php'; - if (file_exists($applicationPath)) { // Applications and Local Dev + if (file_exists($applicationPath)) { $app = require $applicationPath; } else { // Composer's root package path also covers custom vendor directories. $applicationPath = InstalledVersions::getRootPackage()['install_path'] . '/bootstrap/app.php'; if (file_exists($applicationPath)) { $app = require $applicationPath; - } elseif (class_exists(Bootstrapper::class)) { // Packages + } elseif (class_exists(Bootstrapper::class)) { $app = ApplicationResolver::resolve(); } else { // Upstream continues without an application, which silently drops every diff --git a/src/phpstan/src/ApplicationResolver.php b/src/phpstan/src/ApplicationResolver.php index 99d76db4d..6b451ca55 100644 --- a/src/phpstan/src/ApplicationResolver.php +++ b/src/phpstan/src/ApplicationResolver.php @@ -42,7 +42,7 @@ public static function resolve(): Application $config = Bootstrapper::getConfiguration(); // Boot the disposable runtime copy rather than a configured skeleton, so - // analysis never writes caches into committed files. The analysed package's + // analysis never writes caches into committed files. The analyzed package's // own providers and aliases are discovered like an installing application's; // the configuration's dont-discover list still decides package discovery. return Testbench::create(options: [ diff --git a/tests/PHPStan/Unit/Support/BootstrapErrorHandlerTest.php b/tests/PHPStan/Unit/Support/BootstrapErrorHandlerTest.php index 13e55ac58..7f8bc8958 100644 --- a/tests/PHPStan/Unit/Support/BootstrapErrorHandlerTest.php +++ b/tests/PHPStan/Unit/Support/BootstrapErrorHandlerTest.php @@ -22,7 +22,6 @@ public function testHandleUserCodeError(): void $exception = new Exception('User code error'); - // Mock getFile to return a path that looks like user code (not in vendor) $reflection = new ReflectionClass($exception); $fileProperty = $reflection->getProperty('file'); $fileProperty->setValue($exception, getcwd() . '/app/Providers/AppServiceProvider.php'); @@ -45,7 +44,6 @@ public function testHandleFrameworkError(): void $exception = new Exception('Framework error'); - // Mock getFile to return a path that looks like framework code (in vendor) $reflection = new ReflectionClass($exception); $fileProperty = $reflection->getProperty('file'); $fileProperty->setValue($exception, getcwd() . '/vendor/hypervel/foundation/src/Application.php'); @@ -109,7 +107,6 @@ public function testHandleNoAnsi(): void $exception = new Exception('User code error'); - // Mock getFile to return a path that looks like user code $reflection = new ReflectionClass($exception); $fileProperty = $reflection->getProperty('file'); $fileProperty->setValue($exception, getcwd() . '/app/Providers/AppServiceProvider.php'); @@ -131,7 +128,6 @@ public function testHandleAnsi(): void $exception = new Exception('User code error'); - // Mock getFile to return a path that looks like user code $reflection = new ReflectionClass($exception); $fileProperty = $reflection->getProperty('file'); $fileProperty->setValue($exception, getcwd() . '/app/Providers/AppServiceProvider.php'); From d5af085bf83b7100e0446534558713e12b269d89 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:48:20 +0000 Subject: [PATCH 018/303] Correct helper return annotations and view collection typing Keep response and validator PHPDoc truthful for both omitted arguments and explicit null, while preserving precise non-null return types. Explain the view helper's argument-count behavior without changing its runtime API. Declare the concrete View's ArrayAccess generic in its owning source. Update the public helper type fixtures for the analyzer's translation union and a real skeleton view. --- src/foundation/src/helpers.php | 7 +++++-- .../HelperFunctionReturnTypeExtension.php | 17 +++++++++++++---- src/view/src/View.php | 3 +++ types/Foundation/Helpers.php | 6 ++++-- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index cacd8b954..0c4ee0310 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -843,7 +843,7 @@ function resource_path(string $path = ''): string * * With no arguments, return the factory. Any argument, including null, creates a response. * - * @return ($content is null ? ResponseFactory : HypervelResponse) + * @return ($content is null ? HypervelResponse|ResponseFactory : HypervelResponse) */ function response(mixed $content = null, int $status = 200, array $headers = []): ResponseFactory|HypervelResponse { @@ -1048,7 +1048,7 @@ function url(?string $path = null, mixed $parameters = [], ?bool $secure = null) * * With no arguments, return the factory. Any argument, including null, creates a validator. * - * @return ($data is null ? ValidatorFactoryContract : ValidatorContract) + * @return ($data is null ? ValidatorContract|ValidatorFactoryContract : ValidatorContract) */ function validator(?array $data = null, array $rules = [], array $messages = [], array $attributes = []): ValidatorContract|ValidatorFactoryContract { @@ -1066,6 +1066,9 @@ function validator(?array $data = null, array $rules = [], array $messages = [], /** * Get the evaluated view contents for the given view. * + * With no arguments, return the factory. Any argument makes a view, so an explicit + * null view name fails in make() rather than returning the factory. + * * @return ($view is null ? ViewFactory : ViewContract) */ function view(?string $view = null, array|Arrayable $data = [], array $mergeData = []): ViewFactory|ViewContract diff --git a/src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php index 310ddd896..968b5e360 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php @@ -7,6 +7,8 @@ use Hypervel\Contracts\Routing\ResponseFactory; use Hypervel\Contracts\Validation\Factory as ValidationFactory; use Hypervel\Contracts\Validation\Validator; +use Hypervel\Contracts\View\Factory as ViewFactory; +use Hypervel\Contracts\View\View as ViewContract; use Hypervel\Http\Response; use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; @@ -20,12 +22,21 @@ #[AutowiredService] class HelperFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { + /** + * The factory and result types of each helper. + */ + private const array HELPERS = [ + 'response' => [ResponseFactory::class, Response::class], + 'validator' => [ValidationFactory::class, Validator::class], + 'view' => [ViewFactory::class, ViewContract::class], + ]; + /** * Determine whether the helper selects its return type by argument count. */ public function isFunctionSupported(FunctionReflection $functionReflection): bool { - return in_array($functionReflection->getName(), ['response', 'validator'], true); + return isset(self::HELPERS[$functionReflection->getName()]); } /** @@ -33,9 +44,7 @@ public function isFunctionSupported(FunctionReflection $functionReflection): boo */ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type { - [$factory, $result] = $functionReflection->getName() === 'response' - ? [ResponseFactory::class, Response::class] - : [ValidationFactory::class, Validator::class]; + [$factory, $result] = self::HELPERS[$functionReflection->getName()]; $arguments = $functionCall->getArgs(); diff --git a/src/view/src/View.php b/src/view/src/View.php index 87a764365..04c08479f 100755 --- a/src/view/src/View.php +++ b/src/view/src/View.php @@ -23,6 +23,9 @@ use Stringable; use Throwable; +/** + * @implements ArrayAccess + */ class View implements ArrayAccess, Htmlable, Stringable, ViewContract { use Macroable { diff --git a/types/Foundation/Helpers.php b/types/Foundation/Helpers.php index d3734e3f1..80db2baa4 100644 --- a/types/Foundation/Helpers.php +++ b/types/Foundation/Helpers.php @@ -66,7 +66,7 @@ assertType('null', session(['foo' => 'bar'])); assertType('Hypervel\Translation\Translator', trans()); -assertType('array|string', trans('foo')); +assertType('(array|string)', trans('foo')); assertType('Hypervel\Contracts\Validation\Factory', validator()); assertType('Hypervel\Contracts\Validation\Validator', validator([])); @@ -74,7 +74,9 @@ assertType('Hypervel\Contracts\Validation\Validator', validator(rules: ['name' => 'required'])); assertType('Hypervel\Contracts\View\Factory', view()); -assertType('Hypervel\Contracts\View\View', view('foo')); +assertType('Hypervel\Contracts\View\View', view('welcome')); +assertType('Hypervel\Contracts\View\View', view(null)); +assertType('Hypervel\Contracts\View\View', view(data: [])); assertType('Hypervel\Contracts\Routing\UrlGenerator', url()); assertType('string', url('foo')); From d5c04ac043a5e47d6340a59ab9fe0382845e37ea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:48:21 +0000 Subject: [PATCH 019/303] Document view registrations unavailable during source analysis Add narrow argument-type ignores at Wayfinder and Workbench view calls whose registrations occur only when their command or optional authentication provider runs. Explain each runtime registration dependency locally instead of changing provider boot behavior, enabling optional features globally, or suppressing unrelated diagnostics. --- src/wayfinder/src/GenerateCommand.php | 3 +++ src/workbench/routes/web.php | 1 + .../Http/Controllers/Auth/AuthenticatedSessionController.php | 1 + .../Http/Controllers/Auth/ConfirmablePasswordController.php | 1 + .../Controllers/Auth/EmailVerificationPromptController.php | 1 + .../src/Http/Controllers/Auth/NewPasswordController.php | 1 + .../src/Http/Controllers/Auth/PasswordResetLinkController.php | 1 + .../src/Http/Controllers/Auth/RegisteredUserController.php | 1 + src/workbench/src/Http/Controllers/ProfileController.php | 1 + src/workbench/src/View/Components/AppLayout.php | 1 + src/workbench/src/View/Components/GuestLayout.php | 1 + 11 files changed, 13 insertions(+) diff --git a/src/wayfinder/src/GenerateCommand.php b/src/wayfinder/src/GenerateCommand.php index 19823b535..3443fab56 100644 --- a/src/wayfinder/src/GenerateCommand.php +++ b/src/wayfinder/src/GenerateCommand.php @@ -525,6 +525,7 @@ private function writeMultiRouteControllerMethodExport(Collection $routes, strin $duplicateUris = $routes->duplicates(fn (Route $route) => $route->uri()); + // @phpstan-ignore argument.type (handle() registers the wayfinder view namespace when the command runs.) $this->appendContent($path, $this->view->make('wayfinder::multi-method', [ 'method' => $method, 'original_method' => $routes->first()->originalJsMethod(), @@ -556,6 +557,7 @@ private function writeMultiRouteControllerMethodExport(Collection $routes, strin */ private function writeControllerMethodExport(Route $route, string $path, string $method): void { + // @phpstan-ignore argument.type (handle() registers the wayfinder view namespace when the command runs.) $this->appendContent($path, $this->view->make('wayfinder::method', [ 'controller' => $route->controller(), 'method' => $method, @@ -627,6 +629,7 @@ private function appendCommonImports(Collection $routes, string $path, string $n */ private function writeNamedMethodExport(Route $route, string $path, string $method): void { + // @phpstan-ignore argument.type (handle() registers the wayfinder view namespace when the command runs.) $this->appendContent($path, $this->view->make('wayfinder::method', [ 'controller' => $route->controller(), 'method' => $method, diff --git a/src/workbench/routes/web.php b/src/workbench/routes/web.php index 6a73878c8..b449c1dd6 100644 --- a/src/workbench/routes/web.php +++ b/src/workbench/routes/web.php @@ -11,6 +11,7 @@ // }); Route::get('/dashboard', function (): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard'); diff --git a/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php b/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php index b1bde2aa3..ab90658eb 100644 --- a/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -18,6 +18,7 @@ class AuthenticatedSessionController extends Controller */ public function create(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('auth.login'); } diff --git a/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php b/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php index 4aaffc5ce..58532924b 100644 --- a/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php +++ b/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -18,6 +18,7 @@ class ConfirmablePasswordController extends Controller */ public function show(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('auth.confirm-password'); } diff --git a/src/workbench/src/Http/Controllers/Auth/EmailVerificationPromptController.php b/src/workbench/src/Http/Controllers/Auth/EmailVerificationPromptController.php index 5a23c7e51..2ac9cbf92 100644 --- a/src/workbench/src/Http/Controllers/Auth/EmailVerificationPromptController.php +++ b/src/workbench/src/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -18,6 +18,7 @@ public function __invoke(Request $request): RedirectResponse|View { return $request->user()->hasVerifiedEmail() ? redirect()->intended(route('dashboard', absolute: false)) + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) : view('auth.verify-email'); } } diff --git a/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php b/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php index 63e92baad..2ec9c62e7 100644 --- a/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php +++ b/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php @@ -26,6 +26,7 @@ class NewPasswordController extends Controller */ public function create(Request $request): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('auth.reset-password', ['request' => $request]); } diff --git a/src/workbench/src/Http/Controllers/Auth/PasswordResetLinkController.php b/src/workbench/src/Http/Controllers/Auth/PasswordResetLinkController.php index 24d0f1d87..b59e76518 100644 --- a/src/workbench/src/Http/Controllers/Auth/PasswordResetLinkController.php +++ b/src/workbench/src/Http/Controllers/Auth/PasswordResetLinkController.php @@ -18,6 +18,7 @@ class PasswordResetLinkController extends Controller */ public function create(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('auth.forgot-password'); } diff --git a/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php b/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php index b0e5e4cb4..a337f94e7 100644 --- a/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php +++ b/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php @@ -26,6 +26,7 @@ class RegisteredUserController extends Controller */ public function create(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('auth.register'); } diff --git a/src/workbench/src/Http/Controllers/ProfileController.php b/src/workbench/src/Http/Controllers/ProfileController.php index abe666d9a..2c5d06517 100644 --- a/src/workbench/src/Http/Controllers/ProfileController.php +++ b/src/workbench/src/Http/Controllers/ProfileController.php @@ -18,6 +18,7 @@ class ProfileController extends Controller */ public function edit(Request $request): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('profile.edit', [ 'user' => $request->user(), ]); diff --git a/src/workbench/src/View/Components/AppLayout.php b/src/workbench/src/View/Components/AppLayout.php index dd76e0711..7546b5e99 100644 --- a/src/workbench/src/View/Components/AppLayout.php +++ b/src/workbench/src/View/Components/AppLayout.php @@ -14,6 +14,7 @@ class AppLayout extends Component */ public function render(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('layouts.app'); } } diff --git a/src/workbench/src/View/Components/GuestLayout.php b/src/workbench/src/View/Components/GuestLayout.php index 68efc871a..7eba5eb45 100644 --- a/src/workbench/src/View/Components/GuestLayout.php +++ b/src/workbench/src/View/Components/GuestLayout.php @@ -14,6 +14,7 @@ class GuestLayout extends Component */ public function render(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('layouts.guest'); } } From 372b8d3c983e77f4aa769c50139f6e5524b941e5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:48:21 +0000 Subject: [PATCH 020/303] Wire view and translation analysis dependencies and fixture resources Declare the analyzer's direct framework and Finder dependencies, load its supported stubs, and register filesystem support in the PHPStan container. Discover rule and integration fixture functions through standard Composer classmap directories. Populate only the analyzer's disposable Testbench runtime with fixture views and translations, preserve intentional fixture formatting, and register the new typing cases in the existing harness. --- .php-cs-fixer.php | 4 +- composer.json | 4 + src/phpstan/composer.json | 10 ++- src/phpstan/extension.neon | 5 ++ .../src/HypervelStubFilesExtension.php | 32 +++++++ tests/PHPStan/Rules/phpstan-rules.neon | 2 + tests/PHPStan/Type/GeneralTypeTest.php | 4 + tests/PHPStan/application/lang/en.json | 3 + tests/PHPStan/application/lang/en/a.php | 7 ++ tests/PHPStan/application/lang/en/a_b.php | 7 ++ tests/PHPStan/application/lang/en/and.php | 7 ++ tests/PHPStan/application/lang/en/app.php | 7 ++ .../PHPStan/application/lang/en/directive.php | 7 ++ tests/PHPStan/application/lang/en/dollar.php | 7 ++ tests/PHPStan/application/lang/en/double.php | 7 ++ tests/PHPStan/application/lang/en/lang.php | 7 ++ .../PHPStan/application/lang/en/messages.php | 25 ++++++ tests/PHPStan/application/lang/en/object.php | 7 ++ .../PHPStan/application/lang/en/sub/lines.php | 5 ++ .../application/lang/en/surrounded.php | 7 ++ tests/PHPStan/application/lang/en/trans.php | 7 ++ .../resources/views/base.blade.php | 5 ++ .../views/components/alert.blade.php | 1 + .../views/components/orphan.blade.php | 1 + .../emails/mail-message/markdown.blade.php | 1 + .../views/emails/mail-message/view.blade.php | 1 + .../views/emails/mail-send/mailer.blade.php | 0 .../emails/mail-send/send-static.blade.php | 0 .../views/emails/mail-send/send.blade.php | 0 .../emails/mailable/content/html.blade.php | 1 + .../mailable/content/markdown.blade.php | 1 + .../emails/mailable/content/text.blade.php | 1 + .../emails/mailable/content/view.blade.php | 1 + .../views/emails/mailable/markdown.blade.php | 1 + .../views/emails/mailable/view.blade.php | 1 + .../views/emails/orders/shipped.blade.php | 1 + .../resources/views/home.blade.php | 1 + .../resources/views/index.blade.php | 11 +++ .../resources/views/modals/someView.blade.php | 1 + .../named-arguments/facade-make.blade.php | 0 .../named-arguments/factory-make.blade.php | 0 .../views/named-arguments/helper.blade.php | 0 .../mail-message-view.blade.php | 0 .../named-arguments/mailable-text.blade.php | 0 .../named-arguments/route-view.blade.php | 0 .../resources/views/route-view.blade.php | 1 + .../views/translation-lookups.blade.php | 43 ++++++++++ .../resources/views/translations.blade.php | 83 +++++++++++++++++++ .../resources/views/unused.blade.php | 1 + .../views/users/admin/index.blade.php | 1 + .../resources/views/users/index.blade.php | 1 + .../views/view-factory-make.blade.php | 1 + .../views/view-helper-make.blade.php | 1 + .../views/view-static-make.blade.php | 1 + tests/PHPStan/phpunit-bootstrap.php | 14 ++++ 55 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 src/phpstan/src/HypervelStubFilesExtension.php create mode 100644 tests/PHPStan/Rules/phpstan-rules.neon create mode 100644 tests/PHPStan/application/lang/en.json create mode 100644 tests/PHPStan/application/lang/en/a.php create mode 100644 tests/PHPStan/application/lang/en/a_b.php create mode 100644 tests/PHPStan/application/lang/en/and.php create mode 100644 tests/PHPStan/application/lang/en/app.php create mode 100644 tests/PHPStan/application/lang/en/directive.php create mode 100644 tests/PHPStan/application/lang/en/dollar.php create mode 100644 tests/PHPStan/application/lang/en/double.php create mode 100644 tests/PHPStan/application/lang/en/lang.php create mode 100644 tests/PHPStan/application/lang/en/messages.php create mode 100644 tests/PHPStan/application/lang/en/object.php create mode 100644 tests/PHPStan/application/lang/en/sub/lines.php create mode 100644 tests/PHPStan/application/lang/en/surrounded.php create mode 100644 tests/PHPStan/application/lang/en/trans.php create mode 100644 tests/PHPStan/application/resources/views/base.blade.php create mode 100644 tests/PHPStan/application/resources/views/components/alert.blade.php create mode 100644 tests/PHPStan/application/resources/views/components/orphan.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/mail-message/markdown.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/mail-message/view.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/mail-send/mailer.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/mail-send/send-static.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/mail-send/send.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/mailable/content/html.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/mailable/content/markdown.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/mailable/content/text.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/mailable/content/view.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/mailable/markdown.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/mailable/view.blade.php create mode 100644 tests/PHPStan/application/resources/views/emails/orders/shipped.blade.php create mode 100644 tests/PHPStan/application/resources/views/home.blade.php create mode 100644 tests/PHPStan/application/resources/views/index.blade.php create mode 100644 tests/PHPStan/application/resources/views/modals/someView.blade.php create mode 100644 tests/PHPStan/application/resources/views/named-arguments/facade-make.blade.php create mode 100644 tests/PHPStan/application/resources/views/named-arguments/factory-make.blade.php create mode 100644 tests/PHPStan/application/resources/views/named-arguments/helper.blade.php create mode 100644 tests/PHPStan/application/resources/views/named-arguments/mail-message-view.blade.php create mode 100644 tests/PHPStan/application/resources/views/named-arguments/mailable-text.blade.php create mode 100644 tests/PHPStan/application/resources/views/named-arguments/route-view.blade.php create mode 100644 tests/PHPStan/application/resources/views/route-view.blade.php create mode 100644 tests/PHPStan/application/resources/views/translation-lookups.blade.php create mode 100644 tests/PHPStan/application/resources/views/translations.blade.php create mode 100644 tests/PHPStan/application/resources/views/unused.blade.php create mode 100644 tests/PHPStan/application/resources/views/users/admin/index.blade.php create mode 100644 tests/PHPStan/application/resources/views/users/index.blade.php create mode 100644 tests/PHPStan/application/resources/views/view-factory-make.blade.php create mode 100644 tests/PHPStan/application/resources/views/view-helper-make.blade.php create mode 100644 tests/PHPStan/application/resources/views/view-static-make.blade.php diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 5a921e613..4b54b298c 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -124,8 +124,8 @@ ->notPath('src/data/src/Support/Creation/NativeScalar.php') ->notPath('tests/Data/Fixtures/PhpDocTypeContext.php') ->notPath('tests/Foundation/Fixtures/fake-compiled-view.php') - // Analyzer data mirrors upstream Larastan fixtures, whose syntax and line numbers are deliberate. - ->notPath('#^tests/PHPStan/(.+/)?data/#') + // Analyzer data and the fixture application mirror upstream Larastan fixtures, whose syntax and line numbers are deliberate. + ->notPath('#^tests/PHPStan/((.+/)?data|application)/#') ->name('hypervel-test-profile') ->in(__DIR__) ) diff --git a/composer.json b/composer.json index 431ef922a..5ea597f68 100644 --- a/composer.json +++ b/composer.json @@ -371,6 +371,10 @@ "Workbench\\Database\\Factories\\": "src/testbench/workbench/database/factories/", "Workbench\\Database\\Seeders\\": "src/testbench/workbench/database/seeders/" }, + "classmap": [ + "tests/PHPStan/Integration/data", + "tests/PHPStan/Rules/data" + ], "files": [ "tests/Database/Fixtures/MigrationCreatorFakeMigration.php" ] diff --git a/src/phpstan/composer.json b/src/phpstan/composer.json index 50b5e36bc..5399a9e25 100644 --- a/src/phpstan/composer.json +++ b/src/phpstan/composer.json @@ -32,18 +32,24 @@ "require": { "php": "^8.4", "composer-runtime-api": "^2.2", + "hypervel/collections": "^0.4", "hypervel/container": "^0.4", "hypervel/contracts": "^0.4", "hypervel/database": "^0.4", + "hypervel/filesystem": "^0.4", + "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", "hypervel/routing": "^0.4", "hypervel/support": "^0.4", + "hypervel/translation": "^0.4", + "hypervel/view": "^0.4", "phpstan/phpstan": "^2.3.5", "phpstan/phpstan-deprecation-rules": "^2.0.5", - "symfony/console": "^8.1.10" + "symfony/console": "^8.1.10", + "symfony/finder": "^8.1" }, "suggest": { - "hypervel/testbench": "Required to analyse packages with a Testbench-booted application (^0.4)." + "hypervel/testbench": "Required to analyze packages with a Testbench-booted application (^0.4)." }, "config": { "sort-packages": true diff --git a/src/phpstan/extension.neon b/src/phpstan/extension.neon index 282592db4..33296648d 100644 --- a/src/phpstan/extension.neon +++ b/src/phpstan/extension.neon @@ -101,3 +101,8 @@ parametersSchema: noLegacyQueueProperties: bool() noPipeSeparatedValidationRules: bool() eloquentBuilderMacros: arrayOf(string(), string()) + +services: + - + class: Hypervel\Filesystem\Filesystem + autowired: self diff --git a/src/phpstan/src/HypervelStubFilesExtension.php b/src/phpstan/src/HypervelStubFilesExtension.php new file mode 100644 index 000000000..b01ea2ec6 --- /dev/null +++ b/src/phpstan/src/HypervelStubFilesExtension.php @@ -0,0 +1,32 @@ +files()->name('*.stub')->in(__DIR__ . '/../stubs/common') as $stubFile) { + $files[] = $stubFile->getRealPath(); + } + + return $files; + } +} diff --git a/tests/PHPStan/Rules/phpstan-rules.neon b/tests/PHPStan/Rules/phpstan-rules.neon new file mode 100644 index 000000000..b5afd4c37 --- /dev/null +++ b/tests/PHPStan/Rules/phpstan-rules.neon @@ -0,0 +1,2 @@ +includes: + - ../../../src/phpstan/extension.neon diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php index 61879df4d..2f0577761 100644 --- a/tests/PHPStan/Type/GeneralTypeTest.php +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -25,6 +25,10 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/data/application-make.php'); // REMOVED: container-array-access.php; container ArrayAccess is unsupported. yield from self::gatherAssertTypes(__DIR__ . '/data/container-make.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/translate.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/translator.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/view-exists.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/view.php'); } /** @return string[] */ diff --git a/tests/PHPStan/application/lang/en.json b/tests/PHPStan/application/lang/en.json new file mode 100644 index 000000000..efc6b1c9b --- /dev/null +++ b/tests/PHPStan/application/lang/en.json @@ -0,0 +1,3 @@ +{ + "foo bar baz": "Foo Bar Baz" +} diff --git a/tests/PHPStan/application/lang/en/a.php b/tests/PHPStan/application/lang/en/a.php new file mode 100644 index 000000000..13dd3a117 --- /dev/null +++ b/tests/PHPStan/application/lang/en/a.php @@ -0,0 +1,7 @@ + 'Existing line.', +]; diff --git a/tests/PHPStan/application/lang/en/a_b.php b/tests/PHPStan/application/lang/en/a_b.php new file mode 100644 index 000000000..13dd3a117 --- /dev/null +++ b/tests/PHPStan/application/lang/en/a_b.php @@ -0,0 +1,7 @@ + 'Existing line.', +]; diff --git a/tests/PHPStan/application/lang/en/and.php b/tests/PHPStan/application/lang/en/and.php new file mode 100644 index 000000000..13dd3a117 --- /dev/null +++ b/tests/PHPStan/application/lang/en/and.php @@ -0,0 +1,7 @@ + 'Existing line.', +]; diff --git a/tests/PHPStan/application/lang/en/app.php b/tests/PHPStan/application/lang/en/app.php new file mode 100644 index 000000000..13dd3a117 --- /dev/null +++ b/tests/PHPStan/application/lang/en/app.php @@ -0,0 +1,7 @@ + 'Existing line.', +]; diff --git a/tests/PHPStan/application/lang/en/directive.php b/tests/PHPStan/application/lang/en/directive.php new file mode 100644 index 000000000..13dd3a117 --- /dev/null +++ b/tests/PHPStan/application/lang/en/directive.php @@ -0,0 +1,7 @@ + 'Existing line.', +]; diff --git a/tests/PHPStan/application/lang/en/dollar.php b/tests/PHPStan/application/lang/en/dollar.php new file mode 100644 index 000000000..13dd3a117 --- /dev/null +++ b/tests/PHPStan/application/lang/en/dollar.php @@ -0,0 +1,7 @@ + 'Existing line.', +]; diff --git a/tests/PHPStan/application/lang/en/double.php b/tests/PHPStan/application/lang/en/double.php new file mode 100644 index 000000000..13dd3a117 --- /dev/null +++ b/tests/PHPStan/application/lang/en/double.php @@ -0,0 +1,7 @@ + 'Existing line.', +]; diff --git a/tests/PHPStan/application/lang/en/lang.php b/tests/PHPStan/application/lang/en/lang.php new file mode 100644 index 000000000..13dd3a117 --- /dev/null +++ b/tests/PHPStan/application/lang/en/lang.php @@ -0,0 +1,7 @@ + 'Existing line.', +]; diff --git a/tests/PHPStan/application/lang/en/messages.php b/tests/PHPStan/application/lang/en/messages.php new file mode 100644 index 000000000..269b98d0d --- /dev/null +++ b/tests/PHPStan/application/lang/en/messages.php @@ -0,0 +1,25 @@ + 'Foo', + 'bar' => 'Bar', + 'baz' => 'Baz', + + 'nested' => [ + 'key' => 'value', + ], + + 'You\'re beautiful' => 'Du bist schön', + + 'A.Key.With.Dots.' => 'A key with dots.', + + "with.new\nline.char" => 'Message with multi-line char,', + + 'spanning +multiple.lines' => 'Message spanning multiple lines.', + + 'with.a.back\\slash.single.quotes' => 'Message with a backslash.', + "with.a.back\\slash.double.quotes" => 'Message with a backslash.', + + "with.\x61.hex.char" => 'Message with a hex char.', +]; diff --git a/tests/PHPStan/application/lang/en/object.php b/tests/PHPStan/application/lang/en/object.php new file mode 100644 index 000000000..13dd3a117 --- /dev/null +++ b/tests/PHPStan/application/lang/en/object.php @@ -0,0 +1,7 @@ + 'Existing line.', +]; diff --git a/tests/PHPStan/application/lang/en/sub/lines.php b/tests/PHPStan/application/lang/en/sub/lines.php new file mode 100644 index 000000000..b37f6f9d8 --- /dev/null +++ b/tests/PHPStan/application/lang/en/sub/lines.php @@ -0,0 +1,5 @@ + 'Hello world!', +]; diff --git a/tests/PHPStan/application/lang/en/surrounded.php b/tests/PHPStan/application/lang/en/surrounded.php new file mode 100644 index 000000000..13dd3a117 --- /dev/null +++ b/tests/PHPStan/application/lang/en/surrounded.php @@ -0,0 +1,7 @@ + 'Existing line.', +]; diff --git a/tests/PHPStan/application/lang/en/trans.php b/tests/PHPStan/application/lang/en/trans.php new file mode 100644 index 000000000..13dd3a117 --- /dev/null +++ b/tests/PHPStan/application/lang/en/trans.php @@ -0,0 +1,7 @@ + 'Existing line.', +]; diff --git a/tests/PHPStan/application/resources/views/base.blade.php b/tests/PHPStan/application/resources/views/base.blade.php new file mode 100644 index 000000000..820e62e57 --- /dev/null +++ b/tests/PHPStan/application/resources/views/base.blade.php @@ -0,0 +1,5 @@ +

Base view

+ +@include('users.index') + +@include('users/admin.index') diff --git a/tests/PHPStan/application/resources/views/components/alert.blade.php b/tests/PHPStan/application/resources/views/components/alert.blade.php new file mode 100644 index 000000000..f0a497aba --- /dev/null +++ b/tests/PHPStan/application/resources/views/components/alert.blade.php @@ -0,0 +1 @@ +
{{ $slot }}
diff --git a/tests/PHPStan/application/resources/views/components/orphan.blade.php b/tests/PHPStan/application/resources/views/components/orphan.blade.php new file mode 100644 index 000000000..7c1ecd1d9 --- /dev/null +++ b/tests/PHPStan/application/resources/views/components/orphan.blade.php @@ -0,0 +1 @@ +
diff --git a/tests/PHPStan/application/resources/views/emails/mail-message/markdown.blade.php b/tests/PHPStan/application/resources/views/emails/mail-message/markdown.blade.php new file mode 100644 index 000000000..4266807e9 --- /dev/null +++ b/tests/PHPStan/application/resources/views/emails/mail-message/markdown.blade.php @@ -0,0 +1 @@ +// Used with $this->markdown('emails.mail-message.markdown') diff --git a/tests/PHPStan/application/resources/views/emails/mail-message/view.blade.php b/tests/PHPStan/application/resources/views/emails/mail-message/view.blade.php new file mode 100644 index 000000000..cb381e3a7 --- /dev/null +++ b/tests/PHPStan/application/resources/views/emails/mail-message/view.blade.php @@ -0,0 +1 @@ +// Used with $this->view('emails.mail-message.view') diff --git a/tests/PHPStan/application/resources/views/emails/mail-send/mailer.blade.php b/tests/PHPStan/application/resources/views/emails/mail-send/mailer.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/PHPStan/application/resources/views/emails/mail-send/send-static.blade.php b/tests/PHPStan/application/resources/views/emails/mail-send/send-static.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/PHPStan/application/resources/views/emails/mail-send/send.blade.php b/tests/PHPStan/application/resources/views/emails/mail-send/send.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/PHPStan/application/resources/views/emails/mailable/content/html.blade.php b/tests/PHPStan/application/resources/views/emails/mailable/content/html.blade.php new file mode 100644 index 000000000..a260a1665 --- /dev/null +++ b/tests/PHPStan/application/resources/views/emails/mailable/content/html.blade.php @@ -0,0 +1 @@ +// Used with Content(html: 'emails.mailable.html') diff --git a/tests/PHPStan/application/resources/views/emails/mailable/content/markdown.blade.php b/tests/PHPStan/application/resources/views/emails/mailable/content/markdown.blade.php new file mode 100644 index 000000000..af606dbc9 --- /dev/null +++ b/tests/PHPStan/application/resources/views/emails/mailable/content/markdown.blade.php @@ -0,0 +1 @@ +// Used with Content(markdown: 'emails.mailable.markdown') diff --git a/tests/PHPStan/application/resources/views/emails/mailable/content/text.blade.php b/tests/PHPStan/application/resources/views/emails/mailable/content/text.blade.php new file mode 100644 index 000000000..54e7ca628 --- /dev/null +++ b/tests/PHPStan/application/resources/views/emails/mailable/content/text.blade.php @@ -0,0 +1 @@ +// Used with Content(text: 'emails.mailable.text') diff --git a/tests/PHPStan/application/resources/views/emails/mailable/content/view.blade.php b/tests/PHPStan/application/resources/views/emails/mailable/content/view.blade.php new file mode 100644 index 000000000..42b3a7b2c --- /dev/null +++ b/tests/PHPStan/application/resources/views/emails/mailable/content/view.blade.php @@ -0,0 +1 @@ +// Used with Content(view: 'emails.mailable.view') diff --git a/tests/PHPStan/application/resources/views/emails/mailable/markdown.blade.php b/tests/PHPStan/application/resources/views/emails/mailable/markdown.blade.php new file mode 100644 index 000000000..04f2719fa --- /dev/null +++ b/tests/PHPStan/application/resources/views/emails/mailable/markdown.blade.php @@ -0,0 +1 @@ +// Used with $this->markdown('emails.mailable.markdown') diff --git a/tests/PHPStan/application/resources/views/emails/mailable/view.blade.php b/tests/PHPStan/application/resources/views/emails/mailable/view.blade.php new file mode 100644 index 000000000..4c2cead49 --- /dev/null +++ b/tests/PHPStan/application/resources/views/emails/mailable/view.blade.php @@ -0,0 +1 @@ +// Used with $this->view('emails.mailable.view') diff --git a/tests/PHPStan/application/resources/views/emails/orders/shipped.blade.php b/tests/PHPStan/application/resources/views/emails/orders/shipped.blade.php new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/PHPStan/application/resources/views/emails/orders/shipped.blade.php @@ -0,0 +1 @@ + diff --git a/tests/PHPStan/application/resources/views/home.blade.php b/tests/PHPStan/application/resources/views/home.blade.php new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/PHPStan/application/resources/views/home.blade.php @@ -0,0 +1 @@ + diff --git a/tests/PHPStan/application/resources/views/index.blade.php b/tests/PHPStan/application/resources/views/index.blade.php new file mode 100644 index 000000000..e949a5689 --- /dev/null +++ b/tests/PHPStan/application/resources/views/index.blade.php @@ -0,0 +1,11 @@ + + +@extends('base') + +Lorem ipsum + +@include("home") + +Heads up diff --git a/tests/PHPStan/application/resources/views/modals/someView.blade.php b/tests/PHPStan/application/resources/views/modals/someView.blade.php new file mode 100644 index 000000000..5d91fa0d4 --- /dev/null +++ b/tests/PHPStan/application/resources/views/modals/someView.blade.php @@ -0,0 +1 @@ +// Used by view('modals/' . __FUNCTION__) inside function someView diff --git a/tests/PHPStan/application/resources/views/named-arguments/facade-make.blade.php b/tests/PHPStan/application/resources/views/named-arguments/facade-make.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/PHPStan/application/resources/views/named-arguments/factory-make.blade.php b/tests/PHPStan/application/resources/views/named-arguments/factory-make.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/PHPStan/application/resources/views/named-arguments/helper.blade.php b/tests/PHPStan/application/resources/views/named-arguments/helper.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/PHPStan/application/resources/views/named-arguments/mail-message-view.blade.php b/tests/PHPStan/application/resources/views/named-arguments/mail-message-view.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/PHPStan/application/resources/views/named-arguments/mailable-text.blade.php b/tests/PHPStan/application/resources/views/named-arguments/mailable-text.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/PHPStan/application/resources/views/named-arguments/route-view.blade.php b/tests/PHPStan/application/resources/views/named-arguments/route-view.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/PHPStan/application/resources/views/route-view.blade.php b/tests/PHPStan/application/resources/views/route-view.blade.php new file mode 100644 index 000000000..0f7910319 --- /dev/null +++ b/tests/PHPStan/application/resources/views/route-view.blade.php @@ -0,0 +1 @@ +This is used in a Route::view diff --git a/tests/PHPStan/application/resources/views/translation-lookups.blade.php b/tests/PHPStan/application/resources/views/translation-lookups.blade.php new file mode 100644 index 000000000..4ffed264d --- /dev/null +++ b/tests/PHPStan/application/resources/views/translation-lookups.blade.php @@ -0,0 +1,43 @@ +@lang('lookups.missing', [], 'fr') +@lang('lookups.present', [], 'fr') +@lang('lookups.present', [], 'fr', false) +@choice('lookups.missing', 1, [], 'fr') +{{ __('lookups.missing', [], null) }} +{{ __('lookups.missing', [], $locale) }} +{{ Lang::get('lookups.present', [], 'fr', $fallback) }} +{{ trans("lookups.{$item}") }} +{{ trans("lookups.$item") }} +{{ __( 'lookups.missing' ) }} +{{ __(replace: [], key: 'lookups.missing') }} +@lang ('lookups.missing') +{{-- {{ __('lookups.missing') }} +{{ __('lookups.missing') }} --}} +@verbatim {{ __('lookups.missing') }} @endverbatim +@{{ __('lookups.missing') }} +@@lang('lookups.missing') +test@lang('lookups.missing') +{{ $translator->trans('lookups.missing') }} +{{ $t('lookups.missing') }} + +

__('lookups.missing')

+ +{{ "trans('lookups.missing')" }} +
+ +@section('title', __('lookups.missing')) +@foreach (__('lookups.missing') as $line) @endforeach +{!! __('lookups.missing') !!} +{{{ __('lookups.missing') }}} +@php + $label = __('lookups.missing'); +@endphp +@section('label', '@lang("lookups.missing")') +@unknownDirective(__('lookups.missing')) +@for ($index = 0; $index < count(__('lookups.missing')); $index++) @endfor +@php $translate = trans(...); @endphp + +{{-- --}} +@push('scripts', __('lookups.missing')) +{{-- @php __('lookups.missing'); @endphp --}} +@verbatim {{ __('lookups.missing') }} @endverbatim +{{-- @verbatim @endverbatim --}} diff --git a/tests/PHPStan/application/resources/views/translations.blade.php b/tests/PHPStan/application/resources/views/translations.blade.php new file mode 100644 index 000000000..0c212bb7e --- /dev/null +++ b/tests/PHPStan/application/resources/views/translations.blade.php @@ -0,0 +1,83 @@ +@lang('messages.foo') +@choice('messages.foo') +{{ __('messages.foo') }} +{{ trans('messages.foo') }} +{{ trans_choice('messages.foo', 1) }} +{{ Lang::get('messages.foo') }} +{{ Lang::choice('messages.foo', 1) }} +{{ Lang::trans('messages.foo', 1) }} +{{ Lang::transChoice('messages.foo', 1) }} + +@lang('foo bar baz'); +@lang('messages'); +@lang('messages.nested'); +@lang('messages.nested.key'); + +@lang('sub/lines.greeting'); + +@lang('vendor::key'); + +@lang('messages.You\'re beautiful'); + +@lang('messages.A.Key.With.Dots.'); + +More regex tests: + +{{ Lang::get('lang.get') }} +{{ Lang::choice('lang.choice', 1) }} +{{ Lang::trans('lang.trans') }} {{-- removed in Laravel 6+ --}} +{{ Lang::transChoice('lang.transChoice', 2) }} {{-- removed in Laravel 6+ --}} + +{{ trans('a.b') }} +{{ trans('a.b.c') }} +{{ trans('a_b.c-d') }} +{{ trans('a.b', [1,2]) }} +{{ trans('a.b!') }} +{{ trans('a.translate me!') }} +{{ trans('a.über~') }} +{{ trans("app.i\'m") }} +{{ trans('app.i\'m') }} +{{ trans('app.\"ok\"') }} +{{ trans("app.\"ok\"") }} +{{ trans_choice('a.b', 1) }} + +@lang('directive.lang') +@choice('directive.choice', 1) + +Test:@lang('surrounded.by.text')! +@lang('surrounded.by.html.tags') + +{{ TRANS('trans.uppercase') }} +{{ $object->trans('object.instance.method') }} +{{ $t('dollar.t') }} +{{ __('double.underscore.helper.function') }} + + +

@lang('lang.with.extra.paramater', ['name' => config('app.name')])

+{{-- @lang('lang.in.blade.comment') --}} + +@lang('translation.with.a.'.$variable) @lang('and.a.simple.translation.afterwards') +test@lang('lang.with.prefix') + +Should not match: +{{ trans($variableInsteadOfString) }} +{{ trans() }} +{{ differentMethodTrans('different.method.trans') }} +{{ Method1trans('method.1.trans') }} +{{ some_other_method_trans('some.other.method.trans') }} +{{ trans('trans.with.concatenated.string' . $object->property) }} + +Keys with special characters: +@lang("lang.with.new\nline.char") +@lang('lang.spanning +multiple.lines') +@lang('lang.with.a.back\\slash.single.quotes') +@lang("lang.with.a.back\\slash.double.quotes") +@lang("lang.with.\x61.hex.char") + +@lang("messages.with.new\nline.char") +@lang('messages.spanning +multiple.lines') +@lang('messages.with.a.back\\slash.single.quotes') +@lang("messages.with.a.back\\slash.double.quotes") +@lang("messages.with.\x61.hex.char") diff --git a/tests/PHPStan/application/resources/views/unused.blade.php b/tests/PHPStan/application/resources/views/unused.blade.php new file mode 100644 index 000000000..1c8e4aca5 --- /dev/null +++ b/tests/PHPStan/application/resources/views/unused.blade.php @@ -0,0 +1 @@ +// Not used in any other file diff --git a/tests/PHPStan/application/resources/views/users/admin/index.blade.php b/tests/PHPStan/application/resources/views/users/admin/index.blade.php new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/PHPStan/application/resources/views/users/admin/index.blade.php @@ -0,0 +1 @@ + diff --git a/tests/PHPStan/application/resources/views/users/index.blade.php b/tests/PHPStan/application/resources/views/users/index.blade.php new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/PHPStan/application/resources/views/users/index.blade.php @@ -0,0 +1 @@ + diff --git a/tests/PHPStan/application/resources/views/view-factory-make.blade.php b/tests/PHPStan/application/resources/views/view-factory-make.blade.php new file mode 100644 index 000000000..7d3db43f7 --- /dev/null +++ b/tests/PHPStan/application/resources/views/view-factory-make.blade.php @@ -0,0 +1 @@ +// Used by $viewFactory->make('view-factory-make') diff --git a/tests/PHPStan/application/resources/views/view-helper-make.blade.php b/tests/PHPStan/application/resources/views/view-helper-make.blade.php new file mode 100644 index 000000000..59e8c05f8 --- /dev/null +++ b/tests/PHPStan/application/resources/views/view-helper-make.blade.php @@ -0,0 +1 @@ +// Used by view()->make('view-helper-make') diff --git a/tests/PHPStan/application/resources/views/view-static-make.blade.php b/tests/PHPStan/application/resources/views/view-static-make.blade.php new file mode 100644 index 000000000..12be948eb --- /dev/null +++ b/tests/PHPStan/application/resources/views/view-static-make.blade.php @@ -0,0 +1 @@ +// Used by View::make('view-static-make') diff --git a/tests/PHPStan/phpunit-bootstrap.php b/tests/PHPStan/phpunit-bootstrap.php index 0cdbafc15..312bc27dc 100644 --- a/tests/PHPStan/phpunit-bootstrap.php +++ b/tests/PHPStan/phpunit-bootstrap.php @@ -2,8 +2,22 @@ declare(strict_types=1); +use Hypervel\Filesystem\Filesystem; +use Hypervel\Testbench\Bootstrapper; + require_once __DIR__ . '/../bootstrap.php'; +// The analyzed application is this process's disposable Testbench runtime copy, so +// its resources become the fixture application's before it boots. +Bootstrapper::bootstrap(); + +$filesystem = new Filesystem; + +foreach (['lang', 'resources/views'] as $directory) { + $filesystem->deleteDirectory(BASE_PATH . '/' . $directory); + $filesystem->copyDirectory(__DIR__ . '/application/' . $directory, BASE_PATH . '/' . $directory); +} + // PHPStan boots the analyzed application once per process when a test first loads // the extension's bootstrap file. Booting it before any test runs keeps its error // and exception handlers out of PHPUnit's per-test handler checks. From 7abb9685782ee9ad668e80d13e05d60e9292f638 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:48:21 +0000 Subject: [PATCH 021/303] Add analyzer view discovery and parsing support Discover root view files from normalized paths and retain package namespace directories as usage sources without reporting exported package views as unused. Keep explicit unused-view directory configuration separate from translation scanning. Port cached PHP parsing and the upstream directory iterator behavior with its regression coverage, including nested files, iterator reuse and non-rewinding streams. --- .../Support/RecursiveDirectoryIterator.php | 57 +++++ src/phpstan/src/Support/ViewFileHelper.php | 197 ++++++++++++++++++ src/phpstan/src/Support/ViewParser.php | 71 +++++++ .../Support/BrokenRewindDirectoryStream.php | 94 +++++++++ .../RecursiveDirectoryIteratorTest.php | 166 +++++++++++++++ 5 files changed, 585 insertions(+) create mode 100644 src/phpstan/src/Support/RecursiveDirectoryIterator.php create mode 100644 src/phpstan/src/Support/ViewFileHelper.php create mode 100644 src/phpstan/src/Support/ViewParser.php create mode 100644 tests/PHPStan/Unit/Support/BrokenRewindDirectoryStream.php create mode 100644 tests/PHPStan/Unit/Support/RecursiveDirectoryIteratorTest.php diff --git a/src/phpstan/src/Support/RecursiveDirectoryIterator.php b/src/phpstan/src/Support/RecursiveDirectoryIterator.php new file mode 100644 index 000000000..f0c74e12a --- /dev/null +++ b/src/phpstan/src/Support/RecursiveDirectoryIterator.php @@ -0,0 +1,57 @@ +ignoreFirstRewind = false; + + parent::next(); + } + + /** + * Rewind to the first entry, except on the first call. + */ + public function rewind(): void + { + if ($this->ignoreFirstRewind) { + $this->ignoreFirstRewind = false; + + return; + } + + parent::rewind(); + } +} diff --git a/src/phpstan/src/Support/ViewFileHelper.php b/src/phpstan/src/Support/ViewFileHelper.php new file mode 100644 index 000000000..764850c36 --- /dev/null +++ b/src/phpstan/src/Support/ViewFileHelper.php @@ -0,0 +1,197 @@ + + */ + private array $viewDirectories; + + /** + * The directories whose views can be reported as unused, named relative to the directory. + * + * @var list + */ + private array $namedViewDirectories; + + /** + * Create a new view file helper instance. + * + * Configured directories replace the booted finder's paths and namespace hints for + * the unused-view rule; the missing-translation rule always scans the finder's paths, + * as upstream does. Upstream also names the views in namespace hint directories as + * though they were root views. Those views are only scanned for usages here: framework + * and package views, such as pagination::tailwind, are rendered by names that no + * literal call shows. + * + * @param list $viewDirectories + */ + public function __construct( + #[AutowiredParameter] + array $viewDirectories, + private FileHelper $fileHelper, + ) { + $hintDirectories = []; + + if ($viewDirectories === []) { + $finder = $this->finder(); + $viewDirectories = $finder->getPaths(); + + // Hypervel's finder contract has no namespace hints; the standard finder does. + if ($finder instanceof FileViewFinder) { + $hintDirectories = array_merge(...array_values($finder->getHints())); + } + } + + // View names are derived from these directories, so a relative directory is + // resolved once to match the paths of the files found beneath it. + $this->namedViewDirectories = array_map($this->fileHelper->absolutizePath(...), $viewDirectories); + $this->viewDirectories = [ + ...$this->namedViewDirectories, + ...array_map($this->fileHelper->absolutizePath(...), $hintDirectories), + ]; + } + + /** + * Track the root view directories. + * + * The result cache runs the rule again when a view file in the root view directories changes. + */ + public function trackRootViewDirectories(DependencyTracker $dependencyTracker): void + { + foreach ($this->finder()->getPaths() as $path) { + $dependencyTracker->trackDirectoryDependency($this->fileHelper->absolutizePath($path), '*.blade.php'); + } + } + + /** + * Track every view directory. + * + * The result cache runs the rule again when a view file in any of the view directories changes. + */ + public function trackAllViewDirectories(DependencyTracker $dependencyTracker): void + { + foreach ($this->viewDirectories as $viewDirectory) { + $dependencyTracker->trackDirectoryDependency($viewDirectory, '*.blade.php'); + } + } + + /** + * Get the view files in the root view directories. + * + * @return Generator + */ + public function getRootViewFilePaths(): Generator + { + foreach ($this->finder()->getPaths() as $path) { + foreach ($this->getViews($path) as $view) { + yield $view->getPathname(); + } + } + } + + /** + * Get the view files in every view directory. + * + * @return Generator + */ + public function getAllViewFilePaths(): Generator + { + foreach ($this->viewDirectories as $viewDirectory) { + foreach ($this->getViews($viewDirectory) as $view) { + yield $view->getPathname(); + } + } + } + + /** + * Get the names of the views that can be reported as unused. + * + * @return Generator + */ + public function getAllViewNames(): Generator + { + foreach ($this->namedViewDirectories as $viewDirectory) { + $prefix = rtrim($viewDirectory, '/\\') . DIRECTORY_SEPARATOR; + + foreach ($this->getViews($viewDirectory) as $view) { + $relativePath = substr($view->getPathname(), strlen($prefix)); + + // Vendor overrides and error pages are rendered through their view namespaces. + // Upstream matches these directory names anywhere in the absolute path. + if (str_starts_with($relativePath, 'vendor' . DIRECTORY_SEPARATOR) || str_starts_with($relativePath, 'errors' . DIRECTORY_SEPARATOR)) { + continue; + } + + yield str_replace([DIRECTORY_SEPARATOR, '.blade.php'], ['.', ''], $relativePath); + } + } + } + + /** + * Get the Blade view files beneath the given directory. + * + * @return SplFileInfo[] + */ + protected function getViews(string $path): array + { + $absolutePath = $this->fileHelper->absolutizePath($path); + + if (! is_dir($absolutePath)) { + return []; + } + + return iterator_to_array( + new RegexIterator( + new RecursiveIteratorIterator(new RecursiveDirectoryIterator($absolutePath)), + '/\.blade\.php$/i', + ), + ); + } + + /** + * Get the booted application's view finder. + */ + protected function finder(): ViewFinderInterface + { + /** @var ViewFactory $factory The canonical view binding is the standard factory. */ + $factory = $this->resolve('view'); + + return $factory->getFinder(); + } +} diff --git a/src/phpstan/src/Support/ViewParser.php b/src/phpstan/src/Support/ViewParser.php new file mode 100644 index 000000000..cc3dfcd49 --- /dev/null +++ b/src/phpstan/src/Support/ViewParser.php @@ -0,0 +1,71 @@ + */ + private array $nodes = []; + + /** + * Create a new view parser instance. + */ + public function __construct( + #[AutowiredParameter(ref: '@currentPhpVersionSimpleDirectParser')] + private Parser $parser, + ) { + } + + /** + * Get the parsed statements of the given view file. + * + * @return Stmt[] + */ + public function getNodes(string $path): array + { + if (! array_key_exists($path, $this->nodes)) { + $this->nodes[$path] = $this->parseFile($path); + } + + return $this->nodes[$path]; + } + + /** + * Parse PHP code taken from a view, such as a call's argument list. + * + * @return Stmt[] + */ + public function parseCode(string $code): array + { + try { + return $this->parser->parseString($code); + } catch (ParserErrorsException) { + return []; + } + } + + /** + * Parse the given view file. + * + * @return Stmt[] + */ + private function parseFile(string $path): array + { + try { + return $this->parser->parseFile($path); + } catch (ParserErrorsException) { + return []; + } + } +} diff --git a/tests/PHPStan/Unit/Support/BrokenRewindDirectoryStream.php b/tests/PHPStan/Unit/Support/BrokenRewindDirectoryStream.php new file mode 100644 index 000000000..3954049f3 --- /dev/null +++ b/tests/PHPStan/Unit/Support/BrokenRewindDirectoryStream.php @@ -0,0 +1,94 @@ + */ + private array $entries = ['.', '..', 'one.php', 'two.php', 'notes.txt']; + + private int $position = 0; + + /** + * Open the directory. + */ + public function dir_opendir(string $path, int $options): bool + { + $this->position = 0; + + return true; + } + + /** + * Read the next directory entry. + */ + public function dir_readdir(): string|false + { + return $this->entries[$this->position++] ?? false; + } + + /** + * Record the rewind without moving back, as the affected filesystems do. + */ + public function dir_rewinddir(): bool + { + ++self::$rewindCalls; + + return true; + } + + /** + * Close the directory. + */ + public function dir_closedir(): bool + { + return true; + } + + /** + * Get the status of the root directory or one of its files. + * + * @return array + */ + public function url_stat(string $path, int $flags): array + { + $mode = $path === self::SCHEME . '://root' ? 0040777 : 0100666; + + return [ + 0 => 0, + 1 => 0, + 2 => $mode, + 3 => 1, + 4 => 0, + 5 => 0, + 6 => 0, + 7 => 0, + 8 => 0, + 9 => 0, + 10 => 0, + 11 => -1, + 12 => -1, + 'dev' => 0, + 'ino' => 0, + 'mode' => $mode, + 'nlink' => 1, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => 0, + 'atime' => 0, + 'mtime' => 0, + 'ctime' => 0, + 'blksize' => -1, + 'blocks' => -1, + ]; + } +} diff --git a/tests/PHPStan/Unit/Support/RecursiveDirectoryIteratorTest.php b/tests/PHPStan/Unit/Support/RecursiveDirectoryIteratorTest.php new file mode 100644 index 000000000..b2e257fba --- /dev/null +++ b/tests/PHPStan/Unit/Support/RecursiveDirectoryIteratorTest.php @@ -0,0 +1,166 @@ +directory = ParallelTesting::tempDir('RecursiveDirectoryIteratorTest'); + (new Filesystem)->deleteDirectory($this->directory); + + mkdir($this->directory . '/sub/deeper', 0777, true); + mkdir($this->directory . '/empty', 0777, true); + + // More entries than one directory read buffers on affected filesystems + // (~29 entries with typical migration file name lengths), so a dropped + // first chunk would be visible in the result count. + for ($i = 0; $i < 40; ++$i) { + file_put_contents(sprintf('%s/2024_01_01_%06d_create_some_table_with_a_long_name.php', $this->directory, $i), 'directory . '/UPPERCASE.PHP', 'directory . '/notes.txt', 'not a match'); + file_put_contents($this->directory . '/sub/nested.php', 'directory . '/sub/deeper/deep.php', 'deleteDirectory($this->directory); + + parent::tearDown(); + } + + /** + * Find the PHP files beneath the given directory. + * + * @return array + */ + private function scan(string $directory): array + { + /** @var array $files */ + $files = iterator_to_array( + new RegexIterator( + new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)), + '/\.php$/i', + ), + ); + + return $files; + } + + #[Test] + public function itFindsTheSameFilesAsThePlainSplConstruct(): void + { + $files = $this->scan($this->directory); + + /** @var array $splFiles */ + $splFiles = iterator_to_array( + new RegexIterator( + new RecursiveIteratorIterator(new SplRecursiveDirectoryIterator($this->directory)), + '/\.php$/i', + ), + ); + + self::assertSame(array_keys($splFiles), array_keys($files)); + self::assertContainsOnlyInstancesOf(SplFileInfo::class, $files); + } + + #[Test] + public function itFindsFilesInNestedDirectories(): void + { + $files = $this->scan($this->directory); + + self::assertCount(43, $files); + self::assertArrayHasKey($this->directory . DIRECTORY_SEPARATOR . 'UPPERCASE.PHP', $files); + self::assertArrayHasKey($this->directory . DIRECTORY_SEPARATOR . 'sub' . DIRECTORY_SEPARATOR . 'nested.php', $files); + self::assertArrayHasKey( + $this->directory . DIRECTORY_SEPARATOR . 'sub' . DIRECTORY_SEPARATOR . 'deeper' . DIRECTORY_SEPARATOR . 'deep.php', + $files, + ); + } + + #[Test] + public function itRewindsNormallyOnceIterationHasAdvanced(): void + { + // Only the redundant first rewind() is skipped. After next() has + // run, rewind() must really seek back, or reusing an iterator + // would silently continue where the last pass stopped. + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->directory)); + + $first = array_keys(iterator_to_array($iterator)); + $second = array_keys(iterator_to_array($iterator)); + + self::assertSame($first, $second); + self::assertNotSame([], $first); + } + + #[Test] + public function itHandsOutChildrenThatSkipTheirOwnFirstRewind(): void + { + $iterator = new RecursiveDirectoryIterator($this->directory); + + $children = null; + + for ($iterator->rewind(); $iterator->valid(); $iterator->next()) { + if (! $iterator->hasChildren()) { + continue; + } + + $children = $iterator->getChildren(); + break; + } + + // Each child is constructed fresh, so its own first rewind() is + // skipped by the same rule — a subdirectory's first buffered chunk + // must not be lost either. + self::assertInstanceOf(RecursiveDirectoryIterator::class, $children); + } + + #[Test] + public function itDoesNotRewindANewlyConstructedDirectoryStream(): void + { + BrokenRewindDirectoryStream::$rewindCalls = 0; + + self::assertTrue(stream_wrapper_register(BrokenRewindDirectoryStream::SCHEME, BrokenRewindDirectoryStream::class)); + + try { + $files = $this->scan(BrokenRewindDirectoryStream::SCHEME . '://root'); + + self::assertSame([ + BrokenRewindDirectoryStream::SCHEME . '://root' . DIRECTORY_SEPARATOR . 'one.php', + BrokenRewindDirectoryStream::SCHEME . '://root' . DIRECTORY_SEPARATOR . 'two.php', + ], array_keys($files)); + self::assertSame(0, BrokenRewindDirectoryStream::$rewindCalls); + } finally { + stream_wrapper_unregister(BrokenRewindDirectoryStream::SCHEME); + } + } +} From 5fa07f3dd004135aac76e6d392dc522ca132456f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:48:29 +0000 Subject: [PATCH 022/303] Validate view names across supported framework entrypoints Add the view-string type and stubs for view factories, routing, mail content and view assertions while preserving their native parameter and return contracts. Cover existing and missing views, named arguments, optional content views and narrowing through exists(). Compare integration diagnostics exactly so missing expected errors cannot silently pass. --- src/phpstan/src/Types/ViewStringType.php | 99 +++++++++++ .../ViewStringTypeNodeResolverExtension.php | 31 ++++ .../stubs/common/Contracts/Support.stub | 17 ++ src/phpstan/stubs/common/Contracts/View.stub | 27 +++ src/phpstan/stubs/common/Facades.stub | 18 ++ src/phpstan/stubs/common/Helpers.stub | 18 ++ .../stubs/common/InteractsWithViews.stub | 17 ++ .../stubs/common/Mail/Mailables/Content.stub | 15 ++ src/phpstan/stubs/common/MailMessage.stub | 27 +++ src/phpstan/stubs/common/Mailable.stub | 27 +++ src/phpstan/stubs/common/Route.stub | 6 + src/phpstan/stubs/common/Router.stub | 16 ++ src/phpstan/stubs/common/TestResponse.stub | 14 ++ src/phpstan/stubs/common/View/View.stub | 10 ++ .../Fixtures/Integration/view-helper.php | 15 ++ tests/PHPStan/Integration/IntegrationTest.php | 162 ++++++++++++++++++ tests/PHPStan/Integration/data/blade-view.php | 21 +++ .../Integration/data/view-string-content.php | 35 ++++ .../data/view-string-signatures.php | 78 +++++++++ tests/PHPStan/Type/data/view-exists.php | 14 ++ 20 files changed, 667 insertions(+) create mode 100644 src/phpstan/src/Types/ViewStringType.php create mode 100644 src/phpstan/src/Types/ViewStringTypeNodeResolverExtension.php create mode 100644 src/phpstan/stubs/common/Contracts/Support.stub create mode 100644 src/phpstan/stubs/common/Contracts/View.stub create mode 100644 src/phpstan/stubs/common/Facades.stub create mode 100644 src/phpstan/stubs/common/Helpers.stub create mode 100644 src/phpstan/stubs/common/InteractsWithViews.stub create mode 100644 src/phpstan/stubs/common/Mail/Mailables/Content.stub create mode 100644 src/phpstan/stubs/common/MailMessage.stub create mode 100644 src/phpstan/stubs/common/Mailable.stub create mode 100644 src/phpstan/stubs/common/Route.stub create mode 100644 src/phpstan/stubs/common/Router.stub create mode 100644 src/phpstan/stubs/common/TestResponse.stub create mode 100644 src/phpstan/stubs/common/View/View.stub create mode 100644 tests/PHPStan/Fixtures/Integration/view-helper.php create mode 100644 tests/PHPStan/Integration/IntegrationTest.php create mode 100644 tests/PHPStan/Integration/data/blade-view.php create mode 100644 tests/PHPStan/Integration/data/view-string-content.php create mode 100644 tests/PHPStan/Integration/data/view-string-signatures.php create mode 100644 tests/PHPStan/Type/data/view-exists.php diff --git a/src/phpstan/src/Types/ViewStringType.php b/src/phpstan/src/Types/ViewStringType.php new file mode 100644 index 000000000..61bcb67a8 --- /dev/null +++ b/src/phpstan/src/Types/ViewStringType.php @@ -0,0 +1,99 @@ +exists($string) test is a valid view-string type. + */ +class ViewStringType extends StringType +{ + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + return 'view-string'; + } + + /** + * Determine whether the type accepts the given type. + */ + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + $constantStrings = $type->getConstantStrings(); + + if (count($constantStrings) === 1) { + /** @var Factory $view */ + $view = view(); + + return AcceptsResult::createFromBoolean($view->exists($constantStrings[0]->getValue())); + } + + if ($type instanceof self) { + return AcceptsResult::createYes(); + } + + if ($type->isString()->yes()) { + return AcceptsResult::createMaybe(); + } + + return AcceptsResult::createNo(); + } + + /** + * Determine whether the type is a supertype of the given type. + */ + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + $constantStrings = $type->getConstantStrings(); + + if (count($constantStrings) === 1) { + /** @var Factory $view */ + $view = view(); + + return IsSuperTypeOfResult::createFromBoolean($view->exists($constantStrings[0]->getValue())); + } + + if ($type instanceof self) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type->isString()->yes()) { + return IsSuperTypeOfResult::createMaybe(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + /** + * Restore the type from its exported state. + * + * @param mixed[] $properties + */ + public static function __set_state(array $properties): Type + { + return new self; + } +} diff --git a/src/phpstan/src/Types/ViewStringTypeNodeResolverExtension.php b/src/phpstan/src/Types/ViewStringTypeNodeResolverExtension.php new file mode 100644 index 000000000..6e9d31e4c --- /dev/null +++ b/src/phpstan/src/Types/ViewStringTypeNodeResolverExtension.php @@ -0,0 +1,31 @@ +__toString() === 'view-string') { + return new ViewStringType; + } + + return null; + } +} diff --git a/src/phpstan/stubs/common/Contracts/Support.stub b/src/phpstan/stubs/common/Contracts/Support.stub new file mode 100644 index 000000000..c8a445a0d --- /dev/null +++ b/src/phpstan/stubs/common/Contracts/Support.stub @@ -0,0 +1,17 @@ + $data + * @param array $mergeData + */ + public function make(string $view, Arrayable|array $data = [], array $mergeData = []): View; +} + +interface View +{} diff --git a/src/phpstan/stubs/common/Facades.stub b/src/phpstan/stubs/common/Facades.stub new file mode 100644 index 000000000..d89d172ae --- /dev/null +++ b/src/phpstan/stubs/common/Facades.stub @@ -0,0 +1,18 @@ + $data + * @param array $mergeData + */ +function view(?string $view = null, array|Arrayable $data = [], array $mergeData = []): ViewFactory|ViewContract {} + +// trans() and __() are omitted: their source signatures already declare the +// conditional return, and upstream's replacement type excludes the objects +// Hypervel's translator accepts. diff --git a/src/phpstan/stubs/common/InteractsWithViews.stub b/src/phpstan/stubs/common/InteractsWithViews.stub new file mode 100644 index 000000000..0c8dfa9dd --- /dev/null +++ b/src/phpstan/stubs/common/InteractsWithViews.stub @@ -0,0 +1,17 @@ + $data + */ + protected function view(string $view, Arrayable|array $data = []): TestView; +} diff --git a/src/phpstan/stubs/common/Mail/Mailables/Content.stub b/src/phpstan/stubs/common/Mail/Mailables/Content.stub new file mode 100644 index 000000000..1f38eb0c2 --- /dev/null +++ b/src/phpstan/stubs/common/Mail/Mailables/Content.stub @@ -0,0 +1,15 @@ + $with + */ + public function __construct(?string $view = null, ?string $html = null, ?string $text = null, ?string $markdown = null, array $with = [], ?string $htmlString = null); +} diff --git a/src/phpstan/stubs/common/MailMessage.stub b/src/phpstan/stubs/common/MailMessage.stub new file mode 100644 index 000000000..7cae79bec --- /dev/null +++ b/src/phpstan/stubs/common/MailMessage.stub @@ -0,0 +1,27 @@ + $data + * @return $this + */ + public function markdown(string $view, array $data = []): static; + + /** + * @param view-string|view-string[] $view + * @param array $data + * @return $this + */ + public function view(array|string $view, array $data = []): static; + + /** + * @param view-string $textView + * @param array $data + * @return $this + */ + public function text(string $textView, array $data = []): static; +} diff --git a/src/phpstan/stubs/common/Mailable.stub b/src/phpstan/stubs/common/Mailable.stub new file mode 100644 index 000000000..4f656b37f --- /dev/null +++ b/src/phpstan/stubs/common/Mailable.stub @@ -0,0 +1,27 @@ + $data + * @return $this + */ + public function markdown(string $view, array $data = []): static; + + /** + * @param view-string $view + * @param array $data + * @return $this + */ + public function view(string $view, array $data = []): static; + + /** + * @param view-string $textView + * @param array $data + * @return $this + */ + public function text(string $textView, array $data = []): static; +} diff --git a/src/phpstan/stubs/common/Route.stub b/src/phpstan/stubs/common/Route.stub new file mode 100644 index 000000000..10b78c934 --- /dev/null +++ b/src/phpstan/stubs/common/Route.stub @@ -0,0 +1,6 @@ + $data + * @param int|array $status an array is taken as the headers + * @param array $headers + */ + public function view(string $uri, string $view, array $data = [], int|array $status = 200, array $headers = []): Route; +} diff --git a/src/phpstan/stubs/common/TestResponse.stub b/src/phpstan/stubs/common/TestResponse.stub new file mode 100644 index 000000000..85a27fed8 --- /dev/null +++ b/src/phpstan/stubs/common/TestResponse.stub @@ -0,0 +1,14 @@ +> $expectedErrors + * + * @throws Throwable + */ + #[DataProvider('dataIntegrationTests')] + public function testIntegration(string $file, ?array $expectedErrors = null): void + { + $errors = $this->runAnalyse($file); + + if ($expectedErrors === null) { + $this->assertNoErrors($errors); + } else { + $this->assertSameErrorMessages($file, $expectedErrors, $errors); + } + } + + /** @return iterable>}> */ + public static function dataIntegrationTests(): iterable + { + self::getContainer(); + + yield [__DIR__ . '/data/blade-view.php']; + + yield [ + __DIR__ . '/data/view-string-content.php', + [ + 10 => ['Parameter $view of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 11 => ['Parameter $html of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 12 => ['Parameter $text of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 13 => ['Parameter $markdown of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 14 => [ + 'Parameter #1 $view of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.', + 'Parameter #2 $html of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.', + 'Parameter #3 $text of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.', + 'Parameter #4 $markdown of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.', + ], + 15 => ['Parameter $view of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 28 => [ + 'Parameter $html of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string|null given.', + 'Parameter $markdown of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string|null given.', + 'Parameter $text of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string|null given.', + 'Parameter $view of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string|null given.', + ], + 29 => ['Parameter $view of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 30 => ['Parameter $text of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + ], + ]; + + yield [ + __DIR__ . '/data/view-string-signatures.php', + [ + 17 => ['Parameter #1 $view of method Hypervel\Contracts\View\Factory::make() expects view-string, string given.'], + 23 => ['Parameter #2 $view of method Hypervel\Routing\Router::view() expects view-string, string given.'], + 29 => ['Parameter #1 $view of method Hypervel\Notifications\Messages\MailMessage::view() expects array|view-string, string given.'], + 31 => ['Parameter #1 $view of method Hypervel\Notifications\Messages\MailMessage::markdown() expects view-string, string given.'], + 38 => ['Parameter #1 $value of method Hypervel\Testing\TestResponse::assertViewIs() expects view-string, string given.'], + 52 => ['Parameter #1 $view of method ViewStringSignatures\UsesInteractsWithViews::view() expects view-string, string given.'], + 59 => ['Parameter #1 $view of static method Hypervel\View\Factory::make() expects view-string, string given.'], + 65 => ['Parameter #2 $view of static method Hypervel\Routing\Router::view() expects view-string, string given.'], + 71 => ['Parameter #1 $textView of method Hypervel\Notifications\Messages\MailMessage::text() expects view-string, string given.'], + 77 => ['Parameter #1 $textView of method Hypervel\Mail\Mailable::text() expects view-string, string given.'], + ], + ]; + + yield [ + __DIR__ . '/../Fixtures/Integration/view-helper.php', + [ + 13 => ['Parameter #1 $view of function view expects view-string|null, string given.'], + ], + ]; + } + + /** + * Analyze the given file and return its errors. + * + * @see https://github.com/phpstan/phpstan-src/blob/c9772621c0bd6eab7e02fdaa03714bea239b372d/tests/PHPStan/Analyser/AnalyserIntegrationTest.php#L604-L622 + * @see https://github.com/phpstan/phpstan/discussions/6888#discussioncomment-2423613 + * + * @param null|string[] $allAnalysedFiles + * + * @return Error[] + * + * @throws Throwable + */ + private function runAnalyse(string $file, ?array $allAnalysedFiles = null): array + { + $file = $this->getFileHelper()->normalizePath($file); + + /** @var Analyser $analyser */ + $analyser = self::getContainer()->getByType(Analyser::class); // @phpstan-ignore-line + + /** @var FileHelper $fileHelper */ + $fileHelper = self::getContainer()->getByType(FileHelper::class); + + $errors = $analyser->analyse([$file], null, null, true, $allAnalysedFiles)->getErrors(); // @phpstan-ignore-line + + foreach ($errors as $error) { + $this->assertSame($fileHelper->normalizePath($file), $error->getFilePath()); + } + + return $errors; + } + + /** + * Assert that the file reports exactly the expected errors. + * + * Upstream only checks that each reported error is expected, so an expected + * error that is no longer reported would pass. + * + * @param array> $expectedErrors + * @param Error[] $errors + */ + private function assertSameErrorMessages(string $file, array $expectedErrors, array $errors): void + { + $actualErrors = []; + + foreach ($errors as $error) { + $actualErrors[$error->getLine() ?? 0][] = $error->getMessage(); + } + + $normalize = static function (array $errorsByLine): array { + ksort($errorsByLine); + + return array_map(static function (array $messages): array { + sort($messages); + + return $messages; + }, $errorsByLine); + }; + + $this->assertSame($normalize($expectedErrors), $normalize($actualErrors), sprintf('File %s reports different errors.', $file)); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../phpstan-tests.neon', + ]; + } +} diff --git a/tests/PHPStan/Integration/data/blade-view.php b/tests/PHPStan/Integration/data/blade-view.php new file mode 100644 index 000000000..edeec9fb2 --- /dev/null +++ b/tests/PHPStan/Integration/data/blade-view.php @@ -0,0 +1,21 @@ +doSomethingWithAView('home'); + $this->doSomethingWithAView('emails.orders.shipped'); + $this->doSomethingWithAView('users.index'); + } + + /** + * @phpstan-param view-string $view + * + * @param string $view + * @return void + */ + private function doSomethingWithAView(string $view): void + { + } +} diff --git a/tests/PHPStan/Integration/data/view-string-content.php b/tests/PHPStan/Integration/data/view-string-content.php new file mode 100644 index 000000000..b8d00b475 --- /dev/null +++ b/tests/PHPStan/Integration/data/view-string-content.php @@ -0,0 +1,35 @@ + 'This is data, not a view'], + htmlString: '

Rendered HTML

', + ); + new Content('home', 'home', 'home', 'home', [], '

Rendered HTML

'); + new Content(); + new Content(view: null, html: null, text: null, markdown: null, htmlString: null); + new Content(view: $dynamic, html: $dynamic, text: $dynamic, markdown: $dynamic); + new Content(view: 'emails.*'); + new Content(with: [], markdown: 'home', text: 'missing-reordered-text', view: 'home'); + + if ($dynamic === null || view()->exists($dynamic)) { + new Content(view: $dynamic, html: $dynamic, text: $dynamic, markdown: $dynamic); + } +} diff --git a/tests/PHPStan/Integration/data/view-string-signatures.php b/tests/PHPStan/Integration/data/view-string-signatures.php new file mode 100644 index 000000000..5ba230f96 --- /dev/null +++ b/tests/PHPStan/Integration/data/view-string-signatures.php @@ -0,0 +1,78 @@ +make('view-factory-make'); + $factory->make('view-factory-make-does-not-exist'); +} + +function routerView(Router $router): void +{ + $router->view('/uri', 'route-view'); + $router->view('/uri', 'route-view-does-not-exist'); +} + +function mailMessage(MailMessage $message): void +{ + $message->view('emails.mail-message.view'); + $message->view('emails.mail-message.view-does-not-exist'); + $message->markdown('emails.mail-message.markdown'); + $message->markdown('emails.mail-message.markdown-does-not-exist'); +} + +/** @param TestResponse<\Hypervel\Http\Response> $response */ +function testResponseAssertion(TestResponse $response): void +{ + $response->assertViewIs('home'); + $response->assertViewIs('home-does-not-exist'); +} + +class UsesInteractsWithViews +{ + use InteractsWithViews; + + public function good(): void + { + $this->view('home'); + } + + public function bad(): void + { + $this->view('home-does-not-exist'); + } +} + +function viewFacadeMake(): void +{ + View::make('view-factory-make'); + View::make('view-factory-make-does-not-exist'); +} + +function routeFacadeView(): void +{ + Route::view('/uri', 'route-view'); + Route::view('/uri', 'route-view-does-not-exist'); +} + +function mailMessageText(MailMessage $message): void +{ + $message->text('emails.mail-message.view'); + $message->text('emails.mail-message.text-does-not-exist'); +} + +function mailableText(Mailable $mailable): void +{ + $mailable->text('emails.mailable.view'); + $mailable->text('emails.mailable.text-does-not-exist'); +} diff --git a/tests/PHPStan/Type/data/view-exists.php b/tests/PHPStan/Type/data/view-exists.php new file mode 100644 index 000000000..612095cb4 --- /dev/null +++ b/tests/PHPStan/Type/data/view-exists.php @@ -0,0 +1,14 @@ +exists($view)) { + assertType('view-string', $view); + } + + assertType('string', $view); +} From 16c592eb8c489461c825087b877cbe79cca800f7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:48:29 +0000 Subject: [PATCH 023/303] Reflect inherited dynamic view and redirect data methods Describe concrete view and redirect with-methods, including inherited implementations and their fluent subclass results. Preserve native method precedence and keep arbitrary view contracts free of implementation-only magic methods. Mark these calls as side-effecting because they update view data or session state. Cover concrete subclasses, native overrides and contract-only view results in the existing reflection and typing harnesses. --- ...esponseMethodsClassReflectionExtension.php | 45 ++++ ...iewWithMethodsClassReflectionExtension.php | 46 ++++ .../DynamicWhereMethodReflection.php | 201 ++++++++++++++++++ .../Fixtures/Type/CustomRedirectResponse.php | 18 ++ tests/PHPStan/Fixtures/Type/CustomView.php | 11 + ...nseMethodsClassReflectionExtensionTest.php | 87 ++++++++ .../MethodsClassReflectionExtensionTest.php | 32 +++ tests/PHPStan/Type/data/redirect-response.php | 16 ++ tests/PHPStan/Type/data/view.php | 20 ++ 9 files changed, 476 insertions(+) create mode 100644 src/phpstan/src/Methods/RedirectResponseMethodsClassReflectionExtension.php create mode 100644 src/phpstan/src/Methods/ViewWithMethodsClassReflectionExtension.php create mode 100644 src/phpstan/src/Reflection/DynamicWhereMethodReflection.php create mode 100644 tests/PHPStan/Fixtures/Type/CustomRedirectResponse.php create mode 100644 tests/PHPStan/Fixtures/Type/CustomView.php create mode 100644 tests/PHPStan/Reflection/RedirectResponseMethodsClassReflectionExtensionTest.php create mode 100644 tests/PHPStan/Type/MethodsClassReflectionExtensionTest.php create mode 100644 tests/PHPStan/Type/data/redirect-response.php create mode 100644 tests/PHPStan/Type/data/view.php diff --git a/src/phpstan/src/Methods/RedirectResponseMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/RedirectResponseMethodsClassReflectionExtension.php new file mode 100644 index 000000000..53af51b45 --- /dev/null +++ b/src/phpstan/src/Methods/RedirectResponseMethodsClassReflectionExtension.php @@ -0,0 +1,45 @@ +is(RedirectResponse::class)) { + return false; + } + + if (! str_starts_with($methodName, 'with')) { + return false; + } + + return true; + } + + /** + * Get the reflection of the dynamic with*() method. + */ + public function getMethod( + ClassReflection $classReflection, + string $methodName, + ): MethodReflection { + return new DynamicWhereMethodReflection($classReflection, $methodName); + } +} diff --git a/src/phpstan/src/Methods/ViewWithMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/ViewWithMethodsClassReflectionExtension.php new file mode 100644 index 000000000..93eb43a19 --- /dev/null +++ b/src/phpstan/src/Methods/ViewWithMethodsClassReflectionExtension.php @@ -0,0 +1,46 @@ +is(View::class)) { + return false; + } + + if (! str_starts_with($methodName, 'with')) { + return false; + } + + return true; + } + + /** + * Get the reflection of the dynamic with*() method. + */ + public function getMethod( + ClassReflection $classReflection, + string $methodName, + ): MethodReflection { + return new DynamicWhereMethodReflection($classReflection, $methodName); + } +} diff --git a/src/phpstan/src/Reflection/DynamicWhereMethodReflection.php b/src/phpstan/src/Reflection/DynamicWhereMethodReflection.php new file mode 100644 index 000000000..a2a230fad --- /dev/null +++ b/src/phpstan/src/Reflection/DynamicWhereMethodReflection.php @@ -0,0 +1,201 @@ +classReflection; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Get the method's doc comment. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Get the method name. + */ + public function getName(): string + { + return $this->methodName; + } + + /** + * Get the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Get the method's single variant, which accepts one value and returns the object. + * + * @return FunctionVariant[] + */ + public function getVariants(): array + { + return [ + new FunctionVariant( + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + [ + new class implements ParameterReflection { + /** + * Get the parameter name. + */ + public function getName(): string + { + return 'dynamic-with'; + } + + /** + * Determine whether the parameter is optional. + */ + public function isOptional(): bool + { + return false; + } + + /** + * Get the parameter type. + */ + public function getType(): Type + { + return new MixedType; + } + + /** + * Determine how the parameter is passed. + */ + public function passedByReference(): PassedByReference + { + return PassedByReference::createNo(); + } + + /** + * Determine whether the parameter is variadic. + */ + public function isVariadic(): bool + { + return false; + } + + /** + * Get the parameter's default value. + */ + public function getDefaultValue(): ?Type + { + return null; + } + }, + ], + false, + new ObjectType($this->classReflection->getName()), + ), + ]; + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Get the deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return null; + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Get the type the method throws. + */ + public function getThrowType(): ?Type + { + return null; + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + // Upstream reports none, which makes PHPStan call an unused $response->withFoo() + // statement useless, but the call stores the value on the response or view. + return TrinaryLogic::createYes(); + } +} diff --git a/tests/PHPStan/Fixtures/Type/CustomRedirectResponse.php b/tests/PHPStan/Fixtures/Type/CustomRedirectResponse.php new file mode 100644 index 000000000..12f8cb58b --- /dev/null +++ b/tests/PHPStan/Fixtures/Type/CustomRedirectResponse.php @@ -0,0 +1,18 @@ +reflectionProvider = $this->createReflectionProvider(); + $this->reflectionExtension = new RedirectResponseMethodsClassReflectionExtension; + } + + #[Test] + #[DataProvider('greenMethodProvider')] + public function itWillFindMethodsStartingWithWith(string $methodName): void + { + $requestClass = $this->reflectionProvider->getClass(RedirectResponse::class); + + $this->assertTrue($this->reflectionExtension->hasMethod($requestClass, $methodName)); + } + + #[Test] + #[DataProvider('redMethodProvider')] + public function itWillNotFindMethods(string $methodName): void + { + $requestClass = $this->reflectionProvider->getClass(RedirectResponse::class); + + $this->assertFalse($this->reflectionExtension->hasMethod($requestClass, $methodName)); + } + + /** @return iterable */ + public static function redMethodProvider(): iterable + { + yield ['non-existent']; + yield ['aWith']; + yield ['WithFoo']; + } + + #[Test] + #[DataProvider('greenMethodProvider')] + public function itWillHaveCorrectMethodReflection(string $methodName): void + { + $requestClass = $this->reflectionProvider->getClass(RedirectResponse::class); + $methodReflection = $this->reflectionExtension->getMethod($requestClass, $methodName); + $parametersAcceptor = $methodReflection->getVariants()[0]; + + $this->assertSame($methodName, $methodReflection->getName()); + $this->assertSame($requestClass, $methodReflection->getDeclaringClass()); + $this->assertFalse($methodReflection->isStatic()); + $this->assertFalse($methodReflection->isPrivate()); + $this->assertTrue($methodReflection->isPublic()); + // The call stores the value on the response, so an unused result is not a useless statement. + $this->assertTrue($methodReflection->hasSideEffects()->yes()); + $this->assertCount(1, $parametersAcceptor->getParameters()); + $this->assertSame('mixed', $parametersAcceptor->getParameters()[0]->getType()->describe(VerbosityLevel::value())); + $this->assertSame(RedirectResponse::class, $parametersAcceptor->getReturnType()->describe(VerbosityLevel::value())); + } + + /** @return iterable */ + public static function greenMethodProvider(): iterable + { + yield ['withFoo']; + yield ['withFooAndBar']; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../Rules/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Type/MethodsClassReflectionExtensionTest.php b/tests/PHPStan/Type/MethodsClassReflectionExtensionTest.php new file mode 100644 index 000000000..167b53cd1 --- /dev/null +++ b/tests/PHPStan/Type/MethodsClassReflectionExtensionTest.php @@ -0,0 +1,32 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/redirect-response.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Type/data/redirect-response.php b/tests/PHPStan/Type/data/redirect-response.php new file mode 100644 index 000000000..86a121523 --- /dev/null +++ b/tests/PHPStan/Type/data/redirect-response.php @@ -0,0 +1,16 @@ +back()->withSuccess(true)); + assertType(RedirectResponse::class, redirect()->back()->withCookie('foo')); + assertType(CustomRedirectResponse::class, $customResponse->withSuccess(true)); + assertType('string', $customResponse->withFlag()); +} diff --git a/tests/PHPStan/Type/data/view.php b/tests/PHPStan/Type/data/view.php new file mode 100644 index 000000000..3a4ff0603 --- /dev/null +++ b/tests/PHPStan/Type/data/view.php @@ -0,0 +1,20 @@ +with('bar', 'baz')); + // The contract does not promise the view's dynamic with*() methods. + assertType('*ERROR*', view('foo')->withFoo('bar')); + assertType('Hypervel\View\View', $view->withFoo('bar')); + assertType('Hypervel\Tests\PHPStan\Fixtures\Type\CustomView', $customView->withFoo('bar')); +} From 7cbe5f2596bc9edf45f8469200113de4742d07b7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:48:29 +0000 Subject: [PATCH 024/303] Collect view usages and report undiscovered usages conservatively Port view usage collection across helpers, factories, facades, route registration and mail APIs. Match named arguments by their actual signatures and distinguish mail HTML content from view names. Collect view references from Blade includes and components. Keep unused-view reporting opt-in, explain its static-analysis limits, track resource directories and cover relative paths, function shadowing and named call forms. --- ...sedEmailAlternativeSyntaxViewCollector.php | 79 ++++++++++ .../Collectors/UsedEmailSendViewCollector.php | 92 +++++++++++ .../src/Collectors/UsedEmailViewCollector.php | 88 +++++++++++ .../UsedRouteFacadeViewCollector.php | 69 +++++++++ .../UsedViewFacadeMakeCollector.php | 69 +++++++++ .../Collectors/UsedViewFunctionCollector.php | 79 ++++++++++ .../UsedViewInAnotherViewCollector.php | 116 ++++++++++++++ .../src/Collectors/UsedViewMakeCollector.php | 62 ++++++++ src/phpstan/src/Rules/UnusedViewsRule.php | 124 +++++++++++++++ tests/PHPStan/Rules/UnusedViewsRuleTest.php | 143 +++++++++++++++++ tests/PHPStan/Rules/data/FooController.php | 146 ++++++++++++++++++ .../Rules/data/NamespacedViewFunction.php | 16 ++ 12 files changed, 1083 insertions(+) create mode 100644 src/phpstan/src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php create mode 100644 src/phpstan/src/Collectors/UsedEmailSendViewCollector.php create mode 100644 src/phpstan/src/Collectors/UsedEmailViewCollector.php create mode 100644 src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php create mode 100644 src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php create mode 100644 src/phpstan/src/Collectors/UsedViewFunctionCollector.php create mode 100644 src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php create mode 100644 src/phpstan/src/Collectors/UsedViewMakeCollector.php create mode 100644 src/phpstan/src/Rules/UnusedViewsRule.php create mode 100644 tests/PHPStan/Rules/UnusedViewsRuleTest.php create mode 100644 tests/PHPStan/Rules/data/FooController.php create mode 100644 tests/PHPStan/Rules/data/NamespacedViewFunction.php diff --git a/src/phpstan/src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php b/src/phpstan/src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php new file mode 100644 index 000000000..f772a7000 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php @@ -0,0 +1,79 @@ +> */ +#[AutowiredService] +final class UsedEmailAlternativeSyntaxViewCollector implements Collector +{ + private const array VIEW_PARAM_NAMES = ['view', 'html', 'text', 'markdown']; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return New_::class; + } + + /** + * Collect the views passed to a mail content constructor. + * + * @param New_ $node + */ + public function processNode(Node $node, Scope $scope): ?array + { + $class = $node->class; + + if (! $class instanceof Name) { + return null; + } + + if (count($node->getArgs()) < 1) { + return null; + } + + $class = $scope->resolveName($class); + + if ($class !== Content::class) { + return null; + } + + $views = []; + + foreach ($node->getArgs() as $index => $arg) { + if ($arg->name !== null) { + $paramName = $arg->name->name; + } else { + $paramName = self::VIEW_PARAM_NAMES[$index] ?? null; + } + + if ($paramName === null || ! in_array($paramName, self::VIEW_PARAM_NAMES, true)) { + continue; + } + + if (! $arg->value instanceof String_) { + continue; + } + + $views[] = ViewName::normalize($arg->value->value); + } + + return $views !== [] ? $views : null; + } +} diff --git a/src/phpstan/src/Collectors/UsedEmailSendViewCollector.php b/src/phpstan/src/Collectors/UsedEmailSendViewCollector.php new file mode 100644 index 000000000..fc3bfdcc0 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedEmailSendViewCollector.php @@ -0,0 +1,92 @@ + */ +#[AutowiredService] +final class UsedEmailSendViewCollector implements Collector +{ + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Collect the view passed to a mailer's send() call. + * + * @param CallLike $node + */ + public function processNode(Node $node, Scope $scope): ?string + { + if ( + ! $node instanceof StaticCall + && ! $node instanceof MethodCall + ) { + return null; + } + + $name = $node->name; + + if (! $name instanceof Identifier || $name->name !== 'send') { + return null; + } + + if (count($node->getArgs()) < 1) { + return null; + } + + if ($node instanceof StaticCall) { + $class = $node->class; + + if (! $class instanceof Name) { + return null; + } + + $class = $scope->resolveName($class); + + if (! (new ObjectType(Mail::class))->isSuperTypeOf(new ObjectType($class))->yes()) { + return null; + } + } else { + $type = $scope->getType($node->var); + + if (! (new ObjectType(MailerContract::class))->isSuperTypeOf($type)->yes()) { + return null; + } + } + + foreach ($node->getArgs() as $index => $arg) { + if ($arg->name !== null ? $arg->name->name !== 'view' : $index !== 0) { + continue; + } + + return ! $arg->unpack && $arg->value instanceof String_ + ? ViewName::normalize($arg->value->value) + : null; + } + + return null; + } +} diff --git a/src/phpstan/src/Collectors/UsedEmailViewCollector.php b/src/phpstan/src/Collectors/UsedEmailViewCollector.php new file mode 100644 index 000000000..5b0463280 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedEmailViewCollector.php @@ -0,0 +1,88 @@ + */ +#[AutowiredService] +final class UsedEmailViewCollector implements Collector +{ + private const array VIEW_METHOD_NAMES = ['view', 'html', 'text', 'markdown']; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Collect the view passed to a mailable, mail message or mail content method. + * + * @param MethodCall $node + */ + public function processNode(Node $node, Scope $scope): ?string + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + if (! in_array($name->name, self::VIEW_METHOD_NAMES, true)) { + return null; + } + + if (count($node->getArgs()) === 0) { + return null; + } + + $class = $node->var; + + $type = $scope->getType($class); + + $isContent = (new ObjectType(Content::class))->isSuperTypeOf($type)->yes(); + + if ($name->name === 'html' && ! $isContent) { + return null; + } + + if ( + ! (new ObjectType(Mailable::class))->isSuperTypeOf($type)->yes() + && ! (new ObjectType(MailMessage::class))->isSuperTypeOf($type)->yes() + && ! $isContent + ) { + return null; + } + + // Named arguments can be reordered, so the view is matched by name or position. The + // mailable and mail message text() methods name it textView; the others name it view. + $parameter = $name->name === 'text' && ! $isContent ? 'textView' : 'view'; + + $template = $node->getArg($parameter, 0)?->value; + + if (! $template instanceof String_) { + return null; + } + + return ViewName::normalize($template->value); + } +} diff --git a/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php b/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php new file mode 100644 index 000000000..f8a78593d --- /dev/null +++ b/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php @@ -0,0 +1,69 @@ + */ +#[AutowiredService] +final class UsedRouteFacadeViewCollector implements Collector +{ + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Collect the view registered by a Route::view() call. + * + * @param StaticCall $node + */ + public function processNode(Node $node, Scope $scope): ?string + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + if ($name->name !== 'view') { + return null; + } + + // Named arguments can be reordered, so the view is matched by name or position. + $template = $node->getArg('view', 1)?->value; + + if (! $template instanceof String_) { + return null; + } + + $class = $node->class; + + if (! $class instanceof Name) { + return null; + } + + $class = $scope->resolveName($class); + + if (! (new ObjectType(Route::class))->isSuperTypeOf(new ObjectType($class))->yes()) { + return null; + } + + return ViewName::normalize($template->value); + } +} diff --git a/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php b/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php new file mode 100644 index 000000000..020ac5755 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php @@ -0,0 +1,69 @@ + */ +#[AutowiredService] +final class UsedViewFacadeMakeCollector implements Collector +{ + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Collect the view made through the View facade. + * + * @param StaticCall $node + */ + public function processNode(Node $node, Scope $scope): ?string + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + if ($name->name !== 'make') { + return null; + } + + // Named arguments can be reordered, so the view is matched by name or position. + $template = $node->getArg('view', 0)?->value; + + if (! $template instanceof String_) { + return null; + } + + $class = $node->class; + + if (! $class instanceof Name) { + return null; + } + + $class = $scope->resolveName($class); + + if (! (new ObjectType(View::class))->isSuperTypeOf(new ObjectType($class))->yes()) { + return null; + } + + return ViewName::normalize($template->value); + } +} diff --git a/src/phpstan/src/Collectors/UsedViewFunctionCollector.php b/src/phpstan/src/Collectors/UsedViewFunctionCollector.php new file mode 100644 index 000000000..d281842d0 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedViewFunctionCollector.php @@ -0,0 +1,79 @@ + */ +#[AutowiredService] +final class UsedViewFunctionCollector implements Collector +{ + /** + * Create a new collector instance. + */ + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * Collect the view rendered through the view() helper. + * + * @param FuncCall $node + */ + public function processNode(Node $node, Scope $scope): ?string + { + $funcName = $node->name; + + if (! $funcName instanceof Name) { + return null; + } + + // Function names are case-insensitive. + if (strtolower((string) $this->reflectionProvider->resolveFunctionName($funcName, $scope)) !== 'view') { + return null; + } + + // Named arguments can be reordered, so the view is matched by name or position. + $template = $node->getArg('view', 0)?->value; + + if ($template instanceof String_) { + return ViewName::normalize($template->value); + } + + if ($template instanceof Concat) { + $template = $scope->getType($template); + + $constantStrings = $template->getConstantStrings(); + + if (count($constantStrings) !== 1) { + return null; + } + + return ViewName::normalize($constantStrings[0]->getValue()); + } + + return null; + } +} diff --git a/src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php b/src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php new file mode 100644 index 000000000..be2a27e36 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php @@ -0,0 +1,116 @@ +`, ``, `` — which resolve to the + * `components.` view (or its `.index` variant). `` is a slot, not a component. + * + * @see https://hypervel.org/docs/blade#anonymous-components + */ + private const string COMPONENT_TAG_REGEX = '/<\/?x-([a-zA-Z0-9][a-zA-Z0-9._-]*)/'; + + /** + * Create a new collector instance. + */ + public function __construct(private ViewParser $viewParser, private ViewFileHelper $viewFileHelper) + { + } + + /** + * Get the views referenced from other views. + * + * @return list + */ + public function getUsedViews(): array + { + $usedViews = []; + + foreach ($this->viewFileHelper->getAllViewFilePaths() as $viewFile) { + $parserNodes = $this->viewParser->getNodes($viewFile); + + $usedViews = array_merge($usedViews, $this->processNodes($parserNodes)); + } + + return $usedViews; + } + + /** + * Get the views referenced by the given view nodes. + * + * @param Stmt[] $nodes + * + * @return list + */ + private function processNodes(array $nodes): array + { + $usedViews = []; + + foreach ($nodes as $node) { + if (! $node instanceof InlineHTML) { + continue; + } + + preg_match_all(self::VIEW_NAME_REGEX, $node->value, $matches, PREG_SET_ORDER, 0); + + foreach ($matches as $match) { + $usedViews[] = ViewName::normalize($match[5]); + } + + $usedViews = array_merge($usedViews, $this->componentViews($node->value)); + } + + return $usedViews; + } + + /** + * Get the views referenced through anonymous Blade component tags in the given HTML. + * + * @return list + */ + private function componentViews(string $html): array + { + preg_match_all(self::COMPONENT_TAG_REGEX, $html, $matches, PREG_SET_ORDER, 0); + + $usedViews = []; + + foreach ($matches as $match) { + $name = $match[1]; + + if ($name === 'slot') { + continue; + } + + // The `resources/views/components` directory is commonly registered as its own view path, where + // an anonymous component is named by its short path (`alert`); the same file is also reachable + // as `components.alert` under `resources/views`. A component can additionally be an index file + // (`alert/index.blade.php`). Emit every form so whichever naming the project uses is matched. + foreach ([$name, 'components.' . $name] as $view) { + $usedViews[] = $view; + $usedViews[] = $view . '.index'; + } + } + + return $usedViews; + } +} diff --git a/src/phpstan/src/Collectors/UsedViewMakeCollector.php b/src/phpstan/src/Collectors/UsedViewMakeCollector.php new file mode 100644 index 000000000..060716ddf --- /dev/null +++ b/src/phpstan/src/Collectors/UsedViewMakeCollector.php @@ -0,0 +1,62 @@ + */ +#[AutowiredService] +final class UsedViewMakeCollector implements Collector +{ + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Collect the view made through a view factory. + * + * @param MethodCall $node + */ + public function processNode(Node $node, Scope $scope): ?string + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + if ($name->name !== 'make') { + return null; + } + + // Named arguments can be reordered, so the view is matched by name or position. + $template = $node->getArg('view', 0)?->value; + + if (! $template instanceof String_) { + return null; + } + + $class = $node->var; + + if (! (new ObjectType(Factory::class))->isSuperTypeOf($scope->getType($class))->yes()) { + return null; + } + + return ViewName::normalize($template->value); + } +} diff --git a/src/phpstan/src/Rules/UnusedViewsRule.php b/src/phpstan/src/Rules/UnusedViewsRule.php new file mode 100644 index 000000000..114ad2bac --- /dev/null +++ b/src/phpstan/src/Rules/UnusedViewsRule.php @@ -0,0 +1,124 @@ + */ +#[Conditional(attribute: new AutowiredService, if: '%checkUnusedViews%')] +final class UnusedViewsRule implements Rule +{ + /** @var null|list */ + private ?array $viewsUsedInOtherViews = null; + + /** + * Create a new rule instance. + */ + public function __construct(private UsedViewInAnotherViewCollector $usedViewInAnotherViewCollector, private ViewFileHelper $viewFileHelper) + { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * Report views without a statically discovered usage. + * + * @param DependencyTracker&Scope $scope + * @return RuleError[] + */ + public function processNode(Node $node, Scope $scope): array + { + // The rule reads the view files, so the result cache can reuse its errors only while they stay the same. + $this->viewFileHelper->trackAllViewDirectories($scope); + + if ($this->viewsUsedInOtherViews === null) { + $this->viewsUsedInOtherViews = $this->usedViewInAnotherViewCollector->getUsedViews(); + } + + $usedViews = collect([ + $node->get(UsedViewFunctionCollector::class), + $node->get(UsedEmailViewCollector::class), + $node->get(UsedEmailAlternativeSyntaxViewCollector::class), + $node->get(UsedEmailSendViewCollector::class), + $node->get(UsedViewMakeCollector::class), + $node->get(UsedViewFacadeMakeCollector::class), + $node->get(UsedRouteFacadeViewCollector::class), + $this->viewsUsedInOtherViews, + ])->flatten()->unique()->toArray(); + + /** @var Factory $factory */ + $factory = view(); + $finder = $factory->getFinder(); + + $allViews = iterator_to_array($this->viewFileHelper->getAllViewNames()); + + $usedViews = self::filterExistingViews($factory, $usedViews); + $allViews = self::filterExistingViews($factory, $allViews); + + $unusedViews = array_unique(array_diff($allViews, $usedViews)); + + $errors = []; + + foreach ($unusedViews as $file) { + $path = $finder->find($file); + + // Upstream says the view is not used, but only usages with literal view names + // in the analyzed code and views can be found. + $errors[] = RuleErrorBuilder::message('No usage of this view was found.') + ->tip('Views rendered with a dynamic name, or only by code outside the analyzed paths, are not detected.') + ->file($path) + ->line(0) + ->identifier('hypervel.unusedViews') + ->build(); + } + + return $errors; + } + + /** + * Get the given views that the view factory can find. + * + * @param string[] $views + * + * @return string[] + */ + private static function filterExistingViews(Factory $factory, array $views): array + { + return array_filter($views, static function (string $view) use ($factory): bool { + return $factory->exists($view); + }); + } +} diff --git a/tests/PHPStan/Rules/UnusedViewsRuleTest.php b/tests/PHPStan/Rules/UnusedViewsRuleTest.php new file mode 100644 index 000000000..d6e3a703f --- /dev/null +++ b/tests/PHPStan/Rules/UnusedViewsRuleTest.php @@ -0,0 +1,143 @@ + */ +class UnusedViewsRuleTest extends RuleTestCase +{ + /** @var array{string, int, string} */ + private const array UNUSED_VIEW_ERROR = [ + 'No usage of this view was found.', + 0, + 'Views rendered with a dynamic name, or only by code outside the analyzed paths, are not detected.', + ]; + + /** @var list */ + private array $viewDirectories = [ + __DIR__ . '/../application/resources/views/', + __DIR__ . '/../../../src/foundation/src/Exceptions/views', + ]; + + private ?string $workingDirectory = null; + + protected function getRule(): Rule + { + $viewParser = new ViewParser($this->getContainer()->getService('currentPhpVersionSimpleDirectParser')); + $fileHelper = $this->workingDirectory === null ? $this->getFileHelper() : new FileHelper($this->workingDirectory); + $viewFileHelper = new ViewFileHelper($this->viewDirectories, $fileHelper); + + return new UnusedViewsRule(new UsedViewInAnotherViewCollector($viewParser, $viewFileHelper), $viewFileHelper); + } + + /** @return array> */ + protected function getCollectors(): array + { + return [ + new UsedViewFunctionCollector($this->createReflectionProvider()), + new UsedEmailViewCollector, + new UsedEmailAlternativeSyntaxViewCollector, + new UsedEmailSendViewCollector, + new UsedViewMakeCollector, + new UsedViewFacadeMakeCollector, + new UsedRouteFacadeViewCollector, + ]; + } + + public function testRule(): void + { + // `unused.blade.php` and `components/orphan.blade.php` are referenced nowhere. The + // `components/alert.blade.php` view IS used, but only through the `` Blade component tag + // in `index.blade.php` — so it must not be reported (regression guard for component-tag detection). + // The translation rule's `translation-lookups.blade.php` fixture is referenced nowhere either. + $this->analyse([__DIR__ . '/data/FooController.php'], [ + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + ]); + } + + public function testNamespacedViewFunctionIsNotHypervelView(): void + { + $this->analyse([ + __DIR__ . '/data/FooController.php', + __DIR__ . '/data/NamespacedViewFunction.php', + ], [ + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + ]); + } + + public function testDiscoveredViewDirectories(): void + { + // The booted finder's namespace hints, such as pagination's, are scanned for usages but not reported. + $this->viewDirectories = []; + + $this->analyse([__DIR__ . '/data/FooController.php'], [ + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + ]); + } + + public function testRelativeViewDirectories(): void + { + // The test container's own working directory is PHPStan's package root. + $this->workingDirectory = dirname(__DIR__, 3); + $this->viewDirectories = ['tests/PHPStan/application/resources/views']; + + $this->analyse([__DIR__ . '/data/FooController.php'], [ + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + ]); + } + + public function testTracksViewDirectories(): void + { + $tracked = []; + $scope = $this->createStubForIntersectionOfInterfaces([Scope::class, DependencyTracker::class]); + $scope->method('trackDirectoryDependency')->willReturnCallback(static function (string $directory, string $pattern) use (&$tracked): void { + $tracked[] = [$directory, $pattern]; + }); + + $this->getRule()->processNode(new CollectedDataNode([], false), $scope); + + $this->assertSame([ + [__DIR__ . '/../application/resources/views/', '*.blade.php'], + [__DIR__ . '/../../../src/foundation/src/Exceptions/views', '*.blade.php'], + ], $tracked); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../src/phpstan/extension.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/data/FooController.php b/tests/PHPStan/Rules/data/FooController.php new file mode 100644 index 000000000..0ac00c9d7 --- /dev/null +++ b/tests/PHPStan/Rules/data/FooController.php @@ -0,0 +1,146 @@ +markdown('emails.mailable.markdown'); + } + + public function bar(): self + { + $this->html('unused'); + + return $this->view('emails.mailable.view'); + } + + public function content(): Content + { + $foo = new Content('emails.mailable.content.view',); + $bar = new Content(markdown: 'emails.mailable.content.markdown'); + + $foo + ->text('emails.mailable.content.text') + ->html('emails.mailable.content.html'); + + return $bar; + } +} + +class FooMailMessage extends MailMessage +{ + public function build(): self + { + return $this->markdown('emails.mail-message.markdown'); + } + + public function bar(): self + { + return $this->view('emails.mail-message.view'); + } +} + +function mailFacadeSend(): void +{ + Mail::send(data: [], view: 'emails.mail-send.send-static'); + + Mail::to('mail@example.com') + ->send(new FooMailable()); +} + +function mailerContractSend(MailerContract $mailer): void +{ + $mailer->send(data: [], view: 'emails.mail-send.mailer'); + $mailer->send('emails.mail-send.send'); +} + +function viewHelper(): View +{ + return view()->make('view-helper-make'); +} + +function viewFactory(Factory $factory): View +{ + return $factory->make('view-factory-make'); +} + +function viewStaticMake(): View +{ + return \Hypervel\Support\Facades\View::make('view-static-make'); +} + +function routeView(): void +{ + Route::view('/welcome', 'route-view'); +} + +function dummyTranslationView() +{ + return view('translations'); +} + +class SomeClass { + function someView(): void + { + view('modals/' . __FUNCTION__); + } +} + +// Hypervel: views passed as reordered named arguments. +function namedArguments(Factory $factory): void +{ + view(data: [], view: 'named-arguments.helper'); + $factory->make(data: [], view: 'named-arguments.factory-make'); + \Hypervel\Support\Facades\View::make(data: [], view: 'named-arguments.facade-make'); + Route::view(view: 'named-arguments.route-view', uri: '/named-arguments'); +} + +class NamedArgumentsMailable extends Mailable +{ + public function build(): self + { + return $this->text(data: [], textView: 'named-arguments.mailable-text'); + } +} + +class NamedArgumentsMailMessage extends MailMessage +{ + public function build(): self + { + return $this->view(data: [], view: 'named-arguments.mail-message-view'); + } +} diff --git a/tests/PHPStan/Rules/data/NamespacedViewFunction.php b/tests/PHPStan/Rules/data/NamespacedViewFunction.php new file mode 100644 index 000000000..b2cf8f23e --- /dev/null +++ b/tests/PHPStan/Rules/data/NamespacedViewFunction.php @@ -0,0 +1,16 @@ + Date: Wed, 4 Nov 2026 17:48:30 +0000 Subject: [PATCH 025/303] Analyze translation lookups with framework locale semantics Collect known translation keys and lookup arguments from PHP and executable Blade fragments. Preserve source lines, distinguish callable creation from invocation, and exclude literal text, removed comments and unknown custom-directive semantics. Use the booted standard translator's locale and choice resolution to preserve fallback JSON, namespaces, boot-added lines and identical key/value translations without executing missing-key callbacks. Track translation and view resource dependencies and remain conservative for custom translators and loaders. Port translation return inference while retaining explicit narrower declarations for translator implementations and Lang facade subclasses. Cover named and dynamic lookup arguments, native PHP, raw-block handling, helper shadowing and declared-return precision. --- .../UsedTranslationFacadeCollector.php | 73 ++++ .../UsedTranslationFunctionCollector.php | 72 ++++ .../UsedTranslationTranslatorCollector.php | 68 +++ .../UsedTranslationViewCollector.php | 386 ++++++++++++++++++ .../Concerns/CollectsTranslationLookups.php | 121 ++++++ ...bleUnderscoreHelperReturnTypeExtension.php | 51 +++ .../LangGetReturnTypeExtension.php | 67 +++ .../TransHelperReturnTypeExtension.php | 51 +++ .../TranslatorGetReturnTypeExtension.php | 67 +++ .../src/Rules/NoMissingTranslationsRule.php | 215 ++++++++++ .../Rules/BootRegisteredTranslations.php | 20 + .../Rules/ResolvedLocaleTranslations.php | 17 + .../Fixtures/Rules/TranslationLookups.php | 44 ++ tests/PHPStan/Fixtures/Rules/lang/en.json | 3 + .../Fixtures/Rules/lang/en/lookups.php | 8 + .../Fixtures/Rules/lang/es/lookups.php | 7 + .../Fixtures/Rules/lang/fr/lookups.php | 7 + tests/PHPStan/Fixtures/Type/StringLang.php | 18 + .../Fixtures/Type/StringTranslator.php | 18 + .../Rules/NoMissingTranslationsRuleTest.php | 306 ++++++++++++++ .../data/NamespacedTranslationFunction.php | 23 ++ tests/PHPStan/Rules/data/Translation.php | 48 +++ tests/PHPStan/Type/data/translate.php | 25 ++ tests/PHPStan/Type/data/translator.php | 21 + 24 files changed, 1736 insertions(+) create mode 100644 src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php create mode 100644 src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php create mode 100644 src/phpstan/src/Collectors/UsedTranslationTranslatorCollector.php create mode 100644 src/phpstan/src/Collectors/UsedTranslationViewCollector.php create mode 100644 src/phpstan/src/Concerns/CollectsTranslationLookups.php create mode 100644 src/phpstan/src/ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/TranslatorGetReturnTypeExtension.php create mode 100644 src/phpstan/src/Rules/NoMissingTranslationsRule.php create mode 100644 tests/PHPStan/Fixtures/Rules/BootRegisteredTranslations.php create mode 100644 tests/PHPStan/Fixtures/Rules/ResolvedLocaleTranslations.php create mode 100644 tests/PHPStan/Fixtures/Rules/TranslationLookups.php create mode 100644 tests/PHPStan/Fixtures/Rules/lang/en.json create mode 100644 tests/PHPStan/Fixtures/Rules/lang/en/lookups.php create mode 100644 tests/PHPStan/Fixtures/Rules/lang/es/lookups.php create mode 100644 tests/PHPStan/Fixtures/Rules/lang/fr/lookups.php create mode 100644 tests/PHPStan/Fixtures/Type/StringLang.php create mode 100644 tests/PHPStan/Fixtures/Type/StringTranslator.php create mode 100644 tests/PHPStan/Rules/NoMissingTranslationsRuleTest.php create mode 100644 tests/PHPStan/Rules/data/NamespacedTranslationFunction.php create mode 100644 tests/PHPStan/Rules/data/Translation.php create mode 100644 tests/PHPStan/Type/data/translate.php create mode 100644 tests/PHPStan/Type/data/translator.php diff --git a/src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php b/src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php new file mode 100644 index 000000000..6aeb1e679 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php @@ -0,0 +1,73 @@ + + */ +#[AutowiredService] +final class UsedTranslationFacadeCollector implements Collector +{ + use CollectsTranslationLookups; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Collect the translation looked up through the Lang facade. + * + * @param StaticCall $node + * + * @return null|TranslationLookup + */ + public function processNode(Node $node, Scope $scope): ?array + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + if (! in_array($name->name, ['get', 'choice'], true)) { + return null; + } + + $class = $node->class; + + if (! $class instanceof Name) { + return null; + } + + $class = $scope->resolveName($class); + + if (! (new ObjectType(Lang::class))->isSuperTypeOf(new ObjectType($class))->yes()) { + return null; + } + + return $name->name === 'get' + ? $this->translationLookup($node, choice: false, acceptsFallback: true, scope: $scope) + : $this->translationLookup($node, choice: true, acceptsFallback: false, scope: $scope); + } +} diff --git a/src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php b/src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php new file mode 100644 index 000000000..4a5c16d0b --- /dev/null +++ b/src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php @@ -0,0 +1,72 @@ + + */ +#[AutowiredService] +final class UsedTranslationFunctionCollector implements Collector +{ + use CollectsTranslationLookups; + + /** + * Create a new collector instance. + */ + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * Collect the translation looked up through a translation helper. + * + * @param FuncCall $node + * + * @return null|TranslationLookup + */ + public function processNode(Node $node, Scope $scope): ?array + { + if (! $node->name instanceof Name) { + return null; + } + + // Upstream resolves the name as a class name, which misses a namespace's own function + // that shadows the global helper. + $function = strtolower((string) $this->reflectionProvider->resolveFunctionName($node->name, $scope)); + + if (! in_array($function, ['__', 'trans', 'trans_choice'], true)) { + return null; + } + + return $this->translationLookup( + $node, + choice: $function === 'trans_choice', + acceptsFallback: false, + scope: $scope, + ); + } +} diff --git a/src/phpstan/src/Collectors/UsedTranslationTranslatorCollector.php b/src/phpstan/src/Collectors/UsedTranslationTranslatorCollector.php new file mode 100644 index 000000000..fb25cc93d --- /dev/null +++ b/src/phpstan/src/Collectors/UsedTranslationTranslatorCollector.php @@ -0,0 +1,68 @@ + + */ +#[AutowiredService] +final class UsedTranslationTranslatorCollector implements Collector +{ + use CollectsTranslationLookups; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Collect the translation looked up through a translator. + * + * @param MethodCall $node + * + * @return null|TranslationLookup + */ + public function processNode(Node $node, Scope $scope): ?array + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + if (! in_array($name->name, ['get', 'choice'], true)) { + return null; + } + + $class = $node->var; + + // Upstream matches the concrete translator. Hypervel code injects the contract, + // which trans() also returns, and the concrete translator implements it. + if (! (new ObjectType(Translator::class))->isSuperTypeOf($scope->getType($class))->yes()) { + return null; + } + + return $name->name === 'get' + ? $this->translationLookup($node, choice: false, acceptsFallback: true, scope: $scope) + : $this->translationLookup($node, choice: true, acceptsFallback: false, scope: $scope); + } +} diff --git a/src/phpstan/src/Collectors/UsedTranslationViewCollector.php b/src/phpstan/src/Collectors/UsedTranslationViewCollector.php new file mode 100644 index 000000000..5f4a74690 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedTranslationViewCollector.php @@ -0,0 +1,386 @@ + + (?: + \s+ + (?: + @(?:class|style)(?\((?:[^()]++|(?&parentheses))*\)) + | + \{\{\s*\$attributes(?:[^}]+?)?\s*\}\} + | + :\$\w+ + | + [\w\-:.@%]+(?:=(?:"[^"]*"|'[^']*'|[^'"=<>]+))? + ) + )* + \s* + ) + \/?> + /x + REGEXP; + + /** + * Component attributes bound with a single colon, whose values are PHP expressions. + */ + private const string BOUND_ATTRIBUTE_REGEX = '/(?[^"]*)"|\'(?[^\']*)\')/'; + + /** + * The start of a directive with arguments, up to its opening parenthesis. + * + * Blade leaves an @@ prefixed directive, or one that follows a word character as in + * an email address, as text. + */ + private const string DIRECTIVE_REGEX = '/(? + */ + private const array ECHO_REGEXES = [ + '/(@)?\{!!\s*(.+?)\s*!!\}/s', + '/(@)?\{\{\{\s*(.+?)\s*\}\}\}/s', + '/(@)?\{\{\s*(.+?)\s*\}\}/s', + ]; + + /** + * Create a new collector instance. + */ + public function __construct( + private ViewParser $viewParser, + private ViewFileHelper $viewFileHelper, + private Filesystem $filesystem, + ) { + } + + /** + * Get the translations looked up by the root views, keyed by view file. + * + * @return array + */ + public function getUsedTranslations(): array + { + $translations = []; + + foreach ($this->viewFileHelper->getRootViewFilePaths() as $viewFile) { + $translations[$viewFile] = $this->processView($this->filesystem->get($viewFile)); + } + + return $translations; + } + + /** + * Get the translations looked up by the given view source. + * + * Upstream matches translation calls anywhere in a view's text, so literal text, + * JavaScript and string literals count as lookups, while native PHP does not. Only + * the code Blade runs is read here, in the order of Blade's own passes. Verbatim + * blocks, @php blocks and comments are blanked across the whole source, keeping its + * lines, and bound component attributes are read in place. The remaining source is + * then parsed: its PHP runs as written, and Blade compiles the directives and echoes + * in the text between PHP tags. + * + * @return list + */ + private function processView(string $source): array + { + $fragments = []; + + $verbatimBlocks = $this->take(self::VERBATIM_REGEX, $source); + $phpBlocks = $this->take(self::PHP_BLOCK_REGEX, $source); + $comments = $this->take(self::COMMENT_REGEX, $source); + + // Blade sets verbatim and @php blocks aside before it removes comments, so a comment + // around a block removes the block too. + $commented = static fn (int $offset): bool => array_any( + $comments, + static fn (array $comment): bool => $offset >= $comment[0][1] && $offset < $comment[0][1] + strlen($comment[0][0]), + ); + + // Blade restores a verbatim block as written: its Blade syntax stays text, while its + // PHP tags still run. + foreach ($verbatimBlocks as $match) { + if (! $commented($match[1][1])) { + $fragments[] = [$match[1][0], 1 + substr_count($source, "\n", 0, $match[1][1])]; + } + } + + foreach ($phpBlocks as $match) { + if (! $commented($match[1][1])) { + $fragments[] = ["", 1 + substr_count($source, "\n", 0, $match[1][1])]; + } + } + + preg_match_all(self::COMPONENT_TAG_REGEX, $source, $tags, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + foreach ($tags as $tag) { + preg_match_all(self::BOUND_ATTRIBUTE_REGEX, $tag['attributes'][0], $attributes, PREG_SET_ORDER | PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL); + + foreach ($attributes as $attribute) { + [$value, $offset] = $attribute['double'][0] === null ? $attribute['single'] : $attribute['double']; + + $fragments[] = ["viewParser->parseCode($source); + + foreach ((new NodeFinder)->findInstanceOf($statements, InlineHTML::class) as $node) { + foreach ($this->textFragments($node->value) as [$code, $offset]) { + $fragments[] = [$code, $node->getStartLine() + substr_count($node->value, "\n", 0, $offset)]; + } + } + + $translations = $this->lookups($statements, 0); + + foreach ($fragments as [$code, $line]) { + // Most fragments make no translation call, so they are not parsed. + if (preg_match('/trans|__|lang/i', $code) === 1) { + array_push($translations, ...$this->lookups($this->viewParser->parseCode($code), $line - 1)); + } + } + + return $translations; + } + + /** + * Get the PHP that Blade compiles from the directives and echoes in the given text, with each fragment's offset. + * + * Directives compile before echoes, as in Blade. Each echo is blanked as it is taken, + * keeping offsets, so a later echo pattern cannot read it again. + * + * @return list + */ + private function textFragments(string $html): array + { + $fragments = []; + + preg_match_all(self::DIRECTIVE_REGEX, $html, $directives, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + $end = 0; + + foreach ($directives as $directive) { + // Blade reads a directive inside another's arguments as part of those arguments. + if ($directive[0][1] < $end) { + continue; + } + + $start = $directive[0][1] + strlen($directive[0][0]); + $arguments = $this->argumentList($html, $start); + + if ($arguments === null) { + continue; + } + + $end = $start + strlen($arguments); + $code = $this->directiveCode($directive[1][0], $arguments); + + if ($code !== null) { + $fragments[] = [$code, $start]; + } + } + + foreach (self::ECHO_REGEXES as $pattern) { + foreach ($this->take($pattern, $html) as $match) { + if ($match[1][0] === null) { + $fragments[] = ["resolve('blade.compiler'); + + if (isset($compiler->getCustomDirectives()[$name]) || ! method_exists($compiler, 'compile' . ucfirst($name))) { + return null; + } + + return match (strtolower($name)) { + 'lang' => " " " " "> + */ + private function take(string $pattern, string &$html): array + { + preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL); + + $html = (string) preg_replace_callback( + $pattern, + static fn (array $match): string => (string) preg_replace('/[^\n]/', ' ', $match[0]), + $html, + ); + + return $matches; + } + + /** + * Get the lookups made by PHP from a view. + * + * Upstream unescapes each matched key and ignores the other arguments. Parsing the + * code reads keys exactly as PHP does, skips interpolated keys, and supplies the + * locale and fallback arguments. + * + * @param Stmt[] $statements + * @param int $lineOffset the number of view lines before the code's first line + * @return list + */ + private function lookups(array $statements, int $lineOffset): array + { + $lookups = []; + + foreach ((new NodeFinder)->findInstanceOf($statements, CallLike::class) as $call) { + // PHPStan turns a first-class callable into its own node, but views are parsed directly. + if ($call->isFirstClassCallable()) { + continue; + } + + $name = match (true) { + $call instanceof FuncCall && $call->name instanceof Name => $call->name->toLowerString(), + $call instanceof StaticCall && $call->class instanceof Name && $call->name instanceof Identifier => "{$call->class->toLowerString()}::{$call->name->toLowerString()}", + default => null, + }; + + $operation = match ($name) { + '__', 'trans' => [false, false], + 'trans_choice', 'lang::choice' => [true, false], + 'lang::get' => [false, true], + default => null, + }; + + if ($operation === null) { + continue; + } + + $lookup = $this->translationLookup($call, choice: $operation[0], acceptsFallback: $operation[1], scope: null); + + if ($lookup !== null) { + $lookup['line'] = $lineOffset + $call->getStartLine(); + $lookups[] = $lookup; + } + } + + return $lookups; + } + + /** + * Get the parenthesized argument list starting at the given offset of the view text. + */ + private function argumentList(string $html, int $offset): ?string + { + // Try each closing parenthesis in turn, so only the call itself is tokenized. + // PHP's tokenizer keeps parentheses inside strings and comments out of the count. + for ($end = strpos($html, ')', $offset); $end !== false; $end = strpos($html, ')', $end + 1)) { + $arguments = substr($html, $offset, $end + 1 - $offset); + $depth = 0; + + foreach (array_slice(token_get_all(', fallback: bool, choice: bool} + */ +trait CollectsTranslationLookups +{ + /** + * Get the lookup made by a call with a literal translation key. + * + * @param bool $choice whether the call is a choice() lookup, whose locale follows the count + * @param bool $acceptsFallback whether the call has get()'s fallback parameter + * @return null|TranslationLookup + */ + protected function translationLookup(CallLike $call, bool $choice, bool $acceptsFallback, ?Scope $scope): ?array + { + foreach ($call->getArgs() as $argument) { + // Arguments after an unpacked one have unknown positions. + if ($argument->unpack) { + return null; + } + } + + $key = $call->getArg('key', 0)?->value; + + if (! $key instanceof String_) { + return null; + } + + $locales = $this->translationLocales($call->getArg('locale', $choice ? 3 : 2)?->value, $scope); + + if ($locales === null) { + return null; + } + + $fallback = ! $acceptsFallback || $this->translationFallback($call->getArg('fallback', 3)?->value, $scope); + + return [ + 'key' => $key->value, + 'line' => $call->getStartLine(), + 'locales' => $locales, + 'fallback' => $fallback, + 'choice' => $choice, + ]; + } + + /** + * Get the locales a lookup can use, where null is the translator's current locale. + * + * @return null|list + */ + protected function translationLocales(?Expr $locale, ?Scope $scope): ?array + { + if ($locale === null) { + return [null]; + } + + if ($scope === null) { + return match (true) { + $locale instanceof String_ => [$locale->value], + $locale instanceof ConstFetch && $locale->name->toLowerString() === 'null' => [null], + default => null, + }; + } + + $type = $scope->getType($locale); + $strings = TypeCombinator::removeNull($type)->getConstantStrings(); + + if ($strings === []) { + return $type->isNull()->yes() ? [null] : null; + } + + $locales = array_map(static fn (ConstantStringType $string): string => $string->getValue(), $strings); + + if ($type->isNull()->maybe()) { + $locales[] = null; + } + + return $locales; + } + + /** + * Determine whether a lookup can use the fallback locale. + * + * An unknown value counts as true: a translation missing with the fallback + * locale is also missing without it. + */ + protected function translationFallback(?Expr $fallback, ?Scope $scope): bool + { + if ($fallback === null) { + return true; + } + + if ($scope === null) { + return ! ($fallback instanceof ConstFetch && $fallback->name->toLowerString() === 'false'); + } + + return ! $scope->getType($fallback)->isFalse()->yes(); + } +} diff --git a/src/phpstan/src/ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php new file mode 100644 index 000000000..262b9d765 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php @@ -0,0 +1,51 @@ +getName() === '__'; + } + + /** + * Get the translation type for a key that cannot be null. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + // Upstream returns null only for a call without arguments. An explicit or + // possibly null key keeps the helper's declared conditional return type. + $key = $functionCall->getArgs()[0] ?? null; + + if ($key === null || $key->unpack || ! $scope->getType($key->value)->isNull()->no()) { + return null; + } + + return new BenevolentUnionType([ + new ArrayType(new MixedType, new MixedType), + new StringType, + ]); + } +} diff --git a/src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php new file mode 100644 index 000000000..162282f21 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php @@ -0,0 +1,67 @@ +getName() === 'get'; + } + + /** + * Get the translation type. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $translation = new UnionType([ + new ArrayType(new MixedType, new MixedType), + new StringType, + ]); + + $declaredType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $methodReflection->getVariants(), + )->getReturnType(); + + // Upstream always returns the union, which widens a facade whose get() declares a narrower type. + if (! $declaredType->isSuperTypeOf($translation)->yes()) { + return null; + } + + return new BenevolentUnionType($translation->getTypes()); + } +} diff --git a/src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php new file mode 100644 index 000000000..d16815b06 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php @@ -0,0 +1,51 @@ +getName() === 'trans'; + } + + /** + * Get the translation type for a key that cannot be null. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + // Upstream returns the translator only for a call without arguments. An explicit + // or possibly null key keeps the helper's declared conditional return type. + $key = $functionCall->getArgs()[0] ?? null; + + if ($key === null || $key->unpack || ! $scope->getType($key->value)->isNull()->no()) { + return null; + } + + return new BenevolentUnionType([ + new ArrayType(new MixedType, new MixedType), + new StringType, + ]); + } +} diff --git a/src/phpstan/src/ReturnTypes/TranslatorGetReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/TranslatorGetReturnTypeExtension.php new file mode 100644 index 000000000..4e7e18917 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/TranslatorGetReturnTypeExtension.php @@ -0,0 +1,67 @@ +getName() === 'get'; + } + + /** + * Get the translation type. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $translation = new UnionType([ + new ArrayType(new MixedType, new MixedType), + new StringType, + ]); + + $declaredType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $methodReflection->getVariants(), + )->getReturnType(); + + // Upstream always returns the union, which widens a translator whose get() declares a narrower type. + if (! $declaredType->isSuperTypeOf($translation)->yes()) { + return null; + } + + return new BenevolentUnionType($translation->getTypes()); + } +} diff --git a/src/phpstan/src/Rules/NoMissingTranslationsRule.php b/src/phpstan/src/Rules/NoMissingTranslationsRule.php new file mode 100644 index 000000000..6f5b0f239 --- /dev/null +++ b/src/phpstan/src/Rules/NoMissingTranslationsRule.php @@ -0,0 +1,215 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkMissingTranslations%')] +final class NoMissingTranslationsRule implements Rule +{ + use HasContainer; + + /** + * Create a new rule instance. + * + * @param string[] $translationDirectories directories that replace the application's translation sources + */ + public function __construct( + private UsedTranslationViewCollector $usedTranslationViewCollector, + private ViewFileHelper $viewFileHelper, + private Filesystem $filesystem, + #[AutowiredParameter] + private array $translationDirectories, + ) { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * Report collected translation lookups the translator cannot resolve. + * + * @param DependencyTracker&Scope $scope + * @return RuleError[] + */ + public function processNode(Node $node, Scope $scope): array + { + $translator = $this->translator(); + $loader = $translator instanceof Translator ? $translator->getLoader() : null; + + // A subclassed or custom translator or loader can resolve lines its own way, such + // as from an external service, so a missing line cannot be established. + if (! $translator instanceof Translator + || $translator::class !== Translator::class + || ! $loader instanceof FileLoader + || $loader::class !== FileLoader::class) { + return []; + } + + // The rule reads the translation files and the views, so the result cache can reuse its errors only while they stay the same. + foreach ([...$loader->paths(), ...$loader->jsonPaths(), ...array_values($loader->namespaces())] as $path) { + $scope->trackDirectoryDependency($path); + } + + $this->viewFileHelper->trackRootViewDirectories($scope); + + /** @var array[] $collectors */ + $collectors = [ + $node->get(UsedTranslationFunctionCollector::class), + $node->get(UsedTranslationTranslatorCollector::class), + $node->get(UsedTranslationFacadeCollector::class), + $this->usedTranslationViewCollector->getUsedTranslations(), + ]; + + $usedTranslations = []; + + foreach ($collectors as $collector) { + foreach ($collector as $file => $translations) { + if (! array_key_exists($file, $usedTranslations)) { + $usedTranslations[$file] = []; + } + + $usedTranslations[$file] = array_merge($usedTranslations[$file], $translations); + } + } + + $errors = []; + + foreach ($usedTranslations as $file => $translations) { + foreach ($translations as ['key' => $translation, 'line' => $line, 'locales' => $locales, 'fallback' => $fallback, 'choice' => $choice]) { + foreach ($locales as $locale) { + if (! $this->isMissing($translator, $translation, $locale, $fallback, $choice)) { + continue; + } + + $message = $locale === null || $locale === '' + ? sprintf('Translation "%s" has not been found.', $translation) + : sprintf('Translation "%s" has not been found for locale "%s".', $translation, $locale); + + $errors[] = RuleErrorBuilder::message($message) + ->file($file) + ->line($line) + ->identifier('hypervel.missingTranslations') + ->build(); + } + } + } + + return $errors; + } + + /** + * Get the translator the lookups are checked against. + */ + private function translator(): TranslatorContract + { + /** @var TranslatorContract $translator */ + $translator = $this->resolve('translator'); + + if ($this->translationDirectories === []) { + return $translator; + } + + // The configured directories replace the application's translation sources, + // as upstream's do, without changing the application's own translator. + $replacement = new Translator(new FileLoader($this->filesystem, $this->translationDirectories), $translator->getLocale()); + $replacement->setFallback($translator->getFallback()); + + return $replacement; + } + + /** + * Determine whether a structured key with a known group has no translation. + * + * The exact framework translator is checked before this runs, so its protected + * locale resolution, including a determineLocalesUsing() callback, is the one the + * lookup itself uses. + */ + private function isMissing(Translator $translator, string $key, ?string $locale, bool $fallback, bool $choice): bool + { + $locale = $locale ?: $translator->getLocale(); + + // choice() reads the fallback locale's line when the requested locale has none. + if ($choice) { + $locale = (fn (): string => $this->localeForChoice($key, $locale))->call($translator); + } + + if ($translator->has($key, $locale, $fallback)) { + return false; + } + + [$namespace, $group, $item] = $translator->parseKey($key); + $groupKey = $namespace === '*' ? $group : "{$namespace}::{$group}"; + $knownGroup = false; + + /** @var list $locales */ + $locales = $fallback ? (fn (): array => $this->localeArray($locale))->call($translator) : [$locale]; + + foreach ($locales as $groupLocale) { + // has() skips the application's missing-key callback, which get() runs for a missing group. + if (! $translator->has($groupKey, $groupLocale, false)) { + continue; + } + + $lines = $translator->get($groupKey, [], $groupLocale, false); + + if (! is_array($lines)) { + continue; + } + + $knownGroup = true; + + // has() compares the line with the key, so it misses a line equal to its key. + if ($item !== null && Arr::get($lines, $item) === $key) { + return false; + } + } + + return $knownGroup; + } +} diff --git a/tests/PHPStan/Fixtures/Rules/BootRegisteredTranslations.php b/tests/PHPStan/Fixtures/Rules/BootRegisteredTranslations.php new file mode 100644 index 000000000..6da19e7da --- /dev/null +++ b/tests/PHPStan/Fixtures/Rules/BootRegisteredTranslations.php @@ -0,0 +1,20 @@ + $arguments + */ + public function lookup(Translator $translator, string $locale, bool $french, bool $fallback, array $arguments): void + { + __('lookups.present'); + __('lookups.missing'); + __('lookups.identical'); + __('unknown.missing'); + + __('lookups.missing', [], 'fr'); + __('lookups.present', [], 'fr'); + Lang::get('lookups.present', [], 'fr', false); + Lang::get('lookups.french', [], 'fr', false); + Lang::get('lookups.present', [], 'fr', $fallback); + __('lookups.missing', [], $locale); + __('lookups.missing', [], $french ? 'fr' : 'en'); + __('lookups.missing', [], $french ? 'fr' : null); + + trans('lookups.missing', [], 'fr'); + trans_choice('lookups.missing', 1, [], 'fr'); + Lang::choice('lookups.missing', 1, [], 'fr'); + $translator->get('lookups.missing', locale: 'fr'); + $translator->choice('lookups.missing', 1, locale: 'fr'); + __(locale: 'fr', key: 'lookups.missing'); + __('lookups.missing', ...$arguments); + + trans_choice('lookups.apples', 1, [], 'fr'); + __('lookups.apples', [], 'fr'); + } +} diff --git a/tests/PHPStan/Fixtures/Rules/lang/en.json b/tests/PHPStan/Fixtures/Rules/lang/en.json new file mode 100644 index 000000000..1b06cedf0 --- /dev/null +++ b/tests/PHPStan/Fixtures/Rules/lang/en.json @@ -0,0 +1,3 @@ +{ + "lookups.apples": "Apples" +} diff --git a/tests/PHPStan/Fixtures/Rules/lang/en/lookups.php b/tests/PHPStan/Fixtures/Rules/lang/en/lookups.php new file mode 100644 index 000000000..bab0614d0 --- /dev/null +++ b/tests/PHPStan/Fixtures/Rules/lang/en/lookups.php @@ -0,0 +1,8 @@ + 'Present', + 'identical' => 'lookups.identical', +]; diff --git a/tests/PHPStan/Fixtures/Rules/lang/es/lookups.php b/tests/PHPStan/Fixtures/Rules/lang/es/lookups.php new file mode 100644 index 000000000..b1a32dd3a --- /dev/null +++ b/tests/PHPStan/Fixtures/Rules/lang/es/lookups.php @@ -0,0 +1,7 @@ + 'lookups.spanish', +]; diff --git a/tests/PHPStan/Fixtures/Rules/lang/fr/lookups.php b/tests/PHPStan/Fixtures/Rules/lang/fr/lookups.php new file mode 100644 index 000000000..48002ebfc --- /dev/null +++ b/tests/PHPStan/Fixtures/Rules/lang/fr/lookups.php @@ -0,0 +1,7 @@ + 'Français', +]; diff --git a/tests/PHPStan/Fixtures/Type/StringLang.php b/tests/PHPStan/Fixtures/Type/StringLang.php new file mode 100644 index 000000000..bf26211b8 --- /dev/null +++ b/tests/PHPStan/Fixtures/Type/StringLang.php @@ -0,0 +1,18 @@ + */ +class NoMissingTranslationsRuleTest extends RuleTestCase +{ + /** @var string[] */ + private array $translationDirectories = []; + + private ?Translator $translator = null; + + protected function getRule(): Rule + { + $viewParser = new ViewParser($this->getContainer()->getService('currentPhpVersionSimpleDirectParser')); + $viewFileHelper = new ViewFileHelper([], $this->getFileHelper()); + + $filesystem = new Filesystem; + $rule = new NoMissingTranslationsRule(new UsedTranslationViewCollector($viewParser, $viewFileHelper, $filesystem), $viewFileHelper, $filesystem, $this->translationDirectories); + + // A test's own translator replaces the application's without changing the shared application. + if ($this->translator !== null) { + $container = new Container; + $container->instance('translator', $this->translator); + $rule->setContainer($container); + } + + return $rule; + } + + /** @return array> */ + protected function getCollectors(): array + { + return [ + new UsedTranslationFunctionCollector($this->createReflectionProvider()), + new UsedTranslationTranslatorCollector, + new UsedTranslationFacadeCollector, + ]; + } + + public function testRule(): void + { + // Only keys whose group exists are reported, so the fixture application has a + // lang file for each group these keys use. + /** @see ../application/resources/views/translations.blade.php */ + $errorsFromBladeFile = [ + ['Translation "lang.get" has not been found.', 26], + ['Translation "lang.choice" has not been found.', 27], + // Lines 28 and 29 call Lang::trans() and Lang::transChoice(), which the translator does not have. + + ['Translation "a.b" has not been found.', 31], + ['Translation "a.b.c" has not been found.', 32], + ['Translation "a_b.c-d" has not been found.', 33], + ['Translation "a.b" has not been found.', 34], + ['Translation "a.b!" has not been found.', 35], + ['Translation "a.translate me!" has not been found.', 36], + ['Translation "a.über~" has not been found.', 37], + ['Translation "app.i\\\'m" has not been found.', 38], + ['Translation "app.i\'m" has not been found.', 39], + ['Translation "app.\"ok\"" has not been found.', 40], + ['Translation "app."ok"" has not been found.', 41], + ['Translation "a.b" has not been found.', 42], + + ['Translation "directive.lang" has not been found.', 44], + ['Translation "directive.choice" has not been found.', 45], + + ['Translation "surrounded.by.text" has not been found.', 47], + ['Translation "surrounded.by.html.tags" has not been found.', 48], + + ['Translation "trans.uppercase" has not been found.', 50], + // Line 51 calls another object's trans() method. + // REMOVED: line 52's $t() is a JavaScript translation function, whose messages may not come from the lang files. + ['Translation "double.underscore.helper.function" has not been found.', 53], + + ['Translation "lang.in.html.attribute" has not been found.', 55], + ['Translation "lang.with.extra.paramater" has not been found.', 56], + // Blade removes line 57's comment before compiling. + ['Translation "lang.in.html.comment" has not been found.', 58], + ['Translation "and.a.simple.translation.afterwards" has not been found.', 59], + // Blade leaves line 60's test@lang() as text, like an email address. + + ['Translation "lang.with.new' . "\n" . 'line.char" has not been found.', 71], + ['Translation "lang.spanning' . "\n" . 'multiple.lines" has not been found.', 72], + ['Translation "lang.with.a.back\slash.single.quotes" has not been found.', 74], + ['Translation "lang.with.a.back\slash.double.quotes" has not been found.', 75], + ['Translation "lang.with.a.hex.char" has not been found.', 76], + ]; + + $this->analyse([__DIR__ . '/data/Translation.php'], [ + ...$errorsFromBladeFile, + [ + 'Translation "messages.test" has not been found.', + 18, + ], + [ + 'Translation "messages.test" has not been found.', + 19, + ], + [ + 'Translation "messages.test" has not been found.', + 20, + ], + [ + 'Translation "messages.test" has not been found.', + 25, + ], + [ + 'Translation "messages.test" has not been found.', + 26, + ], + [ + 'Translation "messages.test" has not been found.', + 31, + ], + [ + 'Translation "messages.test" has not been found.', + 32, + ], + [ + 'Translation "sub/lines.farewell" has not been found.', + 40, + ], + ]); + } + + public function testTracksTranslationAndViewDirectories(): void + { + $tracked = []; + $scope = $this->createStubForIntersectionOfInterfaces([Scope::class, DependencyTracker::class]); + $scope->method('trackDirectoryDependency')->willReturnCallback(static function (string $directory, string $pattern) use (&$tracked): void { + $tracked[] = [$directory, $pattern]; + }); + + $this->getRule()->processNode(new CollectedDataNode([], false), $scope); + + $this->assertSame([ + [dirname(__DIR__, 3) . '/src/translation/lang', '*'], + [lang_path(), '*'], + ...array_map(static fn (string $path): array => [$path, '*.blade.php'], view()->getFinder()->getPaths()), + ], $tracked); + } + + public function testLookupArguments(): void + { + $this->translationDirectories = [__DIR__ . '/../Fixtures/Rules/lang']; + + // A custom directive replaces the built-in one, and its handler decides what its arguments mean. + $container = Container::getInstance(); + $compiler = $container->make('blade.compiler'); + $customCompiler = clone $compiler; + $customCompiler->directive('push', static fn (string $expression): string => ''); + $container->instance('blade.compiler', $customCompiler); + + /** @see ../application/resources/views/translation-lookups.blade.php */ + $errorsFromBladeFile = [ + ['Translation "lookups.missing" has not been found for locale "fr".', 1], + ['Translation "lookups.present" has not been found for locale "fr".', 3], + ['Translation "lookups.missing" has not been found for locale "fr".', 4], + ['Translation "lookups.missing" has not been found.', 5], + ['Translation "lookups.missing" has not been found.', 10], + ['Translation "lookups.missing" has not been found.', 11], + ['Translation "lookups.missing" has not been found.', 12], + // Blade comments, verbatim blocks, escaped echoes and directives, and other objects' + // trans() methods are not translator lookups; directives in HTML comments still run. + ['Translation "lookups.missing" has not been found.', 21], + // Literal text, JavaScript, string literals and attributes bound outside components do not run as PHP. + ['Translation "lookups.missing" has not been found.', 26], + ['Translation "lookups.missing" has not been found.', 27], + ['Translation "lookups.missing" has not been found.', 28], + ['Translation "lookups.missing" has not been found.', 29], + ['Translation "lookups.missing" has not been found.', 30], + ['Translation "lookups.missing" has not been found.', 32], + // Line 34's @lang is text inside another directive's arguments, and Blade leaves line 35's unknown directive as text. + ['Translation "lookups.missing" has not been found.', 36], + // Line 37 creates a closure rather than looking a key up. + ['Translation "lookups.missing" has not been found.', 38], + // Blade removes line 39's comment, PHP tags included, and line 40's @push is the custom directive. + // A comment removes the blocks inside it on lines 41 and 43; a verbatim block's PHP still runs. + ['Translation "lookups.missing" has not been found.', 42], + ]; + + try { + // The configured directories replace the application's, so none of its groups are known. + $this->analyse([__DIR__ . '/../Fixtures/Rules/TranslationLookups.php'], [ + ...$errorsFromBladeFile, + ['Translation "lookups.missing" has not been found.', 20], + ['Translation "lookups.missing" has not been found for locale "fr".', 24], + ['Translation "lookups.present" has not been found for locale "fr".', 26], + ['Translation "lookups.missing" has not been found for locale "en".', 30], + ['Translation "lookups.missing" has not been found for locale "fr".', 30], + ['Translation "lookups.missing" has not been found.', 31], + ['Translation "lookups.missing" has not been found for locale "fr".', 31], + ['Translation "lookups.missing" has not been found for locale "fr".', 33], + ['Translation "lookups.missing" has not been found for locale "fr".', 34], + ['Translation "lookups.missing" has not been found for locale "fr".', 35], + ['Translation "lookups.missing" has not been found for locale "fr".', 36], + ['Translation "lookups.missing" has not been found for locale "fr".', 37], + ['Translation "lookups.missing" has not been found for locale "fr".', 38], + // trans_choice() falls back to the en.json line; __() reads JSON only for the requested locale. + ['Translation "lookups.apples" has not been found for locale "fr".', 42], + ]); + } finally { + $container->instance('blade.compiler', $compiler); + } + } + + public function testBootRegisteredTranslations(): void + { + $translator = new Translator(new FileLoader(new Filesystem, []), 'en'); + $translator->setFallback('en'); + $translator->addNamespace('package', __DIR__ . '/../Fixtures/Rules/lang'); + $translator->addLines(['added.line' => 'Added'], 'en'); + $translator->handleMissingKeysUsing(static fn (): never => throw new LogicException('The rule ran the missing-key callback.')); + + $this->translator = $translator; + + $this->analyse([__DIR__ . '/../Fixtures/Rules/BootRegisteredTranslations.php'], [ + ['Translation "package::lookups.missing" has not been found.', 15], + ['Translation "added.missing" has not been found.', 17], + ['Translation "added.missing" has not been found for locale "fr".', 18], + ]); + } + + public function testLocalesFromTheTranslatorsResolver(): void + { + $translator = new Translator(new FileLoader(new Filesystem, []), 'en'); + $translator->setFallback('en'); + $translator->addNamespace('package', __DIR__ . '/../Fixtures/Rules/lang'); + $translator->determineLocalesUsing(static fn (array $locales): array => [...$locales, 'es']); + + $this->translator = $translator; + + // The Spanish line equals its key, so only the resolver's extra locale shows that it exists. + $this->analyse([__DIR__ . '/../Fixtures/Rules/ResolvedLocaleTranslations.php'], [ + ['Translation "package::lookups.missing" has not been found.', 15], + ]); + } + + public function testNamespacedTranslationFunctionIsNotTheHelper(): void + { + $translator = new Translator(new FileLoader(new Filesystem, []), 'en'); + $translator->addNamespace('package', __DIR__ . '/../Fixtures/Rules/lang'); + + $this->translator = $translator; + + $this->analyse([__DIR__ . '/data/NamespacedTranslationFunction.php'], [ + ['Translation "package::lookups.missing" has not been found.', 21], + ]); + } + + #[DataProvider('customTranslators')] + public function testCustomTranslatorsAreNotChecked(Translator $translator): void + { + $this->translator = $translator; + + $this->analyse([__DIR__ . '/data/Translation.php'], []); + } + + /** @return iterable */ + public static function customTranslators(): iterable + { + $directory = __DIR__ . '/../application/lang'; + + yield 'custom translator' => [new class(new FileLoader(new Filesystem, $directory), 'en') extends Translator { + }]; + + yield 'custom loader' => [new Translator(new class(new Filesystem, $directory) extends FileLoader { + }, 'en')]; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/phpstan-rules.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/data/NamespacedTranslationFunction.php b/tests/PHPStan/Rules/data/NamespacedTranslationFunction.php new file mode 100644 index 000000000..5d96cd06d --- /dev/null +++ b/tests/PHPStan/Rules/data/NamespacedTranslationFunction.php @@ -0,0 +1,23 @@ +get('messages.bar'); + $translator->choice('messages.bar', 1); + + $translator->get('messages.test'); + $translator->choice('messages.test', 1); + + Lang::get('messages.baz'); + Lang::choice('messages.baz', 1); + + Lang::get('messages.test'); + Lang::choice('messages.test', 1); + + __('foo bar baz'); + __('messages'); + __('messages.nested'); + __('messages.nested.key'); + + Lang::get('sub/lines.greeting'); + Lang::get('sub/lines.farewell'); + + __('vendor::key'); + + __('messages.You\'re beautiful'); + + __('messages.A.Key.With.Dots.'); + } +} diff --git a/tests/PHPStan/Type/data/translate.php b/tests/PHPStan/Type/data/translate.php new file mode 100644 index 000000000..43e092da9 --- /dev/null +++ b/tests/PHPStan/Type/data/translate.php @@ -0,0 +1,25 @@ + 'Niek'])); + + assertType('null', __()); + assertType('(array|string)', __('foo')); + assertType('(array|string)', __('Hi :name', ['name' => 'Niek'])); + + // An explicit or possibly null key keeps the declared conditional return. + assertType('Hypervel\Translation\Translator', trans(null)); + assertType('array|Hypervel\Translation\Translator|string', trans($key)); + assertType('null', __(null)); + assertType('array|string|null', __($key)); +} diff --git a/tests/PHPStan/Type/data/translator.php b/tests/PHPStan/Type/data/translator.php new file mode 100644 index 000000000..d0bfa793e --- /dev/null +++ b/tests/PHPStan/Type/data/translator.php @@ -0,0 +1,21 @@ +get('language.string')); + assertType('(array|string)', $transContract->get('language.string')); + assertType('(array|string)', Lang::get('language.string')); + // A narrower declared return is kept. + assertType('string', $stringTranslator->get('language.string')); + assertType('string', StringLang::get('language.string')); +} From bffed1489f3aee6918eb7820528b0ff982ba8b46 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:48:30 +0000 Subject: [PATCH 026/303] Update the tracked PHPStan port plan after view analysis integration Record the completed view and translation slice, fixture discovery conventions and conservative unused-view directory contract. Keep remaining upstream files and source typing work in the single checklist, identify the next implementation slice, and retain shared provider/result-cache dependency work for the planned integration checkpoint. --- .../2026-11-04-hypervel-phpstan-checklist.md | 37 ++++++++++--------- docs/plans/2026-11-04-hypervel-phpstan.md | 7 ++-- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index 6ad0a2587..755a7e9a4 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -8,7 +8,7 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## Package root -- `extension.neon` services: restore upstream's `AbortIfFunctionTypeSpecifyingExtension` (abort/throw), `AppEnvironmentReturnTypeExtension`, `CheckDispatchArgumentTypesCompatibleWithClassConstructorRule` (bus/events `Dispatchable`), `Filesystem` and `migrationsParser` entries with their classes; omit the container ArrayAccess entries +- `extension.neon` services: restore upstream's `AbortIfFunctionTypeSpecifyingExtension` (abort/throw), `AppEnvironmentReturnTypeExtension`, `CheckDispatchArgumentTypesCompatibleWithClassConstructorRule` (bus/events `Dispatchable`) and `migrationsParser` entries with their classes; omit the container ArrayAccess entries - `extension.neon` and Components integration: set `reportIgnoresWithoutComments: true`; add `phpstan-deprecation-rules` includes to `phpstan.neon.dist`/`phpstan.types.neon.dist` - omit: `phpcs.xml.dist`, `phpstan.neon.dist`, `phpstan-baseline.neon`, `phpunit.xml.dist`, `phpunit-bootstrap.php`, `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, `RELEASE.md`, `UPGRADE.md`, `assets/` (repository tooling; Components owns its own) - merge into `src/docs/phpstan.md`: `docs/custom-config-parameters.md`, `docs/custom-types.md`, `docs/errors-to-ignore.md`, `docs/features.md`, `docs/rules.md` @@ -16,7 +16,7 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## Existing Hypervel extensions -- merge: `src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php` (moved from Foundation) -> `ReturnTypes/Helpers/{ResponseExtension,ValidatorExtension}.php` +- merge: `src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php` (moved from Foundation) -> `ReturnTypes/Helpers/{ResponseExtension,ValidatorExtension}.php`, plus a Hypervel `ViewExtension.php` keeping its `view()` argument-count inference (upstream relies on the stub's false explicit-null return) ## Runtime prerequisites @@ -24,25 +24,23 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## src -- copy: `LarastanStubFilesExtension.php` - copy: `ClosureTypes/RelationshipQueryCallbackExtension.php` -- copy: `Collectors/*` (12 files) - copy: `Concerns/{InspectsQueuedJobs,LoadsAuthModel}.php` - copy: `Internal/{ConfigHelper,ConsoleApplicationHelper,ConsoleApplicationResolver,RecursionGuard}.php` - omit: `Internal/ComposerHelper.php` (`Support\Composer::vendorPath()` resolves the actual install) -- copy: `Methods/{AuthsMethodsExtension,BuilderHelper,FacadesMethodsExtension,HigherOrderCollectionProxyExtension,HigherOrderTapProxyExtension,Macro,MacroMethodsClassReflectionExtension,ManagersMethodsExtension,ModelFactoryMethodsClassReflectionExtension,RedirectResponseMethodsClassReflectionExtension,StorageMethodsClassReflectionExtension,ViewWithMethodsClassReflectionExtension}.php` +- copy: `Methods/{AuthsMethodsExtension,BuilderHelper,FacadesMethodsExtension,HigherOrderCollectionProxyExtension,HigherOrderTapProxyExtension,Macro,MacroMethodsClassReflectionExtension,ManagersMethodsExtension,ModelFactoryMethodsClassReflectionExtension,StorageMethodsClassReflectionExtension}.php` - merge into moved forwarding extensions: `Methods/{EloquentBuilderForwardsCallsExtension,ModelForwardsCallsExtension,RelationForwardsCallsExtension}.php` - omit: `Methods/ContractsMethodsExtension.php` (interfaces stay honest) - copy: `Properties/*` (17 files, including `Schema/MySqlDataTypeToPhpTypeConverter.php`) -- copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection,ReflectionHelper,StaticMethodReflection}.php` +- copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection,ReflectionHelper,StaticMethodReflection}.php` - merge into moved scope reflections: `Reflection/ModelScopeMethodReflection.php` -- copy: `ReturnTypes/*` except below and the ported container typing files (46 files, including `ConsoleCommand/` and `Helpers/`) +- copy: `ReturnTypes/*` except below and the ported container typing and translation files (42 files, including `ConsoleCommand/` and `Helpers/`) - omit: `ReturnTypes/ContainerArrayAccessDynamicMethodReturnTypeExtension.php` (container ArrayAccess is unsupported), `ReturnTypes/ApplicationMakeDynamicReturnTypeExtension.php` (the container extension already covers the Application contract) -- copy: `Rules/*` except below (28 files, including `Queue/` and `UselessConstructs/`) +- copy: `Rules/*` except below and the ported `NoMissingTranslationsRule`/`UnusedViewsRule` (26 files, including `Queue/` and `UselessConstructs/`) - omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` - copy: `SQL/*` (7 files) -- copy: `Support/{ArrKeyLookup,CollectionHelper,ConfigParser,HigherOrderCollectionProxyHelper,RecursiveDirectoryIterator,ViewFileHelper,ViewParser}.php` -- copy: `Types/*` (15 files) +- copy: `Support/{ArrKeyLookup,CollectionHelper,ConfigParser,HigherOrderCollectionProxyHelper}.php` +- copy: `Types/*` except the ported `ViewString*` files (13 files) ## New Hypervel source @@ -53,20 +51,25 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## stubs -- copy: `common/*` (89 files) except below +- copy: `common/*` except below and the ported view stubs (76 files) +- merge into `common/Facades.stub` and `common/Helpers.stub` with their topics: the remaining facade and helper entries (only View, Route and `view()` are ported; `trans()`/`__()` are omitted, as the stub explains) +- add to source with the routing stubs: upstream `common/Route.stub`'s `methods()` and `middleware()` element types as `Route` PHPDoc in `src/routing`, with a `types/` fixture (the stub keeps only the empty declaration stub validation needs) - omit: `common/Redis/{PredisConnection,PredisFacade}.stub` +- add to source: upstream `common/MailMessage.stub`'s property shapes (`$view`, `$from`, `$replyTo`, `$attachments`, `$rawAttachments`) as `MailMessage` PHPDoc in `src/notifications`, with a `types/` fixture +- omit: `common/Contracts/Translation.stub` (only upstream's omitted `trans()` helper stub references it) - merge into `common/`: `12.20.0/Enumerable.stub`, `12.41.0/Http/PendingRequest.stub`, `13.3.0/{EloquentBuilder,Model}.stub`, `13.35.0/{BelongsToMany,HasOneOrMany}.stub`, `13.37.0/HasOneOrManyThrough.stub` ## tests - copy: `bootstrap.php`, `enum-definition.php`, then add the test bootstrap to `tests/PHPStan/phpstan-tests.neon`; omit `laravel-test.sh` -- copy: `application/` fixture app (138 files: app, config, factories, migrations, schema dump, lang, views) -- copy: `Integration/{IntegrationTest,BleedingEdgeIntegrationTest}.php` with `Integration/data/*` (49 files) -- copy: `Reflection/{MacroMethodsClassReflectionExtensionTest,RedirectResponseMethodsClassReflectionExtensionTest}.php` -- copy: `Rules/*Test.php` except below (26 classes), `Rules/phpstan-rules.neon`, `Rules/data/*`, `Rules/Queue/data/*`, `Rules/UselessConstructs/data/*` +- copy: `application/` fixture app except the ported lang and views (109 files: app, config, factories, migrations, schema dump) +- merge into `Integration/IntegrationTest.php`: the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties; copy `Integration/BleedingEdgeIntegrationTest.php` +- copy: `Reflection/MacroMethodsClassReflectionExtensionTest.php` +- copy: `Rules/*Test.php` except below and the ported view/translation rule tests (24 classes), the remaining `Rules/data/*`, `Rules/Queue/data/*`, `Rules/UselessConstructs/data/*` - omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` -- copy: `Type/*Test.php` except below and `GeneralTypeTest.php` (11 classes) with the remaining `Type/data/*` (153 files) +- copy: `Type/*Test.php` except below, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php` (10 classes) with the remaining `Type/data/*` (148 files) +- merge into `Type/MethodsClassReflectionExtensionTest.php` with the macro port: `macros.php` and `macros-php-81.php` (unconditional; Hypervel requires PHP 8.4) - merge: the remaining `Type/GeneralTypeTest.php` data entries, and its `data/config-with-migrations.neon` configuration once migrations are ported - merge: `Type/{RedisDriverNullTypeTest,RedisDriverPhpRedisTypeTest}.php` and their data into one phpredis test; omit `Type/RedisDriverPredisTypeTest.php` with `redis-driver-predis.*` - omit: `Type/data/container-array-access.php`; investigate `Type/data/contracts.php` -- copy: `Unit/*Test.php` except `Support/BootstrapErrorHandlerTest.php` (12 classes, including `SQL/` and `Support/`), `Unit/Support/BrokenRewindDirectoryStream.php`, `Unit/data/*` +- copy: `Unit/*Test.php` except `Support/{BootstrapErrorHandlerTest,RecursiveDirectoryIteratorTest}.php` (11 classes, including `SQL/` and `Support/`), `Unit/data/*` diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 781554d10..38d2a8ce8 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -12,7 +12,8 @@ Remaining work: - Completed: PHPStan `^2.3.5` minimum (root, base skeleton, analyzer manifest; Database and Foundation no longer declare PHPStan), regression ignores, corrected split types, the DataTypeFactory parent-resolution fix, package metadata and analyzer test harness, moved extensions, and bootstrap/error reporting with `tests/PHPStan/BootstrapTest.php`. - Also completed: the package-mode boot and Composer-install fixes, container typing (Bootstrap and runtime discovery) with the source typing fixes it exposed, and the initial `GeneralTypeTest`. -- Next: port the checklist's remaining `src/` files alphabetically with their tests, beginning with `ClosureTypes/` and `Collectors/`. +- Also completed: view and translation analysis (collectors, the missing-translation and unused-view rules, `view-string` types and stubs, with-method reflection, translation return types) with their tests. Their registration-provider dependencies remain with the shared runtime-input tracking under Result-cache correctness and analysis cost. +- Next: port the checklist's remaining `src/` files alphabetically with their tests, beginning with `Concerns/`. `ClosureTypes/` ports with the Eloquent builder/relation forwarding it depends on. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -241,7 +242,7 @@ $enabled = config('feature.enabled'); Cover helpers, facade and repository methods, typed getters and collection access. Respect explicit casts, imports, preceding assignments, nullable/union settings, defaults, package defaults and application overrides. Hypervel uses shallow merging; named groups declared by `mergeableOptions()` get one additional entry-merge level, with an application entry replacing its matching default entry whole. Honor registration order and supported custom providers. Reuse existing parser helpers rather than executing arbitrary config files a second time to get values. When a provider mutates config dynamically beyond the parser's knowledge, preserve uncertainty rather than inventing a fixed shape. -Retain `configDirectories`, `viewDirectories`, `translationDirectories`, migration/schema paths and associated upstream settings for retained features. Prefer boot-discovered paths by default; document exactly whether an explicit setting replaces or supplements discovery. Larastan's configured migration/schema paths replace its fallback paths; preserve that contract unless a separately justified adaptation changes it. +Retain `configDirectories`, `viewDirectories`, `translationDirectories`, migration/schema paths and associated upstream settings for retained features. Prefer boot-discovered paths by default; document exactly whether an explicit setting replaces or supplements discovery. Larastan's configured migration/schema paths replace its fallback paths; preserve that contract unless a separately justified adaptation changes it. A non-empty `viewDirectories` likewise replaces the booted finder's paths and namespace hints, for the unused-view rule only; the missing-translation rule always scans the finder's paths, as upstream does. By default the unused-view rule names root views from the finder's paths and scans namespace hint directories for usages only: framework and package views such as `pagination::tailwind` are rendered by names no literal call shows. ### Model schemas @@ -405,7 +406,7 @@ Port upstream rule/type/reflection/unit/integration tests under `tests/PHPStan`, The process keeps one shared analyzer app. Its PHPUnit bootstrap, `tests/PHPStan/phpunit-bootstrap.php`, loads the extension's bootstrap before any test, so the app's error and exception handlers predate PHPUnit's per-test handler checks (`failOnRisky`); unit-style analyzer tests extend `Hypervel\Tests\PHPStan\TestCase`, which keeps those handlers rather than flushing them through `HandleExceptions::flushState()`. Tests that deliberately change its config, paths, bindings or macros restore their own changes; no blanket flush. Do not run Hypervel Testbench test cases in this process: their teardown flushes the shared app. Boot, cache and install checks run as subprocess fixtures with their own bootstrap/autoload; source runtime fixes stay in their owning framework suites. Every `setUp()` override calls the PHPStan parent initializer. Drop upstream `NoEnvCallsOutsideOfConfigRuleTest::setUp()`/`overrideConfigPath()`: it skips that initializer and permanently changes the app config path, which its explicit `configDirectories` never consume. Verify multiple tests and files in one reused process with existing macro/container cases (a focused ParaTest `-p1` run); no generic lifecycle test framework. -Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime for its assets instead of modifying its committed skeleton, writing into vendor, or always-loading package routes. Keep upstream's `App\`, `Database\Factories\` and `Database\Migrations\` namespaces: `Factory::resolveFactoryName()` derives factories from the app namespace that `Application::getNamespace()` reads from the Testbench skeleton (`App\`). Register those prefixes on Composer's existing loader from `tests/PHPStan/phpunit-bootstrap.php` before PHPStan reflection starts; PHPStan's `AutoloadSourceLocator` resolves them through `ClassLoader::findFile()`. No root `autoload-dev` mapping or custom autoloader. Keep existing `types/` fixtures as public typing regressions and update moved references. +Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime for its assets instead of modifying its committed skeleton, writing into vendor, or always-loading package routes. Keep upstream's `App\`, `Database\Factories\` and `Database\Migrations\` namespaces: `Factory::resolveFactoryName()` derives factories from the app namespace that `Application::getNamespace()` reads from the Testbench skeleton (`App\`). Register those prefixes on Composer's existing loader from `tests/PHPStan/phpunit-bootstrap.php` before PHPStan reflection starts; PHPStan's `AutoloadSourceLocator` resolves them through `ClassLoader::findFile()`. No root `autoload-dev` mapping for these fixture-app prefixes, and no custom autoloader. Root `autoload-dev.classmap` lists `tests/PHPStan/{Integration,Rules}/data`: PHPStan's test source locator discovers functions declared in analysis fixtures through Composer classmap directories, without executing the fixtures. Keep existing `types/` fixtures as public typing regressions and update moved references. Keep type fixtures that intentionally exercise still-supported APIs. In `phpstan.types.neon.dist`, set `noLegacyModelScopes`, `noLegacyModelProperties`, `noLegacyQueueProperties` and `noPipeSeparatedValidationRules` to `false`, with one comment that these fixtures assert supported types rather than authoring policy. Also set `checkModelMethodVisibility` and `noUnnecessaryEnumerableToArrayCalls` to `false` in this harness: `CustomBuilderForwarding.php` deliberately declares public scopes, and Collection/LazyCollection fixtures assert conversion return types. These preferences retain their normal defaults in source/consumer analysis and their dedicated `RuleTestCase` coverage. Preserve type assertions; do not delete compatibility cases. For upstream always-registered diagnostics without a switch, use a local identifier suppression with a fixture-specific reason only at the intentional call (for example the existing `value('foo', 42)` and `with(new User)` helper fixtures). Do not invent public switches or a second rule-registration system solely for the harness. From 53f0c1a38783b3c94df7f7c1aa2bff9445c58270 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:28:42 +0000 Subject: [PATCH 027/303] Declare the request guard creator types Type the internal viaRequest creator with the container, guard name and configuration it already receives. Declare its RequestGuard result so analyzer extensions can reflect the driver without executing authentication callbacks. The public viaRequest signature and guard construction behavior remain unchanged. Covered by the existing AuthManager tests and checkpoint analysis. --- src/auth/src/AuthManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php index fd4f6dcd6..6846988d7 100755 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -222,7 +222,7 @@ public function setDefaultDriver(UnitEnum|string $name): void */ public function viaRequest(string $driver, callable $callback): static { - return $this->extend($driver, function ($app, $name, $config) use ($callback) { + return $this->extend($driver, function (Container $app, string $name, array $config) use ($callback): RequestGuard { return new RequestGuard($name, $callback, $app, $this->createUserProvider($config['provider'] ?? null)); }); } From e06bcdbbce14ffc34c57513d5e75406b514d89fe Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:28:42 +0000 Subject: [PATCH 028/303] Document email verification user contracts at event dispatch Narrow the user supplied to Verified to the MustVerifyEmail contract required by its constructor in Foundation and Workbench. Keep each user resolver evaluation at its existing event-dispatch point. This allows app-aware analysis of framework code without changing verification behavior or the configured base user model. Existing Foundation and Workbench verification tests pass. --- src/foundation/src/Auth/EmailVerificationRequest.php | 6 +++++- .../src/Http/Controllers/Auth/VerifyEmailController.php | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/foundation/src/Auth/EmailVerificationRequest.php b/src/foundation/src/Auth/EmailVerificationRequest.php index 653b32ec4..3c899f9ea 100644 --- a/src/foundation/src/Auth/EmailVerificationRequest.php +++ b/src/foundation/src/Auth/EmailVerificationRequest.php @@ -5,6 +5,7 @@ namespace Hypervel\Foundation\Auth; use Hypervel\Auth\Events\Verified; +use Hypervel\Contracts\Auth\MustVerifyEmail; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Foundation\Http\FormRequest; use Hypervel\Validation\Validator; @@ -47,7 +48,10 @@ public function fulfill(): void $events = app('events'); if ($events->hasListeners(Verified::class)) { - $events->dispatch(new Verified($this->user())); + /** @var MustVerifyEmail $user */ + $user = $this->user(); + + $events->dispatch(new Verified($user)); } } } diff --git a/src/workbench/src/Http/Controllers/Auth/VerifyEmailController.php b/src/workbench/src/Http/Controllers/Auth/VerifyEmailController.php index 5e0c5e57e..b7577949b 100644 --- a/src/workbench/src/Http/Controllers/Auth/VerifyEmailController.php +++ b/src/workbench/src/Http/Controllers/Auth/VerifyEmailController.php @@ -5,6 +5,7 @@ namespace Hypervel\Workbench\Http\Controllers\Auth; use Hypervel\Auth\Events\Verified; +use Hypervel\Contracts\Auth\MustVerifyEmail; use Hypervel\Foundation\Auth\EmailVerificationRequest; use Hypervel\Http\RedirectResponse; use Hypervel\Support\Facades\Event; @@ -22,7 +23,10 @@ public function __invoke(EmailVerificationRequest $request): RedirectResponse } if ($request->user()->markEmailAsVerified() && Event::hasListeners(Verified::class)) { - event(new Verified($request->user())); + /** @var MustVerifyEmail $user */ + $user = $request->user(); + + event(new Verified($user)); } return redirect()->intended(route('dashboard', absolute: false) . '?verified=1'); From 8c9e0d93a2d4de51924d21df0ec00c3a6be74389 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:28:42 +0000 Subject: [PATCH 029/303] Document the token capability required by Sanctum middleware Annotate the existing request-user assignments as nullable HasApiTokens. This describes the methods consumed by the ability middleware without requiring an unrelated Authenticatable intersection. Preserve the authentication, missing-token and ability checks. Existing CheckAbilities and CheckForAnyAbility tests pass, including their capability-only user fixtures. --- src/sanctum/src/Http/Middleware/CheckAbilities.php | 2 ++ src/sanctum/src/Http/Middleware/CheckForAnyAbility.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/sanctum/src/Http/Middleware/CheckAbilities.php b/src/sanctum/src/Http/Middleware/CheckAbilities.php index dc0bb11ed..cdfaae59f 100644 --- a/src/sanctum/src/Http/Middleware/CheckAbilities.php +++ b/src/sanctum/src/Http/Middleware/CheckAbilities.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Auth\AuthenticationException; use Hypervel\Http\Request; +use Hypervel\Sanctum\Contracts\HasApiTokens; use Hypervel\Sanctum\Exceptions\MissingAbilityException; use Symfony\Component\HttpFoundation\Response; @@ -20,6 +21,7 @@ class CheckAbilities */ public function handle(Request $request, Closure $next, string ...$abilities): Response { + /** @var ?HasApiTokens $user */ $user = $request->user(); if (! $user || ! $user->currentAccessToken()) { diff --git a/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php b/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php index 9b30ad7bd..4ba019e98 100644 --- a/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php +++ b/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Auth\AuthenticationException; use Hypervel\Http\Request; +use Hypervel\Sanctum\Contracts\HasApiTokens; use Hypervel\Sanctum\Exceptions\MissingAbilityException; use Symfony\Component\HttpFoundation\Response; @@ -20,6 +21,7 @@ class CheckForAnyAbility */ public function handle(Request $request, Closure $next, string ...$abilities): Response { + /** @var ?HasApiTokens $user */ $user = $request->user(); if (! $user || ! $user->currentAccessToken()) { From e917933a5e83f88e8796f475c14b2962b5ff34c2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:28:42 +0000 Subject: [PATCH 030/303] Load the analyzer authentication application fixtures Port the authentication fixture configuration and its model, relationship, cast and factory dependencies. Register the application namespaces only in the analyzer bootstrap and copy fixture configuration into the disposable Testbench runtime. Select the fixture user explicitly so Workbench discovery does not replace it. Extend the development classmap to type-fixture data and use a valid distinct authentication model for the admin provider. --- composer.json | 3 +- tests/PHPStan/application/app/Account.php | 58 ++++ .../application/app/AccountCollection.php | 22 ++ tests/PHPStan/application/app/Address.php | 23 ++ tests/PHPStan/application/app/Admin.php | 9 + tests/PHPStan/application/app/Car.php | 10 + .../app/Casts/BackedEnumeration.php | 7 + .../app/Casts/BasicEnumeration.php | 7 + .../application/app/Casts/Favorites.php | 33 ++ tests/PHPStan/application/app/Casts/Hash.php | 42 +++ tests/PHPStan/application/app/Comment.php | 15 + tests/PHPStan/application/app/Group.php | 27 ++ .../application/app/HasManySyncable.php | 113 +++++++ tests/PHPStan/application/app/Image.php | 10 + tests/PHPStan/application/app/Mechanic.php | 22 ++ .../app/ModelWithNonGenericCollection.php | 16 + .../ModelWithOnlyValueGenericCollection.php | 19 ++ .../application/app/NonGenericCollection.php | 10 + .../app/OnlyValueGenericCollection.php | 15 + tests/PHPStan/application/app/Part.php | 10 + tests/PHPStan/application/app/Post.php | 79 +++++ tests/PHPStan/application/app/PostBuilder.php | 14 + tests/PHPStan/application/app/Role.php | 30 ++ .../application/app/RoleCollection.php | 16 + tests/PHPStan/application/app/Tag.php | 10 + .../application/app/Traits/HasOwner.php | 24 ++ .../application/app/Traits/HasParent.php | 18 + .../app/Traits/NestedSoftDeletes.php | 12 + tests/PHPStan/application/app/Transaction.php | 17 + .../application/app/TransactionCollection.php | 16 + tests/PHPStan/application/app/User.php | 319 ++++++++++++++++++ .../app/ValueObjects/Favorites.php | 7 + tests/PHPStan/application/config/auth.php | 56 +++ .../database/factories/Post/PostFactory.php | 38 +++ tests/PHPStan/phpunit-bootstrap.php | 15 + 35 files changed, 1141 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/application/app/Account.php create mode 100644 tests/PHPStan/application/app/AccountCollection.php create mode 100644 tests/PHPStan/application/app/Address.php create mode 100644 tests/PHPStan/application/app/Admin.php create mode 100644 tests/PHPStan/application/app/Car.php create mode 100644 tests/PHPStan/application/app/Casts/BackedEnumeration.php create mode 100644 tests/PHPStan/application/app/Casts/BasicEnumeration.php create mode 100644 tests/PHPStan/application/app/Casts/Favorites.php create mode 100644 tests/PHPStan/application/app/Casts/Hash.php create mode 100644 tests/PHPStan/application/app/Comment.php create mode 100644 tests/PHPStan/application/app/Group.php create mode 100644 tests/PHPStan/application/app/HasManySyncable.php create mode 100644 tests/PHPStan/application/app/Image.php create mode 100644 tests/PHPStan/application/app/Mechanic.php create mode 100644 tests/PHPStan/application/app/ModelWithNonGenericCollection.php create mode 100644 tests/PHPStan/application/app/ModelWithOnlyValueGenericCollection.php create mode 100644 tests/PHPStan/application/app/NonGenericCollection.php create mode 100644 tests/PHPStan/application/app/OnlyValueGenericCollection.php create mode 100644 tests/PHPStan/application/app/Part.php create mode 100644 tests/PHPStan/application/app/Post.php create mode 100644 tests/PHPStan/application/app/PostBuilder.php create mode 100644 tests/PHPStan/application/app/Role.php create mode 100644 tests/PHPStan/application/app/RoleCollection.php create mode 100644 tests/PHPStan/application/app/Tag.php create mode 100644 tests/PHPStan/application/app/Traits/HasOwner.php create mode 100644 tests/PHPStan/application/app/Traits/HasParent.php create mode 100644 tests/PHPStan/application/app/Traits/NestedSoftDeletes.php create mode 100644 tests/PHPStan/application/app/Transaction.php create mode 100644 tests/PHPStan/application/app/TransactionCollection.php create mode 100644 tests/PHPStan/application/app/User.php create mode 100644 tests/PHPStan/application/app/ValueObjects/Favorites.php create mode 100644 tests/PHPStan/application/config/auth.php create mode 100644 tests/PHPStan/application/database/factories/Post/PostFactory.php diff --git a/composer.json b/composer.json index 5ea597f68..b53f26ca6 100644 --- a/composer.json +++ b/composer.json @@ -373,7 +373,8 @@ }, "classmap": [ "tests/PHPStan/Integration/data", - "tests/PHPStan/Rules/data" + "tests/PHPStan/Rules/data", + "tests/PHPStan/Type/data" ], "files": [ "tests/Database/Fixtures/MigrationCreatorFakeMigration.php" diff --git a/tests/PHPStan/application/app/Account.php b/tests/PHPStan/application/app/Account.php new file mode 100644 index 000000000..e38ec2814 --- /dev/null +++ b/tests/PHPStan/application/app/Account.php @@ -0,0 +1,58 @@ + $query + * + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('active', 1); + } + + /** + * @return HasMany + */ + public function transactions(): HasMany + { + return $this->hasMany(Transaction::class); + } + + /** @phpstan-return BelongsTo */ + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + /** + * @return BelongsToMany + */ + public function posts(): BelongsToMany + { + return $this->belongsToMany(Post::class); + } + + /** + * @param array $models + * @return AccountCollection + */ + public function newCollection(array $models = []): AccountCollection + { + return new AccountCollection($models); + } +} diff --git a/tests/PHPStan/application/app/AccountCollection.php b/tests/PHPStan/application/app/AccountCollection.php new file mode 100644 index 000000000..018e8c2a6 --- /dev/null +++ b/tests/PHPStan/application/app/AccountCollection.php @@ -0,0 +1,22 @@ + + */ +class AccountCollection extends Collection +{ + /** + * @return self + */ + public function filterByActive(): self + { + return $this->where('active', true); + } +} diff --git a/tests/PHPStan/application/app/Address.php b/tests/PHPStan/application/app/Address.php new file mode 100644 index 000000000..fb635e7b3 --- /dev/null +++ b/tests/PHPStan/application/app/Address.php @@ -0,0 +1,23 @@ + */ + public function addressable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/tests/PHPStan/application/app/Admin.php b/tests/PHPStan/application/app/Admin.php new file mode 100644 index 000000000..0e42dd581 --- /dev/null +++ b/tests/PHPStan/application/app/Admin.php @@ -0,0 +1,9 @@ +algorithm = $algorithm; + } + + /** + * Prepare the given value for storage. + * + * @param \Hypervel\Database\Eloquent\Model $model + * @param string $key + * @param string $value + * @param array $attributes + * @return string + */ + public function set($model, string $key, $value, $attributes): mixed + { + return is_null($this->algorithm) + ? bcrypt($value) + : hash($this->algorithm, $value); + } +} diff --git a/tests/PHPStan/application/app/Comment.php b/tests/PHPStan/application/app/Comment.php new file mode 100644 index 000000000..33bb01aa6 --- /dev/null +++ b/tests/PHPStan/application/app/Comment.php @@ -0,0 +1,15 @@ + */ + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/tests/PHPStan/application/app/Group.php b/tests/PHPStan/application/app/Group.php new file mode 100644 index 000000000..a0780bde9 --- /dev/null +++ b/tests/PHPStan/application/app/Group.php @@ -0,0 +1,27 @@ + */ + public function accounts(): HasMany + { + return $this->hasMany(Account::class); + } + + /** @phpstan-return HasMany */ + public function users(): HasMany + { + return $this->hasMany(User::class); + } +} diff --git a/tests/PHPStan/application/app/HasManySyncable.php b/tests/PHPStan/application/app/HasManySyncable.php new file mode 100644 index 000000000..3d77cd713 --- /dev/null +++ b/tests/PHPStan/application/app/HasManySyncable.php @@ -0,0 +1,113 @@ + + */ +class HasManySyncable extends HasMany +{ + /** + * @param mixed $data + * @param bool $deleting + * @return array + */ + public function sync($data, $deleting = true): array + { + $changes = [ + 'created' => [], 'deleted' => [], 'updated' => [], + ]; + + $relatedKeyName = $this->related->getKeyName(); + + // First we need to attach any of the associated models that are not currently + // in the child entity table. We'll spin through the given IDs, checking to see + // if they exist in the array of current ones, and if not we will insert. + $current = $this->newQuery()->pluck( + $relatedKeyName + )->all(); + + // Separate the submitted data into "update" and "new" + $updateRows = []; + $newRows = []; + foreach ($data as $row) { + unset($row[$this->createdAt()], $row[$this->updatedAt()]); //remove the so they can be auto updated/created + if (method_exists($this->parent, 'getDeletedAtColumn')) { + unset($row[$this->parent->getDeletedAtColumn()]); + } + // We determine "updateable" rows as those whose $relatedKeyName (usually 'id') is set, not empty, and + // match a related row in the database. + if (isset($row[$relatedKeyName]) && ! empty($row[$relatedKeyName]) && in_array($row[$relatedKeyName], $current)) { + $id = $row[$relatedKeyName]; + $updateRows[$id] = $row; + } else { + $newRows[] = $row; + } + } + + // Next, we'll determine the rows in the database that aren't in the "update" list. + // These rows will be scheduled for deletion. Again, we determine based on the relatedKeyName (typically 'id'). + $updateIds = array_keys($updateRows); + $deleteIds = []; + foreach ($current as $currentId) { + if (! in_array($currentId, $updateIds)) { + $deleteIds[] = $currentId; + } + } + + // Delete any non-matching rows + if ($deleting && count($deleteIds) > 0) { + $this->getRelated()->destroy($deleteIds); + + $changes['deleted'] = $this->castKeys($deleteIds); + } + + // Update the updatable rows + foreach ($updateRows as $id => $row) { + $this->getRelated()->where($relatedKeyName, $id) + ->update($row); + } + + $changes['updated'] = $this->castKeys($updateIds); + + // Insert the new rows + $newIds = []; + foreach ($newRows as $row) { + $newModel = $this->create($row); + $newIds[] = $newModel->$relatedKeyName; + } + + $changes['created'][] = $this->castKeys($newIds); + + return $changes; + } + + /** + * Cast the given keys to integers if they are numeric and string otherwise. + * + * @param array $keys + * @return array + */ + protected function castKeys(array $keys) + { + return (array) array_map(function ($v) { + return $this->castKey($v); + }, $keys); + } + + /** + * Cast the given key to an integer if it is numeric. + * + * @param mixed $key + * @return mixed + */ + protected function castKey($key) + { + return is_numeric($key) ? (int) $key : (string) $key; + } +} diff --git a/tests/PHPStan/application/app/Image.php b/tests/PHPStan/application/app/Image.php new file mode 100644 index 000000000..9ab3a44e7 --- /dev/null +++ b/tests/PHPStan/application/app/Image.php @@ -0,0 +1,10 @@ + */ + public function car(): HasOne + { + return $this->hasOne(Car::class); + } + + /** @return HasMany */ + public function parts(): HasMany + { + return $this->hasMany(Part::class); + } +} diff --git a/tests/PHPStan/application/app/ModelWithNonGenericCollection.php b/tests/PHPStan/application/app/ModelWithNonGenericCollection.php new file mode 100644 index 000000000..806ed7da8 --- /dev/null +++ b/tests/PHPStan/application/app/ModelWithNonGenericCollection.php @@ -0,0 +1,16 @@ + + */ + public function newCollection(array $models = []): OnlyValueGenericCollection + { + return new OnlyValueGenericCollection($models); + } +} diff --git a/tests/PHPStan/application/app/NonGenericCollection.php b/tests/PHPStan/application/app/NonGenericCollection.php new file mode 100644 index 000000000..b88e7d258 --- /dev/null +++ b/tests/PHPStan/application/app/NonGenericCollection.php @@ -0,0 +1,10 @@ + */ +class NonGenericCollection extends Collection +{ +} diff --git a/tests/PHPStan/application/app/OnlyValueGenericCollection.php b/tests/PHPStan/application/app/OnlyValueGenericCollection.php new file mode 100644 index 000000000..239d37185 --- /dev/null +++ b/tests/PHPStan/application/app/OnlyValueGenericCollection.php @@ -0,0 +1,15 @@ + + */ +class OnlyValueGenericCollection extends Collection +{ +} diff --git a/tests/PHPStan/application/app/Part.php b/tests/PHPStan/application/app/Part.php new file mode 100644 index 000000000..ed6e2a2f4 --- /dev/null +++ b/tests/PHPStan/application/app/Part.php @@ -0,0 +1,10 @@ + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class); + } + + /** @return MorphToMany */ + public function tags(): MorphToMany + { + return $this->morphedByMany(Tag::class, 'taggable'); + } + + /** @return MorphOne */ + public function image(): MorphOne + { + return $this->morphOne(Image::class, 'imageable'); + } + + /** @return MorphMany */ + public function comments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable'); + } + + /** @return MorphOne */ + public function latestComment(): MorphOne + { + return $this->comments()->one(); + } + + /** + * @return PostBuilder + */ + public static function query(): PostBuilder + { + return parent::query(); + } + + /** + * @param \Hypervel\Database\Query\Builder $query + * @return PostBuilder + */ + public function newEloquentBuilder($query): PostBuilder + { + return new PostBuilder($query); + } + + /** @return PostFactory */ + protected static function newFactory(): Factory + { + return PostFactory::new(); + } +} diff --git a/tests/PHPStan/application/app/PostBuilder.php b/tests/PHPStan/application/app/PostBuilder.php new file mode 100644 index 000000000..5f09003ed --- /dev/null +++ b/tests/PHPStan/application/app/PostBuilder.php @@ -0,0 +1,14 @@ + + */ +class PostBuilder extends Builder +{ +} diff --git a/tests/PHPStan/application/app/Role.php b/tests/PHPStan/application/app/Role.php new file mode 100644 index 000000000..40153bc8b --- /dev/null +++ b/tests/PHPStan/application/app/Role.php @@ -0,0 +1,30 @@ + + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class); + } + + /** + * @param array $models + * @return RoleCollection + */ + public function newCollection(array $models = []): RoleCollection + { + return new RoleCollection($models); + } +} diff --git a/tests/PHPStan/application/app/RoleCollection.php b/tests/PHPStan/application/app/RoleCollection.php new file mode 100644 index 000000000..24a7a345f --- /dev/null +++ b/tests/PHPStan/application/app/RoleCollection.php @@ -0,0 +1,16 @@ + + */ +class RoleCollection extends Collection +{ + // +} diff --git a/tests/PHPStan/application/app/Tag.php b/tests/PHPStan/application/app/Tag.php new file mode 100644 index 000000000..a7c56d99b --- /dev/null +++ b/tests/PHPStan/application/app/Tag.php @@ -0,0 +1,10 @@ +ownerRelation; + } + + /** + * @return BelongsTo + */ + public function ownerRelation(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } +} diff --git a/tests/PHPStan/application/app/Traits/HasParent.php b/tests/PHPStan/application/app/Traits/HasParent.php new file mode 100644 index 000000000..40a182f6a --- /dev/null +++ b/tests/PHPStan/application/app/Traits/HasParent.php @@ -0,0 +1,18 @@ + + */ + public function parent(): BelongsTo + { + return $this->belongsTo(static::class); + } +} diff --git a/tests/PHPStan/application/app/Traits/NestedSoftDeletes.php b/tests/PHPStan/application/app/Traits/NestedSoftDeletes.php new file mode 100644 index 000000000..f92878f53 --- /dev/null +++ b/tests/PHPStan/application/app/Traits/NestedSoftDeletes.php @@ -0,0 +1,12 @@ + $models + * @return TransactionCollection + */ + public function newCollection(array $models = []): TransactionCollection + { + return new TransactionCollection($models); + } +} diff --git a/tests/PHPStan/application/app/TransactionCollection.php b/tests/PHPStan/application/app/TransactionCollection.php new file mode 100644 index 000000000..64317b856 --- /dev/null +++ b/tests/PHPStan/application/app/TransactionCollection.php @@ -0,0 +1,16 @@ + + */ +class TransactionCollection extends Collection +{ + // +} diff --git a/tests/PHPStan/application/app/User.php b/tests/PHPStan/application/app/User.php new file mode 100644 index 000000000..d8b864d7a --- /dev/null +++ b/tests/PHPStan/application/app/User.php @@ -0,0 +1,319 @@ + scopeSomeScope(Builder $builder) + * + * @mixin \Eloquent + */ +class User extends Authenticatable +{ + use HasFactory; + use Notifiable; + use SoftDeletes; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected array $fillable = [ + 'name', 'email', 'password', + ]; + + /** @var array */ + protected array $casts = [ + 'meta' => 'array', + 'blocked' => 'boolean', + 'email_verified_at' => 'date', + 'allowed_ips' => 'encrypted:array', + 'floatButRoundedDecimalString' => 'decimal:1', + 'options' => AsArrayObject::class, + 'properties' => AsCollection::class, + 'enum_collection' => AsEnumCollection::class.':'.BackedEnumeration::class, + 'enum_array_object' => AsEnumArrayObject::class.':'.BasicEnumeration::class, + 'castable_with_argument' => AsStringable::class.':argument', + 'favorites' => Favorites::class, + 'secret' => Hash::class.':sha256', + 'basic_enum' => BasicEnumeration::class, + 'backed_enum' => BackedEnumeration::class, + + 'int' => 'int', + 'integer' => 'integer', + 'real' => 'real', + 'float' => 'float', + 'double' => 'double', + 'decimal' => 'decimal', + 'nullable_decimal' => 'decimal:2', + 'string' => 'string', + 'bool' => 'bool', + 'boolean' => 'boolean', + 'object' => 'object', + 'array' => 'array', + 'json' => 'json', + 'collection' => 'collection', + 'nullable_collection' => 'collection', + 'date' => 'date', + 'datetime' => 'datetime', + 'immutable_date' => 'immutable_date', + 'immutable_datetime' => 'immutable_datetime', + 'timestamp' => 'timestamp', + ]; + + /** + * The attributes that should be hidden for arrays. + * + * @var array + */ + protected array $hidden = [ + 'password', 'remember_token', + ]; + + public function id(): int + { + return $this->id; + } + + public function getAllCapsName(): string + { + return Str::upper($this->name); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('active', 1); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeWhereActive(Builder $query): Builder + { + return $query->where('active', 1); + } + + /** @phpstan-return BelongsTo */ + public function group(): BelongsTo + { + return $this->belongsTo(Group::class)->withTrashed(); + } + + /** @phpstan-return HasMany */ + public function accounts(): HasMany + { + return $this->hasMany(Account::class); + } + + /** + * @return HasMany + */ + public function accounts_snake(): HasMany + { + return $this->hasMany(Account::class); + } + + /** + * @return HasMany + */ + public function accountsCamel(): HasMany + { + return $this->hasMany(Account::class); + } + + /** @phpstan-return HasMany */ + public function modelsWithNonGenericCollection(): HasMany + { + return $this->hasMany(ModelWithNonGenericCollection::class); + } + + /** @phpstan-return HasMany */ + public function modelsWithOnlyValueGenericCollection(): HasMany + { + return $this->hasMany(ModelWithOnlyValueGenericCollection::class); + } + + /** + * @return HasManyThrough + */ + public function transactions(): HasManyThrough + { + return $this->hasManyThrough(Transaction::class, Account::class); + } + + /** + * @return HasManySyncable + */ + public function syncableRelation(): HasManySyncable + { + return $this->hasManySyncable(Account::class); + } + + /** + * @return MorphMany + */ + public function address(): MorphMany + { + return $this->morphMany(Address::class, 'addressable'); + } + + /** @return HasOne */ + public function oneAddress(): HasOne + { + return $this->hasOne(Address::class); + } + + /** + * @return BelongsToMany + */ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class) + ->withPivot('some_column') + ->wherePivotIn('some_column', [1, 2, 3]); + } + + /** + * @return HasMany + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** + * @return BelongsTo + */ + public function parent(): BelongsTo + { + return $this->belongsTo(get_class($this)); + } + + /** + * @return BelongsToMany + */ + public function posts(): BelongsToMany + { + return $this->belongsToMany(Post::class); + } + + /** + * @return HasManySyncable + */ + public function hasManySyncable($related, $foreignKey = null, $localKey = null): HasManySyncable + { + $instance = $this->newRelatedInstance($related); + + $foreignKey = $foreignKey ?: $this->getForeignKey(); + + $localKey = $localKey ?: $this->getKeyName(); + + return new HasManySyncable( + $instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey + ); + } + + /** @return HasOne */ + public function mechanic(): HasOne + { + return $this->hasOne(Mechanic::class); + } + + /** @return HasOneThrough */ + public function car(): HasOneThrough + { + return $this->hasOneThrough(Car::class, Mechanic::class); + } + + /** @return HasManyThrough */ + public function parts(): HasManyThrough + { + return $this->hasManyThrough(Part::class, Mechanic::class); + } + + /** @return HasOneThrough */ + public function firstPart(): HasOneThrough + { + return $this->parts()->one(); + } + + public function getOnlyAvailableWithAccessorAttribute(): string + { + return 'foo'; + } + + public function isActive(): bool + { + return $this->active === 1; + } + + public function setActive(): void + { + $this->active = 1; + } + + protected function newStyleAttribute(): Attribute + { + return Attribute::make( + fn ($value) => 5, + ); + } + + /** + * @return Attribute + */ + protected function stringButInt(): Attribute + { + return Attribute::make( + fn ($value) => 5, + fn (string $value) => strtolower($value) + ); + } + + /** This will not take any effect because it's public and does not specify generic types at return type. */ + public function email(): Attribute + { + return Attribute::make( + fn ($value) => 5, + fn (string $value) => strtolower($value) + ); + } + + /** @return Attribute */ + protected function computed(): Attribute + { + return Attribute::get( + fn ($value) => 5, + ); + } +} diff --git a/tests/PHPStan/application/app/ValueObjects/Favorites.php b/tests/PHPStan/application/app/ValueObjects/Favorites.php new file mode 100644 index 000000000..be8dd880d --- /dev/null +++ b/tests/PHPStan/application/app/ValueObjects/Favorites.php @@ -0,0 +1,7 @@ + [ + 'guard' => 'web', + 'passwords' => 'users', + ], + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + + 'admin' => [ + 'driver' => 'session', + 'provider' => 'admins', + ], + + 'api' => [ + 'driver' => 'token', + 'provider' => 'users', + 'hash' => false, + ], + + 'passport' => [ + 'driver' => 'passport', + 'provider' => 'users', + ], + ], + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => App\User::class, + ], + + 'admins' => [ + 'driver' => 'eloquent', + 'model' => App\Admin::class, + ], + ], + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => 'password_resets', + 'expire' => 60, + 'throttle' => 60, + ], + ], + + 'password_timeout' => 10800, + +]; diff --git a/tests/PHPStan/application/database/factories/Post/PostFactory.php b/tests/PHPStan/application/database/factories/Post/PostFactory.php new file mode 100644 index 000000000..f41701dc5 --- /dev/null +++ b/tests/PHPStan/application/database/factories/Post/PostFactory.php @@ -0,0 +1,38 @@ + + */ +class PostFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected ?string $model = Post::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + 'user_id' => User::factory(), + ]; + } +} diff --git a/tests/PHPStan/phpunit-bootstrap.php b/tests/PHPStan/phpunit-bootstrap.php index 312bc27dc..c3ace95a9 100644 --- a/tests/PHPStan/phpunit-bootstrap.php +++ b/tests/PHPStan/phpunit-bootstrap.php @@ -2,11 +2,19 @@ declare(strict_types=1); +use Composer\Autoload\ClassLoader; use Hypervel\Filesystem\Filesystem; use Hypervel\Testbench\Bootstrapper; require_once __DIR__ . '/../bootstrap.php'; +// The fixture application keeps upstream's namespaces, which the Testbench +// skeleton's own composer.json declares but Components' autoloader does not map. +/** @var ClassLoader $loader */ +$loader = require __DIR__ . '/../../vendor/autoload.php'; +$loader->addPsr4('App\\', __DIR__ . '/application/app/'); +$loader->addPsr4('Database\Factories\\', __DIR__ . '/application/database/factories/'); + // The analyzed application is this process's disposable Testbench runtime copy, so // its resources become the fixture application's before it boots. Bootstrapper::bootstrap(); @@ -18,6 +26,13 @@ $filesystem->copyDirectory(__DIR__ . '/application/' . $directory, BASE_PATH . '/' . $directory); } +// Fixture config files replace the skeleton's files of the same name; the rest stay. +$filesystem->copyDirectory(__DIR__ . '/application/config', BASE_PATH . '/config'); + +// Testbench points the users provider at the shared workbench User model unless +// AUTH_MODEL names one, which would override the fixture auth configuration. +$_SERVER['AUTH_MODEL'] = $_ENV['AUTH_MODEL'] = 'App\User'; + // PHPStan boots the analyzed application once per process when a test first loads // the extension's bootstrap file. Booting it before any test runs keeps its error // and exception handlers out of PHPUnit's per-test handler checks. From 5bdcec028ba4c6a458bd3c5ff5937d1e834300d4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:28:42 +0000 Subject: [PATCH 031/303] Infer authentication guards and provider user types Resolve guard types from custom creator declarations and manager driver methods without instantiating guards. Share configured-name inference across facade, factory, helper and request entry points, including literal unions, enum names and coroutine-selected defaults. Infer provider users conservatively, preserving explicit declarations and falling back for unknown providers or custom database-backed guards. Keep the factory branch of nullable auth helper calls and delegate unpacked helper calls to the declared conditional. Add isolated configuration-dependent fixtures and port the auth, request-user, contract and Gate type cases. Source, public typing and skeleton analysis pass; the complete analyzer suite passes with 179 tests and 270 assertions. --- src/phpstan/src/Concerns/LoadsAuthModel.php | 104 ++++++++++ src/phpstan/src/ReturnTypes/AuthExtension.php | 68 +++++++ .../src/ReturnTypes/AuthGuardHelper.php | 184 ++++++++++++++++++ .../src/ReturnTypes/AuthManagerExtension.php | 67 +++++++ .../GuardDynamicMethodReturnTypeExtension.php | 63 ++++++ ...DynamicStaticMethodReturnTypeExtension.php | 60 ++++++ .../src/ReturnTypes/GuardExtension.php | 148 ++++++++++++++ .../src/ReturnTypes/Helpers/AuthExtension.php | 75 +++++++ .../src/ReturnTypes/RequestUserExtension.php | 83 ++++++++ tests/PHPStan/Type/AuthGuardTypeTest.php | 90 +++++++++ tests/PHPStan/Type/GeneralTypeTest.php | 5 + tests/PHPStan/Type/data/auth-guards.php | 61 ++++++ .../PHPStan/Type/data/auth-untyped-guard.php | 18 ++ tests/PHPStan/Type/data/auth.php | 110 +++++++++++ tests/PHPStan/Type/data/contracts.php | 13 ++ tests/PHPStan/Type/data/gate-facade.php | 15 ++ tests/PHPStan/Type/data/request-user.php | 18 ++ tests/PHPStan/Type/phpstan-auth-guards.neon | 2 + types/Foundation/Helpers.php | 4 +- 19 files changed, 1187 insertions(+), 1 deletion(-) create mode 100644 src/phpstan/src/Concerns/LoadsAuthModel.php create mode 100644 src/phpstan/src/ReturnTypes/AuthExtension.php create mode 100644 src/phpstan/src/ReturnTypes/AuthGuardHelper.php create mode 100644 src/phpstan/src/ReturnTypes/AuthManagerExtension.php create mode 100644 src/phpstan/src/ReturnTypes/GuardDynamicMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/GuardDynamicStaticMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/GuardExtension.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php create mode 100644 src/phpstan/src/ReturnTypes/RequestUserExtension.php create mode 100644 tests/PHPStan/Type/AuthGuardTypeTest.php create mode 100644 tests/PHPStan/Type/data/auth-guards.php create mode 100644 tests/PHPStan/Type/data/auth-untyped-guard.php create mode 100644 tests/PHPStan/Type/data/auth.php create mode 100644 tests/PHPStan/Type/data/contracts.php create mode 100644 tests/PHPStan/Type/data/gate-facade.php create mode 100644 tests/PHPStan/Type/data/request-user.php create mode 100644 tests/PHPStan/Type/phpstan-auth-guards.neon diff --git a/src/phpstan/src/Concerns/LoadsAuthModel.php b/src/phpstan/src/Concerns/LoadsAuthModel.php new file mode 100644 index 000000000..543d5630c --- /dev/null +++ b/src/phpstan/src/Concerns/LoadsAuthModel.php @@ -0,0 +1,104 @@ + $guardNames + * @return list + */ + private function getAuthModels(ConfigRepository $config, ?array $guardNames = null): array + { + $guards = $config->get('auth.guards'); + $providers = $config->get('auth.providers'); + $manager = $this->resolve('auth'); + + if (! is_array($guards) || ! is_array($providers) || ! $manager instanceof AuthManager) { + return []; + } + + /** @var array{array, array} $customCreators */ + $customCreators = (fn (): array => [$this->customCreators, $this->customProviderCreators])->call($manager); + $authModels = []; + + foreach ($guardNames ?? array_keys($guards) as $guardName) { + $authModel = $this->getGuardAuthModel($guards[$guardName] ?? null, $providers, ...$customCreators); + + if ($authModel === null) { + return []; + } + + if (! in_array($authModel, $authModels, strict: true)) { + $authModels[] = $authModel; + } + } + + return $authModels; + } + + /** + * Get the user class the given guard configuration's provider returns. + * + * @param array $providers + * @param array $guardCreators + * @param array $providerCreators + * @return ?class-string + */ + private function getGuardAuthModel(mixed $guard, array $providers, array $guardCreators, array $providerCreators): ?string + { + $provider = is_array($guard) ? ($guard['provider'] ?? null) : null; + + if (! is_string($provider) || $provider === '' || ! is_array($providers[$provider] ?? null)) { + return null; + } + + $driver = $providers[$provider]['driver'] ?? null; + + // A custom provider creator replaces the built-in provider of the same name. + if (! is_string($driver) || isset($providerCreators[$driver])) { + return null; + } + + $guardDriver = $guard['driver'] ?? null; + $model = $providers[$provider]['model'] ?? null; + + return match ($driver) { + 'eloquent' => is_string($model) && $model !== '' ? $model : null, + // Other guards can return users from elsewhere: Sanctum accepts its session + // guards' users when its provider, like the database one, has no model. + 'database' => in_array($guardDriver, ['session', 'token'], true) && ! isset($guardCreators[$guardDriver]) + ? GenericUser::class + : null, + default => null, + }; + } +} diff --git a/src/phpstan/src/ReturnTypes/AuthExtension.php b/src/phpstan/src/ReturnTypes/AuthExtension.php new file mode 100644 index 000000000..f86252c91 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AuthExtension.php @@ -0,0 +1,68 @@ +getName() === 'user' + && $methodReflection->getDeclaringClass()->getName() === Guard::class; + } + + /** + * Get the user type of every configured guard. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $authModels = $this->getAuthModels($this->getContainer()->get('config')); + + if (count($authModels) === 0) { + return null; + } + + return TypeCombinator::addNull( + TypeCombinator::union(...array_map( + static fn (string $authModel): Type => new ObjectType($authModel), + $authModels, + )), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/AuthGuardHelper.php b/src/phpstan/src/ReturnTypes/AuthGuardHelper.php new file mode 100644 index 000000000..65cca2979 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AuthGuardHelper.php @@ -0,0 +1,184 @@ + + */ + private array $driverTypes = []; + + /** + * Create a new helper instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClosureTypeFactory $closureTypeFactory, + ) { + } + + /** + * Get the type of the guard selected by the given name argument. + * + * Returns null when the call's declared return already describes the guard. + */ + public function getGuardType(?Arg $name, Scope $scope, Type $declaredType, bool $acceptsEnums = true): ?Type + { + $types = []; + + foreach ($this->getGuardNames($name, $scope, $acceptsEnums) as $guardName) { + $type = $this->getConfiguredGuardType($guardName); + + if ($type === null) { + return null; + } + + $types[] = $type; + } + + if ($types === []) { + return null; + } + + $guardType = TypeCombinator::union(...$types); + + // A factory or facade declaring a narrower guard keeps its declaration. + return $declaredType->isSuperTypeOf($guardType)->yes() ? $guardType : null; + } + + /** + * Get the names of the guards a name argument can select. + * + * AuthManager::guard() treats a missing, null or empty name as the current default, + * which shouldUse() can set to any configured guard per coroutine. An unknown name + * can also be any configured guard, because resolving any other name throws. + * + * @return list + */ + public function getGuardNames(?Arg $name, Scope $scope, bool $acceptsEnums): array + { + $type = $name === null || $name->unpack ? null : $scope->getType($name->value); + $values = []; + + foreach ($type?->getConstantStrings() ?? [] as $constantString) { + $values[] = $constantString->getValue(); + } + + if ($values === [] && $acceptsEnums) { + foreach ($type?->getEnumCases() ?? [] as $enumCase) { + // enum_value() resolves a backed case to its value and a pure case to its name. + foreach ($enumCase->getBackingValueType()?->getConstantScalarValues() ?? [$enumCase->getEnumCaseName()] as $value) { + $values[] = (string) $value; + } + } + } + + if ($values === [] || in_array('', $values, true)) { + return $this->getConfiguredGuardNames(); + } + + return $values; + } + + /** + * Get the names of the configured guards. + * + * @return list + */ + private function getConfiguredGuardNames(): array + { + $guards = $this->getContainer()->get('config')->get('auth.guards'); + + $names = []; + + foreach (is_array($guards) ? array_keys($guards) : [] as $name) { + $names[] = (string) $name; + } + + return $names; + } + + /** + * Get the type of the configured guard with the given name. + */ + private function getConfiguredGuardType(string $name): ?Type + { + $driver = $this->getContainer()->get('config')->get('auth.guards')[$name]['driver'] ?? null; + + if (! is_string($driver)) { + return null; + } + + if (! array_key_exists($driver, $this->driverTypes)) { + $this->driverTypes[$driver] = $this->resolveDriverType($driver); + } + + return $this->driverTypes[$driver]; + } + + /** + * Resolve the type of the guard the given driver creates. + */ + private function resolveDriverType(string $driver): ?Type + { + $manager = $this->resolve('auth'); + + if (! $manager instanceof AuthManager) { + return null; + } + + /** @var ?Closure $creator */ + $creator = (fn (): ?Closure => $this->customCreators[$driver] ?? null)->call($manager); + + if ($creator !== null) { + $type = $this->closureTypeFactory->fromClosureObject($creator)->getReturnType(); + } else { + $class = $this->reflectionProvider->getClass($manager::class); + $method = 'create' . ucfirst($driver) . 'Driver'; + + if (! $class->hasNativeMethod($method)) { + return null; + } + + $type = $class->getNativeMethod($method)->getOnlyVariant()->getReturnType(); + } + + // An untyped creator keeps the declared guard type. + return (new ObjectType(Guard::class))->isSuperTypeOf($type)->yes() ? $type : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/AuthManagerExtension.php b/src/phpstan/src/ReturnTypes/AuthManagerExtension.php new file mode 100644 index 000000000..93aa2032d --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AuthManagerExtension.php @@ -0,0 +1,67 @@ +getName() === 'user' + && $methodReflection->getDeclaringClass()->getName() === Guard::class; + } + + /** + * Get the user type of every configured guard. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $authModels = $this->getAuthModels($this->getContainer()->get('config')); + + if (count($authModels) === 0) { + return null; + } + + return TypeCombinator::addNull( + TypeCombinator::union(...array_map( + static fn (string $authModel): Type => new ObjectType($authModel), + $authModels, + )), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/GuardDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/GuardDynamicMethodReturnTypeExtension.php new file mode 100644 index 000000000..2fb9794e4 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/GuardDynamicMethodReturnTypeExtension.php @@ -0,0 +1,63 @@ +guard('web') with the + * declared Guard while Auth::guard('web') resolves the session guard. + */ +#[AutowiredService] +class GuardDynamicMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension +{ + /** + * Create a new extension instance. + */ + public function __construct( + private AuthGuardHelper $authGuardHelper, + ) { + } + + /** + * Get the class whose method calls are typed. + */ + public function getClass(): string + { + return Factory::class; + } + + /** + * Determine whether the method is the factory's guard(). + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'guard'; + } + + /** + * Get the type of the requested guard. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + return $this->authGuardHelper->getGuardType( + $methodCall->getArgs()[0] ?? null, + $scope, + ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/GuardDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/GuardDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 000000000..c4a53bfe4 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/GuardDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,60 @@ +getName() === 'guard'; + } + + /** + * Get the type of the requested guard. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + // Upstream falls back to StatefulGuard and types a missing name as the configured + // default. Only the session guard is stateful, and shouldUse() changes the default + // per coroutine, so unresolved guards keep the declared Guard|StatefulGuard. + return $this->authGuardHelper->getGuardType( + $methodCall->getArgs()[0] ?? null, + $scope, + ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/GuardExtension.php b/src/phpstan/src/ReturnTypes/GuardExtension.php new file mode 100644 index 000000000..e549cbd7b --- /dev/null +++ b/src/phpstan/src/ReturnTypes/GuardExtension.php @@ -0,0 +1,148 @@ +getName() === 'user'; + } + + /** + * Get the user type of the guard the call was made on. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $declaredType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + + // A guard declaring a narrower user() than the contract keeps its declaration. + if (! $declaredType->isSuperTypeOf(TypeCombinator::addNull(new ObjectType(Authenticatable::class)))->yes()) { + return null; + } + + $authModels = $this->getAuthModels( + $this->getContainer()->get('config'), + $this->getGuardNamesFromMethodCall($scope, $methodCall), + ); + + if (count($authModels) === 0) { + return null; + } + + return TypeCombinator::addNull( + TypeCombinator::union(...array_map( + static fn (string $authModel): Type => new ObjectType($authModel), + $authModels, + )), + ); + } + + /** + * Get the names of the guards the call that returned the guard can select. + * + * Returns null for a guard that did not come from the auth factory, the Auth + * facade or the auth() helper, which may be any configured guard. + * + * @return ?list + */ + private function getGuardNamesFromMethodCall(Scope $scope, MethodCall $methodCall): ?array + { + $call = $methodCall->var; + + // Upstream reads the argument of any single-argument call, such as $this->guardFor('admins'). + if ($call instanceof MethodCall + && $call->name instanceof Identifier + && $call->name->toLowerString() === 'guard' + && (new ObjectType(Factory::class))->isSuperTypeOf($scope->getType($call->var))->yes() + ) { + return $this->authGuardHelper->getGuardNames($this->getArgument($call, 'name'), $scope, acceptsEnums: true); + } + + if ($call instanceof StaticCall + && $call->class instanceof Name + && $call->name instanceof Identifier + && $call->name->toLowerString() === 'guard' + && (new ObjectType(Auth::class))->isSuperTypeOf(new ObjectType($scope->resolveName($call->class)))->yes() + ) { + return $this->authGuardHelper->getGuardNames($this->getArgument($call, 'name'), $scope, acceptsEnums: true); + } + + if ($call instanceof FuncCall + && $call->name instanceof Name + && $this->reflectionProvider->resolveFunctionName($call->name, $scope) === 'auth' + ) { + return $this->authGuardHelper->getGuardNames($this->getArgument($call, 'guard'), $scope, acceptsEnums: false); + } + + return null; + } + + /** + * Get the argument a call passes for its first parameter. + */ + private function getArgument(CallLike $call, string $parameter): ?Arg + { + foreach ($call->getArgs() as $position => $arg) { + if ($arg->name === null ? $position === 0 : $arg->name->toString() === $parameter) { + return $arg; + } + } + + return null; + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php b/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php new file mode 100644 index 000000000..7dec8d209 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php @@ -0,0 +1,75 @@ +getName() === 'auth'; + } + + /** + * Get the type of the auth factory or requested guard. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + $guard = $functionCall->getArgs()[0] ?? null; + + // Unlike guard(null), which selects the default guard, auth() returns the factory + // for a null guard, whether omitted or passed explicitly. + if ($guard === null || (! $guard->unpack && $scope->getType($guard->value)->isNull()->yes())) { + $factory = $this->resolve(Factory::class); + + return is_object($factory) ? new ObjectType($factory::class) : null; + } + + // A guard that may be null, or an unpacked argument that may be empty, can still + // return the factory, so the declared conditional return applies. + if ($guard->unpack || ! $scope->getType($guard->value)->isNull()->no()) { + return null; + } + + // Upstream types every named guard as StatefulGuard, which only the session guard is. + return $this->authGuardHelper->getGuardType( + $guard, + $scope, + ParametersAcceptorSelector::selectFromArgs($scope, $functionCall->getArgs(), $functionReflection->getVariants())->getReturnType(), + acceptsEnums: false, + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/RequestUserExtension.php b/src/phpstan/src/ReturnTypes/RequestUserExtension.php new file mode 100644 index 000000000..9dfbe27b8 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/RequestUserExtension.php @@ -0,0 +1,83 @@ +getName() === 'user'; + } + + /** + * Get the user type of the requested guard, or of every guard. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + if ($methodReflection->getDeclaringClass()->getName() !== Request::class) { + return null; + } + + // Upstream reads one constant guard name, so user('') looked up an empty name + // instead of the default guard that the request's user resolver uses. + $authModels = $this->getAuthModels( + $this->getContainer()->get('config'), + $this->authGuardHelper->getGuardNames($methodCall->getArgs()[0] ?? null, $scope, acceptsEnums: false), + ); + + if (count($authModels) === 0) { + return null; + } + + return TypeCombinator::addNull( + TypeCombinator::union(...array_map( + static fn (string $authModel): Type => new ObjectType($authModel), + $authModels, + )), + ); + } +} diff --git a/tests/PHPStan/Type/AuthGuardTypeTest.php b/tests/PHPStan/Type/AuthGuardTypeTest.php new file mode 100644 index 000000000..8caac012d --- /dev/null +++ b/tests/PHPStan/Type/AuthGuardTypeTest.php @@ -0,0 +1,90 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** + * Get the fixtures' type assertions, analyzed against this test's own auth setup. + * + * @return iterable + */ + public static function dataFileAsserts(): iterable + { + $config = new Repository(['auth' => [ + 'guards' => [ + 'web' => ['driver' => 'session', 'provider' => 'users'], + 'legacy' => ['driver' => 'session', 'provider' => 'database'], + 'api' => ['driver' => 'token', 'provider' => 'database'], + 'members' => ['driver' => 'request', 'provider' => 'users'], + 'request' => ['driver' => 'request', 'provider' => 'database'], + 'remote' => ['driver' => 'session', 'provider' => 'remote'], + 'missing' => ['driver' => 'session', 'provider' => 'absent'], + 'providerless' => ['driver' => 'request'], + '0' => ['driver' => 'session', 'provider' => 'users'], + ], + 'providers' => [ + 'users' => ['driver' => 'eloquent', 'model' => User::class], + 'database' => ['driver' => 'database', 'table' => 'users'], + 'remote' => ['driver' => 'remote'], + ], + ]]); + + $app = new Container; + $app->instance('config', $config); + + $manager = new AuthManager($app); + $manager->viaRequest('request', static fn () => null); + $manager->extend('token', static fn (): RequestGuard => throw new LogicException('Analysis never creates guards.')); + $manager->extend('untyped', static fn () => throw new LogicException('Analysis never creates guards.')); + $manager->provider('remote', static fn () => throw new LogicException('Analysis never creates providers.')); + $app->instance('auth', $manager); + + // These services read the application's auth setup. This test's own config file gives + // it its own service instances, so their swapped container and cached guard types + // never reach other tests. + foreach ([AuthGuardHelper::class, GuardExtension::class, AuthExtension::class] as $service) { + self::getContainer()->getByType($service)->setContainer($app); + } + + yield from self::gatherAssertTypes(__DIR__ . '/data/auth-guards.php'); + + // Any guard the analyzer cannot type leaves the default guard with its declared type. + $config->set('auth.guards.untyped', ['driver' => 'untyped']); + + yield from self::gatherAssertTypes(__DIR__ . '/data/auth-untyped-guard.php'); + } + + /** + * Get the analyzer configuration files for this test. + * + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-auth-guards.neon']; + } +} diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php index 2f0577761..da803a6a3 100644 --- a/tests/PHPStan/Type/GeneralTypeTest.php +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -23,8 +23,13 @@ public static function dataFileAsserts(): iterable { yield from self::gatherAssertTypes(__DIR__ . '/data/app-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/application-make.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/auth.php'); // REMOVED: container-array-access.php; container ArrayAccess is unsupported. yield from self::gatherAssertTypes(__DIR__ . '/data/container-make.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/contracts.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/gate-facade.php'); + // Upstream never registers request-user.php, which shares request-object.php's namespace. + yield from self::gatherAssertTypes(__DIR__ . '/data/request-user.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/translate.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/translator.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/view-exists.php'); diff --git a/tests/PHPStan/Type/data/auth-guards.php b/tests/PHPStan/Type/data/auth-guards.php new file mode 100644 index 000000000..d1d7521bf --- /dev/null +++ b/tests/PHPStan/Type/data/auth-guards.php @@ -0,0 +1,61 @@ + $names */ +function test(string $name, ?string $maybeName, array $names): void +{ + // shouldUse() changes the default guard per coroutine, so it may be any configured guard. + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', Auth::guard()); + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', Auth::guard(null)); + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', Auth::guard('')); + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', auth()->guard()); + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', auth('')); + + // Resolving a guard that is not configured throws, so an unknown name may be any configured guard. + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', Auth::guard($name)); + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', Auth::guard(...$names)); + + // auth() returns the factory for a null or omitted guard, so these keep the declaration. + // PHPStan resolves the declared conditional from an unpacked argument's element type. + assertType('Hypervel\Contracts\Auth\Factory|Hypervel\Contracts\Auth\Guard', auth($maybeName)); + assertType('Hypervel\Contracts\Auth\Guard', auth(...$names)); + + assertType(SessionGuard::class, Auth::guard('0')); + assertType(SessionGuard::class, Auth::guard(NumberedGuard::Zero)); + assertType('App\User|null', Auth::guard(NumberedGuard::Zero)->user()); + + // A custom creator wins over the manager's own createTokenDriver(). + assertType(RequestGuard::class, Auth::guard('api')); + assertType(RequestGuard::class, Auth::guard('members')); + + // An Eloquent provider gives its model to any guard driver. + assertType('App\User|null', Auth::guard('web')->user()); + assertType('App\User|null', Auth::guard('members')->user()); + // The stock session and token guards give a database provider's GenericUser. + assertType(GenericUser::class . '|null', Auth::guard('legacy')->user()); + + // Other guards and providers keep the declared user type. + assertType(Authenticatable::class . '|null', Auth::guard('api')->user()); + assertType(Authenticatable::class . '|null', Auth::guard('request')->user()); + assertType(Authenticatable::class . '|null', Auth::guard('remote')->user()); + assertType(Authenticatable::class . '|null', Auth::guard('missing')->user()); + assertType(Authenticatable::class . '|null', Auth::guard('providerless')->user()); + assertType(Authenticatable::class . '|null', Auth::guard()->user()); + assertType(Authenticatable::class . '|null', Auth::user()); +} diff --git a/tests/PHPStan/Type/data/auth-untyped-guard.php b/tests/PHPStan/Type/data/auth-untyped-guard.php new file mode 100644 index 000000000..fa62041b0 --- /dev/null +++ b/tests/PHPStan/Type/data/auth-untyped-guard.php @@ -0,0 +1,18 @@ +logout()); + assertType(SessionGuard::class, Auth::guard('web')); + assertType(SessionGuard::class, Auth::guard('admin')); + assertType(TokenGuard::class, Auth::guard('api')); + // Upstream falls back to StatefulGuard, but only SessionGuard is stateful, so Hypervel + // keeps the declared Guard for a guard it cannot type. + assertType(Guard::class, Auth::guard('passport')); + assertType(Guard::class, Auth::guard('undefined')); + assertType(Guard::class, Auth::guard($guard)); + assertType(Guard::class, Auth::guard($maybeGuard)); + assertType('App\User|null', Auth::guard('web')->user()); + assertType('App\Admin|null', Auth::guard('admin')->user()); + assertType('null', Auth::guard('web')->login($user)); + + // A guard name may be an enum case, as AuthManager::guard() reads it with enum_value(). + assertType(SessionGuard::class, Auth::guard(GuardName::Admin)); + assertType('App\Admin|null', Auth::guard(GuardName::Admin)->user()); + assertType(SessionGuard::class, Auth::guard(UnitGuardName::admin)); + assertType('App\Admin|null', Auth::guard(name: UnitGuardName::admin)->user()); + + assertType(AuthManager::class, auth()); + assertType(AuthManager::class, auth(null)); + assertType(AuthManager::class, auth(guard: null)); + assertType('App\Admin|App\User|null', auth()->user()); + assertType(TokenGuard::class, auth('api')); + assertType(Guard::class, auth('passport')); + assertType('App\Admin|null', auth('admin')->user()); + assertType('App\Admin|null', auth(guard: 'admin')->user()); + assertType(TokenGuard::class, auth()->guard('api')); + assertType('App\Admin|null', auth()->guard(GuardName::Admin)->user()); + + // A known set of names gives each named guard. + assertType('Hypervel\Auth\SessionGuard|Hypervel\Auth\TokenGuard', Auth::guard($adminOrApi)); + assertType('App\Admin|App\User|null', Auth::guard($adminOrApi)->user()); + + // Narrower or explicit declarations keep their types. + assertType(SessionGuard::class, $sessionGuardFactory->guard('api')); + assertType('App\Admin|null', $adminGuard->user()); + assertType('App\User|null', $userAuthManager->user()); + assertType('App\User|null', UserAuth::user()); +} diff --git a/tests/PHPStan/Type/data/contracts.php b/tests/PHPStan/Type/data/contracts.php new file mode 100644 index 000000000..a46445323 --- /dev/null +++ b/tests/PHPStan/Type/data/contracts.php @@ -0,0 +1,13 @@ +isLocal()); + // REMOVED: $session->ageFlashData(); contracts do not gain methods from their implementations. +} diff --git a/tests/PHPStan/Type/data/gate-facade.php b/tests/PHPStan/Type/data/gate-facade.php new file mode 100644 index 000000000..40ae90716 --- /dev/null +++ b/tests/PHPStan/Type/data/gate-facade.php @@ -0,0 +1,15 @@ +user()); + assertType('App\User|null', $request->user('web')); + assertType('App\Admin|null', $request->user('admin')); + // An empty guard name is the default guard, which may be any configured guard. + assertType('App\Admin|App\User|null', $request->user('')); +} diff --git a/tests/PHPStan/Type/phpstan-auth-guards.neon b/tests/PHPStan/Type/phpstan-auth-guards.neon new file mode 100644 index 000000000..097ef2f61 --- /dev/null +++ b/tests/PHPStan/Type/phpstan-auth-guards.neon @@ -0,0 +1,2 @@ +includes: + - ../phpstan-tests.neon diff --git a/types/Foundation/Helpers.php b/types/Foundation/Helpers.php index 80db2baa4..7ec49c969 100644 --- a/types/Foundation/Helpers.php +++ b/types/Foundation/Helpers.php @@ -14,7 +14,9 @@ assertType('Hypervel\Config\Repository', app(Repository::class)); assertType('Hypervel\Config\Repository', app('config')); -assertType('Hypervel\Contracts\Auth\Factory', auth()); +assertType('Hypervel\Auth\AuthManager', auth()); +assertType('Hypervel\Auth\AuthManager', auth(null)); +assertType('Hypervel\Auth\AuthManager', auth(guard: null)); assertType('Hypervel\Contracts\Auth\Guard', auth('foo')); assertType('Hypervel\Cache\CacheManager', cache()); From 9891a51e56f392be55febce178db330bdae9987b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:28:42 +0000 Subject: [PATCH 032/303] Add optional request-scope authentication rules Port the configurable rules that recommend the available request for user checks. Resolve the global auth helper by identity, match PHP method names case-insensitively and avoid suggesting this in static contexts. Omit automatic fixes because custom request user resolvers can make replacements semantically different. Preserve the upstream rule fixtures and add focused static-context, namespaced-helper and mixed-case coverage. --- .../Rules/NoAuthFacadeInRequestScopeRule.php | 105 +++++++++++++++ .../Rules/NoAuthHelperInRequestScopeRule.php | 121 ++++++++++++++++++ .../NoAuthFacadeInRequestScopeRuleTest.php | 39 ++++++ .../NoAuthHelperInRequestScopeRuleTest.php | 39 ++++++ .../no-auth-facade-in-request-scope-rule.php | 55 ++++++++ .../no-auth-helper-in-request-scope-rule.php | 84 ++++++++++++ 6 files changed, 443 insertions(+) create mode 100644 src/phpstan/src/Rules/NoAuthFacadeInRequestScopeRule.php create mode 100644 src/phpstan/src/Rules/NoAuthHelperInRequestScopeRule.php create mode 100644 tests/PHPStan/Rules/NoAuthFacadeInRequestScopeRuleTest.php create mode 100644 tests/PHPStan/Rules/NoAuthHelperInRequestScopeRuleTest.php create mode 100644 tests/PHPStan/Rules/data/no-auth-facade-in-request-scope-rule.php create mode 100644 tests/PHPStan/Rules/data/no-auth-helper-in-request-scope-rule.php diff --git a/src/phpstan/src/Rules/NoAuthFacadeInRequestScopeRule.php b/src/phpstan/src/Rules/NoAuthFacadeInRequestScopeRule.php new file mode 100644 index 000000000..e3cdcfae0 --- /dev/null +++ b/src/phpstan/src/Rules/NoAuthFacadeInRequestScopeRule.php @@ -0,0 +1,105 @@ + */ +#[Conditional(attribute: new AutowiredService, if: '%checkAuthCallsWhenInRequestScope%')] +class NoAuthFacadeInRequestScopeRule implements Rule +{ + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Report Auth facade user checks made where the request is available. + * + * @param StaticCall $node + * + * @return list + * + * @throws ShouldNotHappenException + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->class instanceof Name) { + return []; + } + + $className = $node->class->toString(); + + if ($className !== Auth::class && ! (new ObjectType(Auth::class))->isSuperTypeOf(new ObjectType($className))->yes()) { + return []; + } + + if (! $node->name instanceof Node\Identifier) { + return []; + } + + $methodName = $node->name->toLowerString(); + + if (! in_array($methodName, ['check', 'user', 'guest'], true)) { + return []; + } + + $message = match ($methodName) { + 'check' => 'Do not use Auth::check() in a class that has access to the request. Use $%s->user() !== null instead.', + 'user' => 'Do not use Auth::user() in a class that has access to the request. Use $%s->user() instead.', + 'guest' => 'Do not use Auth::guest() in a class that has access to the request. Use $%s->user() === null instead.', + }; + + // Upstream offers an automatic fix for both cases below. Hypervel omits it: + // Auth::resolveUsersUsing() or a request's own resolver changes what the + // request returns, so the replacement is not proven equivalent. Upstream + // also suggests $this->user() in static contexts, which have no $this. + if ( + $scope->isInClass() + && $scope->getClassReflection()->is(Request::class) + && $scope->hasVariableType('this')->yes() + ) { + return [ + RuleErrorBuilder::message(sprintf($message, 'this')) + ->identifier('hypervel.noAuthFacadeInRequestScope') + ->build(), + ]; + } + + if (! $scope->hasVariableType('request')->yes()) { + return []; + } + + $requestType = $scope->getVariableType('request'); + + if (! (new ObjectType(Request::class))->isSuperTypeOf($requestType)->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf($message, 'request')) + ->identifier('hypervel.noAuthFacadeInRequestScope') + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/NoAuthHelperInRequestScopeRule.php b/src/phpstan/src/Rules/NoAuthHelperInRequestScopeRule.php new file mode 100644 index 000000000..6c4430357 --- /dev/null +++ b/src/phpstan/src/Rules/NoAuthHelperInRequestScopeRule.php @@ -0,0 +1,121 @@ + */ +#[Conditional(attribute: new AutowiredService, if: '%checkAuthCallsWhenInRequestScope%')] +class NoAuthHelperInRequestScopeRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + ) { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Report auth() helper user checks made where the request is available. + * + * @param MethodCall $node + * + * @return list + * + * @throws ShouldNotHappenException + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Node\Identifier) { + return []; + } + + $methodName = $node->name->toLowerString(); + + if (! in_array($methodName, ['check', 'user', 'guest'], true)) { + return []; + } + + if (! $node->var instanceof Node\Expr\FuncCall) { + return []; + } + + // Upstream matches the written name, which also matches a namespaced auth() function. + if (! $node->var->name instanceof Name + || $this->reflectionProvider->resolveFunctionName($node->var->name, $scope) !== 'auth' + ) { + return []; + } + + $calledOnType = $scope->getType($node->var); + + if (! (new ObjectType(AuthManager::class))->isSuperTypeOf($calledOnType)->yes()) { + return []; + } + + $message = match ($methodName) { + 'check' => 'Do not use auth()->check() in a class that has access to the request. Use $%s->user() !== null instead.', + 'user' => 'Do not use auth()->user() in a class that has access to the request. Use $%s->user() instead.', + 'guest' => 'Do not use auth()->guest() in a class that has access to the request. Use $%s->user() === null instead.', + }; + + // Upstream offers an automatic fix for both cases below. Hypervel omits it: + // Auth::resolveUsersUsing() or a request's own resolver changes what the + // request returns, so the replacement is not proven equivalent. Upstream + // also suggests $this->user() in static contexts, which have no $this. + if ( + $scope->isInClass() + && $scope->getClassReflection()->is(Request::class) + && $scope->hasVariableType('this')->yes() + ) { + return [ + RuleErrorBuilder::message(sprintf($message, 'this')) + ->identifier('hypervel.noAuthHelperInRequestScope') + ->build(), + ]; + } + + if (! $scope->hasVariableType('request')->yes()) { + return []; + } + + $requestType = $scope->getVariableType('request'); + + if (! (new ObjectType(Request::class))->isSuperTypeOf($requestType)->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf($message, 'request')) + ->identifier('hypervel.noAuthHelperInRequestScope') + ->build(), + ]; + } +} diff --git a/tests/PHPStan/Rules/NoAuthFacadeInRequestScopeRuleTest.php b/tests/PHPStan/Rules/NoAuthFacadeInRequestScopeRuleTest.php new file mode 100644 index 000000000..39995140f --- /dev/null +++ b/tests/PHPStan/Rules/NoAuthFacadeInRequestScopeRuleTest.php @@ -0,0 +1,39 @@ + */ +class NoAuthFacadeInRequestScopeRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new NoAuthFacadeInRequestScopeRule; + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/no-auth-facade-in-request-scope-rule.php'], [ + ['Do not use Auth::check() in a class that has access to the request. Use $request->user() !== null instead.', 17], + ['Do not use Auth::user() in a class that has access to the request. Use $request->user() instead.', 22], + ['Do not use Auth::guest() in a class that has access to the request. Use $request->user() === null instead.', 27], + ['Do not use Auth::check() in a class that has access to the request. Use $this->user() !== null instead.', 40], + ['Do not use Auth::check() in a class that has access to the request. Use $request->user() !== null instead.', 53], + ]); + } + + // REMOVED: testFix; the replacement depends on request user resolvers, so the rule offers no fix. + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/phpstan-rules.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/NoAuthHelperInRequestScopeRuleTest.php b/tests/PHPStan/Rules/NoAuthHelperInRequestScopeRuleTest.php new file mode 100644 index 000000000..0e5314f6c --- /dev/null +++ b/tests/PHPStan/Rules/NoAuthHelperInRequestScopeRuleTest.php @@ -0,0 +1,39 @@ + */ +class NoAuthHelperInRequestScopeRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new NoAuthHelperInRequestScopeRule($this->createReflectionProvider()); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/no-auth-helper-in-request-scope-rule.php'], [ + ['Do not use auth()->check() in a class that has access to the request. Use $request->user() !== null instead.', 23], + ['Do not use auth()->user() in a class that has access to the request. Use $request->user() instead.', 28], + ['Do not use auth()->guest() in a class that has access to the request. Use $request->user() === null instead.', 33], + ['Do not use auth()->check() in a class that has access to the request. Use $this->user() !== null instead.', 51], + ['Do not use auth()->check() in a class that has access to the request. Use $request->user() !== null instead.', 64], + ]); + } + + // REMOVED: testFix; the replacement depends on request user resolvers, so the rule offers no fix. + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/phpstan-rules.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/data/no-auth-facade-in-request-scope-rule.php b/tests/PHPStan/Rules/data/no-auth-facade-in-request-scope-rule.php new file mode 100644 index 000000000..21764e555 --- /dev/null +++ b/tests/PHPStan/Rules/data/no-auth-facade-in-request-scope-rule.php @@ -0,0 +1,55 @@ +user() ? true : false; + } + + public function badCheck(Request $request) + { + return Auth::check(); + } + + public function badUser(Request $request) + { + return Auth::user(); + } + + public function badGuest(Request $request) + { + return Auth::guest(); + } + + public function goodWithNoRequest() + { + return Auth::check(); + } +} + +class CustomRequest extends Request +{ + public function foo(): bool + { + return Auth::check(); + } + + public static function staticFoo(): bool + { + return Auth::check(); + } +} + +class MixedCaseMethod +{ + public function check(Request $request): bool + { + return Auth::Check(); + } +} diff --git a/tests/PHPStan/Rules/data/no-auth-helper-in-request-scope-rule.php b/tests/PHPStan/Rules/data/no-auth-helper-in-request-scope-rule.php new file mode 100644 index 000000000..3310a101a --- /dev/null +++ b/tests/PHPStan/Rules/data/no-auth-helper-in-request-scope-rule.php @@ -0,0 +1,84 @@ +user() ? true : false; + } + + public function badCheck(FooRequest $request): bool + { + return auth()->check(); + } + + public function badUser(FooRequest $request) + { + return auth()->user(); + } + + public function badGuest(Request $request): bool + { + return auth()->guest(); + } + + public function goodWithNoRequest(): bool + { + return auth()->check(); + } + + public function goodWithProperty(Request $request): bool + { + return $this->authManager->check(); + } +} + +class CustomRequest extends Request +{ + public function foo(): bool + { + return auth()->check(); + } + + public static function staticFoo(): bool + { + return auth()->check(); + } +} + +class MixedCaseMethod +{ + public function check(Request $request): bool + { + return auth()->Check(); + } +} + +namespace Hypervel\Tests\PHPStan\Rules\Data\NoAuthHelperInRequestScope\NamespacedHelper; + +use Hypervel\Auth\AuthManager; +use Hypervel\Http\Request; + +function auth(): AuthManager +{ + return app(AuthManager::class); +} + +class NamespacedHelper +{ + public function check(Request $request): bool + { + return auth()->check(); + } +} From d1e218215604e11d7d6d998bc3cfb85ed6ebe1f9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:28:42 +0000 Subject: [PATCH 033/303] Describe authentication analysis differences from Larastan Document contract fidelity, coroutine-aware default guard typing and the absence of unsafe automatic request-scope replacements. Record the omitted container ArrayAccess extension at the existing container inference entry point. These notes preserve the intentional port boundaries for future upstream synchronization. --- src/phpstan/README.md | 3 +++ .../ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/phpstan/README.md b/src/phpstan/README.md index 086f46ae4..491302051 100644 --- a/src/phpstan/README.md +++ b/src/phpstan/README.md @@ -9,6 +9,9 @@ Documentation: https://hypervel.org/docs/phpstan - Several checks are enabled by default, and Hypervel adds coroutine, worker-lifetime and authoring-policy checks. See the [configuration defaults](https://hypervel.org/docs/phpstan#configuration). - There is no Octane compatibility rule or `checkOctaneCompatibility` setting; Hypervel's worker-lifetime checks replace it. There is no deferred service provider rule, because Hypervel has no deferred providers. - Container ArrayAccess return types are not provided, because the Hypervel container does not support ArrayAccess. +- Interfaces do not gain methods from their implementations. Upstream resolves an undefined method on a contract against its container binding, and on the auth user contracts against the configured auth models. +- Without a guard name, `Auth::guard()` and `auth()->guard()` may return any configured guard, because `shouldUse()` changes the default guard per request. A guard whose type cannot be read from its driver keeps the declared `Guard` type rather than `StatefulGuard`. +- The request-scope auth rules offer no automatic fix, because the request's user resolver can return a different user than the `Auth` facade or `auth()` helper. - The `generalizeEnvReturnType` and `redisDriver` settings are omitted. An `env()` default does not decide the type of the value it reads, and Redis types always describe Hypervel's phpredis connection. Ported from: https://github.com/larastan/larastan diff --git a/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php index 7c911d258..bf29a38cb 100644 --- a/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php @@ -18,6 +18,8 @@ * PHPStan applies this extension to the Application contract, which extends the * container contract. Upstream's separate ApplicationMakeDynamicReturnTypeExtension * is omitted because it would resolve every application call a second time. + * ContainerArrayAccessDynamicMethodReturnTypeExtension is omitted because the + * Hypervel container does not support ArrayAccess. */ #[AutowiredService] final class ContainerMakeDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension From a435a9752b9baa92046c319449877181d5a5929a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:28:42 +0000 Subject: [PATCH 034/303] Update the PHPStan implementation plan after authentication analysis Record the settled guard and provider inference design, explicit declaration precedence, fixture loading and helper null semantics. Update the remaining-file checklist to reflect the completed authentication flow. Group subsequent work by complete related flows, moving shared helpers with their first consumers. Retain the remaining consumer documentation and final integration obligations. --- .../2026-11-04-hypervel-phpstan-checklist.md | 20 +++++++++---------- docs/plans/2026-11-04-hypervel-phpstan.md | 9 +++++---- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index 755a7e9a4..478209147 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -25,18 +25,17 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## src - copy: `ClosureTypes/RelationshipQueryCallbackExtension.php` -- copy: `Concerns/{InspectsQueuedJobs,LoadsAuthModel}.php` +- copy with the queue rules: `Concerns/InspectsQueuedJobs.php` - copy: `Internal/{ConfigHelper,ConsoleApplicationHelper,ConsoleApplicationResolver,RecursionGuard}.php` - omit: `Internal/ComposerHelper.php` (`Support\Composer::vendorPath()` resolves the actual install) -- copy: `Methods/{AuthsMethodsExtension,BuilderHelper,FacadesMethodsExtension,HigherOrderCollectionProxyExtension,HigherOrderTapProxyExtension,Macro,MacroMethodsClassReflectionExtension,ManagersMethodsExtension,ModelFactoryMethodsClassReflectionExtension,StorageMethodsClassReflectionExtension}.php` +- copy: `Methods/{BuilderHelper,FacadesMethodsExtension,HigherOrderCollectionProxyExtension,HigherOrderTapProxyExtension,Macro,MacroMethodsClassReflectionExtension,ManagersMethodsExtension,ModelFactoryMethodsClassReflectionExtension,StorageMethodsClassReflectionExtension}.php` - merge into moved forwarding extensions: `Methods/{EloquentBuilderForwardsCallsExtension,ModelForwardsCallsExtension,RelationForwardsCallsExtension}.php` -- omit: `Methods/ContractsMethodsExtension.php` (interfaces stay honest) +- omit: `Methods/ContractsMethodsExtension.php` (interfaces stay honest; README and `REMOVED:` recorded, so add its source comment to `ManagersMethodsExtension`'s class docblock, the sibling container-forwarding extension, when that ports) - copy: `Properties/*` (17 files, including `Schema/MySqlDataTypeToPhpTypeConverter.php`) - copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection,ReflectionHelper,StaticMethodReflection}.php` - merge into moved scope reflections: `Reflection/ModelScopeMethodReflection.php` -- copy: `ReturnTypes/*` except below and the ported container typing and translation files (42 files, including `ConsoleCommand/` and `Helpers/`) -- omit: `ReturnTypes/ContainerArrayAccessDynamicMethodReturnTypeExtension.php` (container ArrayAccess is unsupported), `ReturnTypes/ApplicationMakeDynamicReturnTypeExtension.php` (the container extension already covers the Application contract) -- copy: `Rules/*` except below and the ported `NoMissingTranslationsRule`/`UnusedViewsRule` (26 files, including `Queue/` and `UselessConstructs/`) +- copy: `ReturnTypes/*` except the ported auth, container typing and translation files and the omitted `ContainerArrayAccessDynamicMethodReturnTypeExtension`/`ApplicationMakeDynamicReturnTypeExtension` (36 files, including `ConsoleCommand/` and `Helpers/`) +- copy: `Rules/*` except below and the ported auth request-scope, `NoMissingTranslationsRule` and `UnusedViewsRule` rules (24 files, including `Queue/` and `UselessConstructs/`) - omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` - copy: `SQL/*` (7 files) - copy: `Support/{ArrKeyLookup,CollectionHelper,ConfigParser,HigherOrderCollectionProxyHelper}.php` @@ -54,7 +53,7 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `common/*` except below and the ported view stubs (76 files) - merge into `common/Facades.stub` and `common/Helpers.stub` with their topics: the remaining facade and helper entries (only View, Route and `view()` are ported; `trans()`/`__()` are omitted, as the stub explains) - add to source with the routing stubs: upstream `common/Route.stub`'s `methods()` and `middleware()` element types as `Route` PHPDoc in `src/routing`, with a `types/` fixture (the stub keeps only the empty declaration stub validation needs) -- omit: `common/Redis/{PredisConnection,PredisFacade}.stub` +- omit: `common/Redis/{PredisConnection,PredisFacade}.stub`, `common/Gate.stub` (Hypervel's `Gate::forUser()` and its contract already return `static`) - add to source: upstream `common/MailMessage.stub`'s property shapes (`$view`, `$from`, `$replyTo`, `$attachments`, `$rawAttachments`) as `MailMessage` PHPDoc in `src/notifications`, with a `types/` fixture - omit: `common/Contracts/Translation.stub` (only upstream's omitted `trans()` helper stub references it) - merge into `common/`: `12.20.0/Enumerable.stub`, `12.41.0/Http/PendingRequest.stub`, `13.3.0/{EloquentBuilder,Model}.stub`, `13.35.0/{BelongsToMany,HasOneOrMany}.stub`, `13.37.0/HasOneOrManyThrough.stub` @@ -62,14 +61,13 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## tests - copy: `bootstrap.php`, `enum-definition.php`, then add the test bootstrap to `tests/PHPStan/phpstan-tests.neon`; omit `laravel-test.sh` -- copy: `application/` fixture app except the ported lang and views (109 files: app, config, factories, migrations, schema dump) +- copy: the remaining `application/` fixture app (76 files: app, config, factories, migrations, schema dump) - merge into `Integration/IntegrationTest.php`: the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties; copy `Integration/BleedingEdgeIntegrationTest.php` - copy: `Reflection/MacroMethodsClassReflectionExtensionTest.php` -- copy: `Rules/*Test.php` except below and the ported view/translation rule tests (24 classes), the remaining `Rules/data/*`, `Rules/Queue/data/*`, `Rules/UselessConstructs/data/*` +- copy: `Rules/*Test.php` except below and the ported auth, view and translation rule tests (22 classes), the remaining `Rules/data/*`, `Rules/Queue/data/*`, `Rules/UselessConstructs/data/*` - omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` -- copy: `Type/*Test.php` except below, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php` (10 classes) with the remaining `Type/data/*` (148 files) +- copy: `Type/*Test.php` except below, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php` (10 classes) with the remaining `Type/data/*` (144 files) - merge into `Type/MethodsClassReflectionExtensionTest.php` with the macro port: `macros.php` and `macros-php-81.php` (unconditional; Hypervel requires PHP 8.4) - merge: the remaining `Type/GeneralTypeTest.php` data entries, and its `data/config-with-migrations.neon` configuration once migrations are ported - merge: `Type/{RedisDriverNullTypeTest,RedisDriverPhpRedisTypeTest}.php` and their data into one phpredis test; omit `Type/RedisDriverPredisTypeTest.php` with `redis-driver-predis.*` -- omit: `Type/data/container-array-access.php`; investigate `Type/data/contracts.php` - copy: `Unit/*Test.php` except `Support/{BootstrapErrorHandlerTest,RecursiveDirectoryIteratorTest}.php` (11 classes, including `SQL/` and `Support/`), `Unit/data/*` diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 38d2a8ce8..1086ab167 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -13,7 +13,8 @@ Remaining work: - Completed: PHPStan `^2.3.5` minimum (root, base skeleton, analyzer manifest; Database and Foundation no longer declare PHPStan), regression ignores, corrected split types, the DataTypeFactory parent-resolution fix, package metadata and analyzer test harness, moved extensions, and bootstrap/error reporting with `tests/PHPStan/BootstrapTest.php`. - Also completed: the package-mode boot and Composer-install fixes, container typing (Bootstrap and runtime discovery) with the source typing fixes it exposed, and the initial `GeneralTypeTest`. - Also completed: view and translation analysis (collectors, the missing-translation and unused-view rules, `view-string` types and stubs, with-method reflection, translation return types) with their tests. Their registration-provider dependencies remain with the shared runtime-input tracking under Result-cache correctness and analysis cost. -- Next: port the checklist's remaining `src/` files alphabetically with their tests, beginning with `Concerns/`. `ClosureTypes/` ports with the Eloquent builder/relation forwarding it depends on. +- Also completed: the authentication flow (guard and user return types, the request-scope rules, the fixture app's auth models and config) with `AuthGuardTypeTest` for configuration-dependent guard typing. +- Next: port the checklist's remaining `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -215,7 +216,7 @@ Port every applicable upstream inference feature, not just the rules in the next | Models | Native declarations and explicit PHPDoc take precedence appropriately; infer schema properties, relations, accessors, casts, separate read/write types, nullability, custom collections, factories and serialization shapes via `ModelSerializationDynamicMethodReturnTypeExtension`. Honor Hypervel attributes, visibility/appends and inheritance. Unknown schema knowledge must not erase declared properties or known relationships. | | Builder/relation forwarding | Preserve model generics, custom builders, relation-specific fluent returns, attributed scopes, SoftDeletes/Scout and macro callbacks. Merge existing Hypervel capabilities with applicable upstream cases. | | Helpers and collections | Preserve conditional argument behavior, `Arr` shapes, `filter()` narrowing, higher-order proxy callbacks and generic collections. Compare inherited annotations before adding a stub. | -| Authentication | Infer configured guard/provider user models and nullable results through actual auth APIs. Recognize real manager forwarding without advertising implementation methods on unrelated auth contracts. | +| Authentication | One shared guard-type helper serves `Auth::guard()`, `Factory::guard()` and `auth($guard)`, following `AuthManager::resolve()`: a booted custom creator's declared return (`ClosureTypeFactory`, never invoked), else the booted manager's `create{Driver}Driver()` declaration, else the declared return. `viaRequest()`'s creator declares `RequestGuard`, so request guards are typed; an untyped creator keeps the declared return. Never claim `StatefulGuard` for an unmodelled guard: only `SessionGuard` is stateful, and token/request/Sanctum/JWT/Passport guards are not. An omitted, null or empty name is the union of every configured guard's type, because `shouldUse()` changes the default per coroutine; so is an unknown or unpacked name, because resolving an unconfigured name throws. A known set of string or enum names gives the union of those guards. `auth()` and `auth(null)` type as the booted factory concrete; unlike `guard(null)`, a maybe-null or unpacked `auth()` argument can return the factory, so it keeps the declared conditional. Guard names come only from `guard()` on the factory or facade, the global `auth()` (resolved function identity) and `Request::user()`; enum cases map like `enum_value()` where the signature accepts enums. User models are provider-based inference: an Eloquent provider gives its model for any guard driver; a database provider gives `GenericUser` only for the stock session/token drivers (Sanctum accepts session-guard users when its provider has no `getModel()`). A guard without a modelled provider, or one whose provider driver has a custom creator, keeps the call's declaration. Preserve narrower declarations; manager and facade `user()` calls are typed only when they forward the `Guard` contract's method. Omit `AuthsMethodsExtension`: `AuthManager`'s mixins already forward guard methods, and contracts do not gain implementation methods. | | Views and translations | Retain `view-string` and live view-finder namespaces. Keep translation typing and the adapted missing-key rule. The optional unused-view rule reports absent statically discovered usage, not proof that a view can be deleted. | | Custom PHPDoc types | Port `model-property`, `relation-of`, `builder-of`, `collection-of`, `view-string`, their parsers, templates and applicable tests. Keep these analyzer-only annotations in stubs where adding them to framework source would require this dev package. | | Dates and environment | Keep native/source `CarbonInterface` boundaries for configurable dates and exact immutable types for framework-owned values. Do not infer configurable dates from `get_class(now())`, or assume an `env()` default dictates the supplied environment value's type. | @@ -406,7 +407,7 @@ Port upstream rule/type/reflection/unit/integration tests under `tests/PHPStan`, The process keeps one shared analyzer app. Its PHPUnit bootstrap, `tests/PHPStan/phpunit-bootstrap.php`, loads the extension's bootstrap before any test, so the app's error and exception handlers predate PHPUnit's per-test handler checks (`failOnRisky`); unit-style analyzer tests extend `Hypervel\Tests\PHPStan\TestCase`, which keeps those handlers rather than flushing them through `HandleExceptions::flushState()`. Tests that deliberately change its config, paths, bindings or macros restore their own changes; no blanket flush. Do not run Hypervel Testbench test cases in this process: their teardown flushes the shared app. Boot, cache and install checks run as subprocess fixtures with their own bootstrap/autoload; source runtime fixes stay in their owning framework suites. Every `setUp()` override calls the PHPStan parent initializer. Drop upstream `NoEnvCallsOutsideOfConfigRuleTest::setUp()`/`overrideConfigPath()`: it skips that initializer and permanently changes the app config path, which its explicit `configDirectories` never consume. Verify multiple tests and files in one reused process with existing macro/container cases (a focused ParaTest `-p1` run); no generic lifecycle test framework. -Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime for its assets instead of modifying its committed skeleton, writing into vendor, or always-loading package routes. Keep upstream's `App\`, `Database\Factories\` and `Database\Migrations\` namespaces: `Factory::resolveFactoryName()` derives factories from the app namespace that `Application::getNamespace()` reads from the Testbench skeleton (`App\`). Register those prefixes on Composer's existing loader from `tests/PHPStan/phpunit-bootstrap.php` before PHPStan reflection starts; PHPStan's `AutoloadSourceLocator` resolves them through `ClassLoader::findFile()`. No root `autoload-dev` mapping for these fixture-app prefixes, and no custom autoloader. Root `autoload-dev.classmap` lists `tests/PHPStan/{Integration,Rules}/data`: PHPStan's test source locator discovers functions declared in analysis fixtures through Composer classmap directories, without executing the fixtures. Keep existing `types/` fixtures as public typing regressions and update moved references. +Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime for its assets instead of modifying its committed skeleton, writing into vendor, or always-loading package routes. Keep upstream's `App\`, `Database\Factories\` and `Database\Migrations\` namespaces: `Factory::resolveFactoryName()` derives factories from the app namespace that `Application::getNamespace()` reads from the Testbench skeleton (`App\`). Register those prefixes on Composer's existing loader from `tests/PHPStan/phpunit-bootstrap.php` before PHPStan reflection starts; PHPStan's `AutoloadSourceLocator` resolves them through `ClassLoader::findFile()`. No root `autoload-dev` mapping for these fixture-app prefixes, and no custom autoloader. Root `autoload-dev.classmap` lists `tests/PHPStan/{Integration,Rules,Type}/data`: PHPStan's test source locator discovers functions and inline helper classes declared in analysis fixtures through Composer classmap directories, without executing the fixtures, as upstream's `tests/` classmap does. Keep existing `types/` fixtures as public typing regressions and update moved references. Keep type fixtures that intentionally exercise still-supported APIs. In `phpstan.types.neon.dist`, set `noLegacyModelScopes`, `noLegacyModelProperties`, `noLegacyQueueProperties` and `noPipeSeparatedValidationRules` to `false`, with one comment that these fixtures assert supported types rather than authoring policy. Also set `checkModelMethodVisibility` and `noUnnecessaryEnumerableToArrayCalls` to `false` in this harness: `CustomBuilderForwarding.php` deliberately declares public scopes, and Collection/LazyCollection fixtures assert conversion return types. These preferences retain their normal defaults in source/consumer analysis and their dedicated `RuleTestCase` coverage. Preserve type assertions; do not delete compatibility cases. For upstream always-registered diagnostics without a switch, use a local identifier suppression with a fixture-specific reason only at the intentional call (for example the existing `value('foo', 42)` and `with(new User)` helper fixtures). Do not invent public switches or a second rule-registration system solely for the harness. @@ -449,7 +450,7 @@ For e2e baselines, preserve known intentional analyzer limitations with reasons - Private Composer repositories currently point at the main Components `src/*`. Validate against this worktree through a task-local Composer setup with its own installed dependencies, without permanently committing a worktree-specific path or disrupting another workload's vendor directory. Final private manifests reference the intended durable package source/version. - Audit every suppression affected by improved inference or the new ignore policy in Components, both private packages, skeletons and affected apps. Convert every reason-less identifier ignore and every blanket line/next-line ignore in their analyzed paths to a source fix or an accurate identifier-specific ignore with a parenthesized reason. Do not limit this to newly ported files or errors that became unmatched. Remove obsolete inline ignores/global patterns and correct stale explanations such as claiming a boot-registered macro is unknowable. Retain narrowly justified limitations; new broad ignores require the normal owner approval. - Audit deprecated-API usage in those same consumers, including calls to PHP, Symfony, Carbon and other vendor APIs, not just Hypervel declarations. Use the supported replacement when it preserves the contract. Keep a reasoned identifier-specific ignore when deprecated usage remains necessary, including compatibility with a still-supported public API. Do not remove supported APIs, strip truthful deprecation tags, mark unrelated code deprecated to silence errors, or add runtime workarounds merely to satisfy analysis. Dependency defects follow the normal owner decision workflow. -- Add `src/docs/phpstan.md` and navigation links, following existing Laravel-style documentation. Explain installation, app/Testbench boot, supported versions, configuration/defaults, optional integrations, custom PHPDoc types, diagnostic examples, suppressions, safe fixes, cache behavior and the distinction between authoring recommendations and runtime API removals. State in the page and release notes that adopting the documented setup diagnoses existing reason-less/blanket ignores and uses the included deprecation extension for known deprecated APIs across dependencies, including PHP, Symfony and Carbon. Explain its standard loading and opt-out mechanisms and upstream deprecated-scope behavior; do not promise detection of every runtime deprecation regardless of available metadata. Keep the package README minimal. +- Add `src/docs/phpstan.md` and navigation links, following existing Laravel-style documentation. Explain installation, app/Testbench boot (package analysis types auth users from the configured Testbench app, whose users model `AUTH_MODEL` or the workbench `User` selects), supported versions, configuration/defaults, optional integrations, custom PHPDoc types, diagnostic examples, suppressions, safe fixes, cache behavior and the distinction between authoring recommendations and runtime API removals. State in the page and release notes that adopting the documented setup diagnoses existing reason-less/blanket ignores and uses the included deprecation extension for known deprecated APIs across dependencies, including PHP, Symfony and Carbon. Explain its standard loading and opt-out mechanisms and upstream deprecated-scope behavior; do not promise detection of every runtime deprecation regardless of available metadata. Keep the package README minimal. - Add a concise `src/docs/porting-from-laravel.md` entry: replace `larastan/larastan` with `hypervel/phpstan`, update the extension include, migrate Larastan-owned ignore identifiers (`larastan.*`, `rules.modelAppends`) to the documented `hypervel.*` mapping, and retain PHPStan core identifiers. Explain that retained parameter names remain compatible while defaults and omitted Octane/deferred-provider settings differ; link to the canonical PHPStan page. - Update affected feature docs, skeleton setup, testing/type-fixture instructions, component manifests and package READMEs for centralized extension ownership. Amend `docs/todo.md` by removing completed items and the superseded instruction to keep extensions distributed. Preserve still-unimplemented runtime-removal and unrelated todos; update their references where this package now supplies checks. Never rewrite historical plans. - Record intentionally omitted upstream features in the package README, a concise source comment at the natural registration/insertion point and `REMOVED:` comments at matching test locations, as required by the porting policy. Do not create empty placeholder classes for omitted features or annotate routine namespace changes and bug fixes as public API differences. From d32fa883492f16b07aa263fc3560c89cec5672db Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:52:16 +0000 Subject: [PATCH 035/303] Describe console command maps and closure callback binding Document command-map values on the Console Application and Kernel contracts. Add the command callback closure-this type to the Kernel contract and Foundation implementation, keeping this framework fact in source instead of duplicating it in an analyzer stub. Add public type assertions for closure binding through both kernel entry points. Existing Kernel and ConsoleApplication tests pass; runtime signatures and behavior are unchanged. --- src/contracts/src/Console/Application.php | 2 ++ src/contracts/src/Console/Kernel.php | 4 ++++ src/foundation/src/Console/Kernel.php | 2 ++ types/Foundation/Console/Kernel.php | 19 +++++++++++++++++++ 4 files changed, 27 insertions(+) create mode 100644 types/Foundation/Console/Kernel.php diff --git a/src/contracts/src/Console/Application.php b/src/contracts/src/Console/Application.php index 08f0cc590..22fd614e9 100644 --- a/src/contracts/src/Console/Application.php +++ b/src/contracts/src/Console/Application.php @@ -37,6 +37,8 @@ public function has(string $name): bool; /** * Get all of the commands registered with the application. + * + * @return array */ public function all(?string $namespace = null): array; diff --git a/src/contracts/src/Console/Kernel.php b/src/contracts/src/Console/Kernel.php index 244aa62c4..113d5b012 100644 --- a/src/contracts/src/Console/Kernel.php +++ b/src/contracts/src/Console/Kernel.php @@ -33,6 +33,8 @@ public function resolveConsoleSchedule(): Schedule; /** * Register a Closure based command with the application. + * + * @param-closure-this ClosureCommand $callback */ public function command(string $signature, Closure $callback): ClosureCommand; @@ -77,6 +79,8 @@ public function queue(string $command, array $parameters = []): PendingDispatch; /** * Get all of the commands registered with the console. + * + * @return array */ public function all(): array; diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index 9d13bc7bc..9b6115f1b 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -354,6 +354,8 @@ protected function commands(): void * Register a Closure based command with the application. * * Boot or tests only. Commands remain registered for the kernel's lifetime. + * + * @param-closure-this ClosureCommand $callback */ public function command(string $signature, Closure $callback): ClosureCommand { diff --git a/types/Foundation/Console/Kernel.php b/types/Foundation/Console/Kernel.php new file mode 100644 index 000000000..5080704f4 --- /dev/null +++ b/types/Foundation/Console/Kernel.php @@ -0,0 +1,19 @@ +command('contract', function (): void { + assertType(ClosureCommand::class, $this); + }); + + $kernel->command('kernel', function (): void { + assertType(ClosureCommand::class, $this); + }); +}; From 1979dca6fdda53c296b2a9a5ddb2f11bab37320a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:52:16 +0000 Subject: [PATCH 036/303] Register analyzer console fixtures through supported application paths Declare the direct console dependency and load the command fixtures into the disposable Testbench application before boot. Use normal app/Console/Commands discovery for the ported fixtures and starting callbacks for the focused lookup fixtures. Preserve coverage for kernel-only and provider-style registration without replacing the Testbench kernel. The ordered lookup fixtures exercise definitions with and without an argument, receiver identity, overrides and guarded lookups. --- src/phpstan/composer.json | 1 + .../Fixtures/Console/LookupCommand.php | 42 ++++++++++++++++ .../Fixtures/Console/WithArgumentCommand.php | 31 ++++++++++++ .../Console/WithoutArgumentCommand.php | 10 ++++ .../app/Console/Commands/BarCommand.php | 29 +++++++++++ .../Commands/BarCommandWithAttribute.php | 35 +++++++++++++ .../app/Console/Commands/BazCommand.php | 23 +++++++++ .../app/Console/Commands/FooCommand.php | 49 +++++++++++++++++++ .../Commands/LoadedByKernelCommand.php | 31 ++++++++++++ .../Commands/RegisteredInKernelCommand.php | 31 ++++++++++++ tests/PHPStan/phpunit-bootstrap.php | 14 +++++- 11 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Fixtures/Console/LookupCommand.php create mode 100644 tests/PHPStan/Fixtures/Console/WithArgumentCommand.php create mode 100644 tests/PHPStan/Fixtures/Console/WithoutArgumentCommand.php create mode 100644 tests/PHPStan/application/app/Console/Commands/BarCommand.php create mode 100644 tests/PHPStan/application/app/Console/Commands/BarCommandWithAttribute.php create mode 100644 tests/PHPStan/application/app/Console/Commands/BazCommand.php create mode 100644 tests/PHPStan/application/app/Console/Commands/FooCommand.php create mode 100644 tests/PHPStan/application/app/Console/Commands/LoadedByKernelCommand.php create mode 100644 tests/PHPStan/application/app/Console/Commands/RegisteredInKernelCommand.php diff --git a/src/phpstan/composer.json b/src/phpstan/composer.json index 5399a9e25..3bd9a653c 100644 --- a/src/phpstan/composer.json +++ b/src/phpstan/composer.json @@ -33,6 +33,7 @@ "php": "^8.4", "composer-runtime-api": "^2.2", "hypervel/collections": "^0.4", + "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/contracts": "^0.4", "hypervel/database": "^0.4", diff --git a/tests/PHPStan/Fixtures/Console/LookupCommand.php b/tests/PHPStan/Fixtures/Console/LookupCommand.php new file mode 100644 index 000000000..925e7e981 --- /dev/null +++ b/tests/PHPStan/Fixtures/Console/LookupCommand.php @@ -0,0 +1,42 @@ +argument('argument')); + + // A passed guard keeps each call from the commands without the name, and the + // last one is never reached. + if ($this->hasArgument('argument')) { + $this->argument('argument'); + } + + if ($this->hasOption('option')) { + $this->option('option'); + } + + if ($this->hasArgument(name: 'argument')) { + $this->argument('argument'); + } + + if ($this->hasOption('option')) { + $this->option(key: 'option'); + } + + $this->hasArgument('missing') && $this->argument('missing'); + } +} diff --git a/tests/PHPStan/Fixtures/Console/WithArgumentCommand.php b/tests/PHPStan/Fixtures/Console/WithArgumentCommand.php new file mode 100644 index 000000000..27cb98de5 --- /dev/null +++ b/tests/PHPStan/Fixtures/Console/WithArgumentCommand.php @@ -0,0 +1,31 @@ +', $bar->argument('argumentArray')); + assertType('mixed', $this->option('alias')); + } +} diff --git a/tests/PHPStan/Fixtures/Console/WithoutArgumentCommand.php b/tests/PHPStan/Fixtures/Console/WithoutArgumentCommand.php new file mode 100644 index 000000000..521664ae1 --- /dev/null +++ b/tests/PHPStan/Fixtures/Console/WithoutArgumentCommand.php @@ -0,0 +1,10 @@ +}', $this->argument()); + assertType('array{command: string, argumentArray: array}', $this->arguments()); + assertType('array', $this->argument('argumentArray')); + + assertType('true', $this->hasArgument('argumentArray')); + + assertType('array{optionArray: array, disable-event-dispatcher: bool, help: bool, silent: bool, quiet: bool, verbose: bool, version: bool, ansi: bool|null, no-interaction: bool, env: string|null}', $this->options()); + assertType('array{optionArray: array, disable-event-dispatcher: bool, help: bool, silent: bool, quiet: bool, verbose: bool, version: bool, ansi: bool|null, no-interaction: bool, env: string|null}', $this->option()); + assertType('array', $this->option('optionArray')); + + assertType('true', $this->hasOption('optionArray')); + } +} diff --git a/tests/PHPStan/application/app/Console/Commands/BarCommandWithAttribute.php b/tests/PHPStan/application/app/Console/Commands/BarCommandWithAttribute.php new file mode 100644 index 000000000..46451829b --- /dev/null +++ b/tests/PHPStan/application/app/Console/Commands/BarCommandWithAttribute.php @@ -0,0 +1,35 @@ +}', $this->argument()); + assertType('array{command: string, argumentArray: array}', $this->arguments()); + assertType('array', $this->argument('argumentArray')); + + assertType('true', $this->hasArgument('argumentArray')); + + assertType('array{optionArray: array, disable-event-dispatcher: bool, help: bool, silent: bool, quiet: bool, verbose: bool, version: bool, ansi: bool|null, no-interaction: bool, env: string|null}', $this->options()); + assertType('array{optionArray: array, disable-event-dispatcher: bool, help: bool, silent: bool, quiet: bool, verbose: bool, version: bool, ansi: bool|null, no-interaction: bool, env: string|null}', $this->option()); + assertType('array', $this->option('optionArray')); + + assertType('true', $this->hasOption('optionArray')); + } +} diff --git a/tests/PHPStan/application/app/Console/Commands/BazCommand.php b/tests/PHPStan/application/app/Console/Commands/BazCommand.php new file mode 100644 index 000000000..37f1e8724 --- /dev/null +++ b/tests/PHPStan/application/app/Console/Commands/BazCommand.php @@ -0,0 +1,23 @@ +}', $this->argument()); + assertType('array{command: string, optionalArgumentArray: array}', $this->arguments()); + assertType('array', $this->argument('optionalArgumentArray')); + + assertType('true', $this->hasArgument('optionalArgumentArray')); + } +} diff --git a/tests/PHPStan/application/app/Console/Commands/FooCommand.php b/tests/PHPStan/application/app/Console/Commands/FooCommand.php new file mode 100644 index 000000000..819feb18e --- /dev/null +++ b/tests/PHPStan/application/app/Console/Commands/FooCommand.php @@ -0,0 +1,49 @@ +argument()); + assertType('array{command: string, argument: string, optionalArg: string|null, optionalArgWithDefault: string}', $this->arguments()); + assertType('array{command: string, argument: string, optionalArg: string|null, optionalArgWithDefault: string}', $this->argument(null)); + assertType('string', $this->argument('argument')); + assertType('string|null', $this->argument('optionalArg')); + assertType('string', $this->argument('optionalArgWithDefault')); + assertType('mixed', $this->argument('foobar')); + + assertType('true', $this->hasArgument('argument')); + assertType('true', $this->hasArgument('optionalArg')); + assertType('true', $this->hasArgument('optionalArgWithDefault')); + assertType('false', $this->hasArgument('foobar')); + + assertType('array{option: bool, optionWithValue: string|null, disable-event-dispatcher: bool, help: bool, silent: bool, quiet: bool, verbose: bool, version: bool, ansi: bool|null, no-interaction: bool, env: string|null}', $this->options()); + assertType('array{option: bool, optionWithValue: string|null, disable-event-dispatcher: bool, help: bool, silent: bool, quiet: bool, verbose: bool, version: bool, ansi: bool|null, no-interaction: bool, env: string|null}', $this->option()); + assertType('array{option: bool, optionWithValue: string|null, disable-event-dispatcher: bool, help: bool, silent: bool, quiet: bool, verbose: bool, version: bool, ansi: bool|null, no-interaction: bool, env: string|null}', $this->option(null)); + // Upstream reads shortcuts as options, but option() throws for them and accepts negations. + assertType('mixed', $this->option('O')); + assertType('bool', $this->option('option')); + assertType('string|null', $this->option('optionWithValue')); + assertType('bool', $this->option('help')); + assertType('mixed', $this->option('v')); + assertType('bool|null', $this->option('no-ansi')); + + assertType('false', $this->hasOption('O')); + assertType('true', $this->hasOption('option')); + assertType('true', $this->hasOption('optionWithValue')); + assertType('true', $this->hasOption('help')); + assertType('false', $this->hasOption('v')); + assertType('true', $this->hasOption('no-ansi')); + assertType('false', $this->hasOption('foobar')); + } +} diff --git a/tests/PHPStan/application/app/Console/Commands/LoadedByKernelCommand.php b/tests/PHPStan/application/app/Console/Commands/LoadedByKernelCommand.php new file mode 100644 index 000000000..9b41c9767 --- /dev/null +++ b/tests/PHPStan/application/app/Console/Commands/LoadedByKernelCommand.php @@ -0,0 +1,31 @@ +argument()); + assertType('string', $this->argument('argument')); + assertType('string|null', $this->option('option')); + + assertType('true', $this->hasArgument('argument')); + assertType('false', $this->hasArgument('undefinedArgument')); + assertType('true', $this->hasOption('option')); + } + + public function undefinedInput(): void + { + $this->argument('undefinedArgument'); + $this->option('undefinedOption'); + } +} diff --git a/tests/PHPStan/application/app/Console/Commands/RegisteredInKernelCommand.php b/tests/PHPStan/application/app/Console/Commands/RegisteredInKernelCommand.php new file mode 100644 index 000000000..baa64cc4d --- /dev/null +++ b/tests/PHPStan/application/app/Console/Commands/RegisteredInKernelCommand.php @@ -0,0 +1,31 @@ +argument()); + assertType('string', $this->argument('argument')); + assertType('string|null', $this->option('option')); + + assertType('true', $this->hasArgument('argument')); + assertType('false', $this->hasArgument('undefinedArgument')); + assertType('true', $this->hasOption('option')); + } + + public function undefinedInput(): void + { + $this->argument('undefinedArgument'); + $this->option('undefinedOption'); + } +} diff --git a/tests/PHPStan/phpunit-bootstrap.php b/tests/PHPStan/phpunit-bootstrap.php index c3ace95a9..50e15788e 100644 --- a/tests/PHPStan/phpunit-bootstrap.php +++ b/tests/PHPStan/phpunit-bootstrap.php @@ -3,8 +3,12 @@ declare(strict_types=1); use Composer\Autoload\ClassLoader; +use Hypervel\Console\Application; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\Console\ClosureCommand; use Hypervel\Testbench\Bootstrapper; +use Hypervel\Tests\PHPStan\Fixtures\Console\WithArgumentCommand; +use Hypervel\Tests\PHPStan\Fixtures\Console\WithoutArgumentCommand; require_once __DIR__ . '/../bootstrap.php'; @@ -21,7 +25,8 @@ $filesystem = new Filesystem; -foreach (['lang', 'resources/views'] as $directory) { +// The console kernel discovers app/Console/Commands, as it does for an application. +foreach (['app/Console/Commands', 'lang', 'resources/views'] as $directory) { $filesystem->deleteDirectory(BASE_PATH . '/' . $directory); $filesystem->copyDirectory(__DIR__ . '/application/' . $directory, BASE_PATH . '/' . $directory); } @@ -29,6 +34,13 @@ // Fixture config files replace the skeleton's files of the same name; the rest stay. $filesystem->copyDirectory(__DIR__ . '/application/config', BASE_PATH . '/config'); +// Providers register package commands through Application::starting(). These fixtures are +// registered only that way, in this order, which the command lookup tests rely on. +Application::starting(static function (Application $artisan): void { + $artisan->resolveCommands([WithoutArgumentCommand::class, WithArgumentCommand::class]); + $artisan->resolve(new ClosureCommand('lookup:closure', static function (): void {})); +}); + // Testbench points the users provider at the shared workbench User model unless // AUTH_MODEL names one, which would override the fixture auth configuration. $_SERVER['AUTH_MODEL'] = $_ENV['AUTH_MODEL'] = 'App\User'; From 22681c8e47b534f3ddcd4fa5a9a99e4c622bf68b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:52:16 +0000 Subject: [PATCH 037/303] Resolve normalized console definitions for analyzer receivers Read commands from the booted kernel and normalize application arguments and options before cached lookup results are consumed. Deduplicate command objects across aliases without requiring a canonical loader key. Resolve the actual receiver class and retain conservative fallback for broad command types or unknown union alternatives. Build bulk input shapes from the normalized definitions. A focused resolver test covers ordinary aliases and alias-only entries. --- .../src/Internal/ConsoleApplicationHelper.php | 122 +++++++++++++++ .../Internal/ConsoleApplicationResolver.php | 144 ++++++++++++++++++ .../ConsoleApplicationResolverTest.php | 41 +++++ 3 files changed, 307 insertions(+) create mode 100644 src/phpstan/src/Internal/ConsoleApplicationHelper.php create mode 100644 src/phpstan/src/Internal/ConsoleApplicationResolver.php create mode 100644 tests/PHPStan/Unit/Internal/ConsoleApplicationResolverTest.php diff --git a/src/phpstan/src/Internal/ConsoleApplicationHelper.php b/src/phpstan/src/Internal/ConsoleApplicationHelper.php new file mode 100644 index 000000000..3554e33e8 --- /dev/null +++ b/src/phpstan/src/Internal/ConsoleApplicationHelper.php @@ -0,0 +1,122 @@ + $commands + */ + public function getArguments(array $commands, Scope $scope): ?Type + { + $argTypes = []; + + foreach ($commands as $command) { + $arguments = $command->getDefinition()->getArguments(); + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($arguments as $name => $argument) { + $argumentType = $this->getArgumentType($scope, $argument); + $builder->setOffsetValueType(new ConstantStringType($name), $argumentType); + } + + $argTypes[] = $builder->getArray(); + } + + return count($argTypes) > 0 ? TypeCombinator::union(...$argTypes) : null; + } + + /** + * Get the type of the given command argument. + */ + public function getArgumentType(Scope $scope, InputArgument $argument): Type + { + if ($argument->isArray()) { + $argType = new ArrayType(new IntegerType, new StringType); + + if (! $argument->isRequired() && $argument->getDefault() !== []) { + $argType = TypeCombinator::union($argType, $scope->getTypeFromValue($argument->getDefault())); + } + } else { + $argType = new StringType; + + if (! $argument->isRequired()) { + $argType = TypeCombinator::union($argType, $scope->getTypeFromValue($argument->getDefault())); + } + } + + return $argType; + } + + /** + * Get the options array type of the given commands. + * + * @param array $commands + */ + public function getOptions(array $commands, Scope $scope): ?Type + { + $optTypes = []; + + foreach ($commands as $command) { + $options = $command->getDefinition()->getOptions(); + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($options as $name => $option) { + $argumentType = $this->getOptionType($scope, $option); + $builder->setOffsetValueType(new ConstantStringType($name), $argumentType); + } + + $optTypes[] = $builder->getArray(); + } + + return count($optTypes) > 0 ? TypeCombinator::union(...$optTypes) : null; + } + + /** + * Get the type of the given command option. + */ + public function getOptionType(Scope $scope, InputOption $option): Type + { + if (! $option->acceptValue()) { + if ($option->isNegatable()) { + return TypeCombinator::addNull(new BooleanType); + } + + return new BooleanType; + } + + $optType = TypeCombinator::union(new StringType, new NullType); + + if ($option->isValueRequired() && ($option->isArray() || $option->getDefault() !== null)) { + $optType = TypeCombinator::removeNull($optType); + } + + if ($option->isArray()) { + $optType = new ArrayType(new IntegerType, $optType); + } + + return TypeCombinator::union($optType, $scope->getTypeFromValue($option->getDefault())); + } +} diff --git a/src/phpstan/src/Internal/ConsoleApplicationResolver.php b/src/phpstan/src/Internal/ConsoleApplicationResolver.php new file mode 100644 index 000000000..f9e9b48cf --- /dev/null +++ b/src/phpstan/src/Internal/ConsoleApplicationResolver.php @@ -0,0 +1,144 @@ + */ + private ?array $commands = null; + + /** @var array> */ + private array $commandsByClass = []; + + /** + * Find the registered commands that are instances of the given class. + * + * @return array keyed by object ID + */ + public function findCommands(ClassReflection $classReflection): array + { + $className = $classReflection->getName(); + + if (array_key_exists($className, $this->commandsByClass)) { + return $this->commandsByClass[$className]; + } + + // The base command stands for every command, and closure commands share one class + // across unrelated signatures, so neither identifies a definition. + if (! $classReflection->is(Command::class) || $className === Command::class || $className === ClosureCommand::class) { + return $this->commandsByClass[$className] = []; + } + + $commands = []; + + foreach ($this->getCommands() as $command) { + $id = spl_object_id($command); + + // Each alias lists a command again, under a key that need not be its own name. + if (isset($commands[$id]) || ! is_a($command, $className)) { + continue; + } + + // Upstream merges the application definition only for single-name lookups, so the + // shared command's bulk argument and option shapes depended on analysis order. + // Merge it once here, as run() does before binding the input. + $command->mergeApplicationDefinition(); + + $commands[$id] = $command; + } + + return $this->commandsByClass[$className] = $commands; + } + + /** + * Find the registered commands the given receiver type can be. + * + * @return array keyed by object ID + */ + public function findCommandsForType(Type $type): array + { + $commands = []; + + foreach ($type->getObjectClassReflections() as $classReflection) { + $classCommands = $this->findCommands($classReflection); + + // An alternative with no known definition could define any name, so the other + // alternatives cannot describe the receiver. + if ($classCommands === []) { + return []; + } + + $commands += $classCommands; + } + + return $commands; + } + + /** + * Get the commands registered with the console. + * + * Commands registered through the console kernel (`withCommands()` classes and discovered + * paths, and `command()` closures) are only added to the kernel's own Artisan application, + * so ask the kernel. + * + * @return array + */ + private function getCommands(): array + { + if ($this->commands !== null) { + return $this->commands; + } + + $kernel = $this->resolve(Kernel::class); + + if ($kernel instanceof Kernel) { + try { + return $this->commands = $kernel->all(); + } catch (Throwable) { + // The kernel cannot be asked for its commands, e.g. because one of them cannot be resolved + // in the analysis environment. Fall back to a standalone Artisan application, which only + // sees the commands registered through Artisan::starting(). + } + } + + return $this->commands = $this->getApplication()->all(); + } + + /** + * Get a standalone console application. + */ + private function getApplication(): Application + { + if ($this->application === null) { + $this->application = new Application(app(), app(Dispatcher::class), app()->version()); + $this->application->setContainerCommandLoader(); + } + + return $this->application; + } +} diff --git a/tests/PHPStan/Unit/Internal/ConsoleApplicationResolverTest.php b/tests/PHPStan/Unit/Internal/ConsoleApplicationResolverTest.php new file mode 100644 index 000000000..33dfdb11d --- /dev/null +++ b/tests/PHPStan/Unit/Internal/ConsoleApplicationResolverTest.php @@ -0,0 +1,41 @@ +instance(Kernel::class, m::mock(Kernel::class, ['all' => [ + 'lookup:with' => $with, + 'lookup:with-alias' => $with, + // A command loader may list a command only under a key other than its own name. + 'lookup:without-alias' => $without, + ]])); + + $resolver = new ConsoleApplicationResolver; + $resolver->setContainer($container); + + $commands = $resolver->findCommands(PHPStanTestCase::createReflectionProvider()->getClass(LookupCommand::class)); + + $this->assertSame([$with, $without], array_values($commands)); + } +} From a226bb3e7f7fbdc22d35876aa4f372c66d03427f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:52:16 +0000 Subject: [PATCH 038/303] Expose Artisan command closure binding through analyzer stubs Port the Artisan facade kernel mixin so command callbacks inherit the source closure-this annotation. Supply only the empty Kernel declaration required by isolated stub validation. Activate the upstream Artisan fixture that asserts callback variable certainty and ClosureCommand binding. --- src/phpstan/stubs/common/Facades.stub | 6 ++++++ .../stubs/common/Foundation/Console/Kernel.stub | 6 ++++++ tests/PHPStan/Type/data/artisan.php | 16 ++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 src/phpstan/stubs/common/Foundation/Console/Kernel.stub create mode 100644 tests/PHPStan/Type/data/artisan.php diff --git a/src/phpstan/stubs/common/Facades.stub b/src/phpstan/stubs/common/Facades.stub index d89d172ae..1e2f5c6e6 100644 --- a/src/phpstan/stubs/common/Facades.stub +++ b/src/phpstan/stubs/common/Facades.stub @@ -2,11 +2,17 @@ namespace Hypervel\Support\Facades; +use Hypervel\Foundation\Console\Kernel; use Hypervel\Routing\Router; use Hypervel\View\Factory; abstract class Facade {} +/** + * @mixin Kernel + */ +class Artisan extends Facade {} + /** * @mixin Factory */ diff --git a/src/phpstan/stubs/common/Foundation/Console/Kernel.stub b/src/phpstan/stubs/common/Foundation/Console/Kernel.stub new file mode 100644 index 000000000..9bfbd738e --- /dev/null +++ b/src/phpstan/stubs/common/Foundation/Console/Kernel.stub @@ -0,0 +1,6 @@ +describe('Foo Bar'); +} From a0ce784f33b56fde071edd7d4866b04d32143a3e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:52:16 +0000 Subject: [PATCH 039/303] Infer console input types from registered command definitions Port the four argument and option return-type extensions using receiver-based command lookup. Preserve overridden declarations, treat explicit null keys as bulk reads, and aggregate named input types independently of command iteration order. Match runtime option semantics: negations are valid names and CLI shortcuts are not getter names. Register the command fixtures, including cold-first bulk shapes, fallback results, cross-command receivers and unknown receiver unions. --- .../ArgumentDynamicReturnTypeExtension.php | 106 +++++++++++++++++ .../HasArgumentDynamicReturnTypeExtension.php | 81 +++++++++++++ .../HasOptionDynamicReturnTypeExtension.php | 84 +++++++++++++ .../OptionDynamicReturnTypeExtension.php | 110 ++++++++++++++++++ tests/PHPStan/Type/GeneralTypeTest.php | 15 +++ tests/PHPStan/Type/data/console-commands.php | 30 +++++ 6 files changed, 426 insertions(+) create mode 100644 src/phpstan/src/ReturnTypes/ConsoleCommand/ArgumentDynamicReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/ConsoleCommand/HasArgumentDynamicReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/ConsoleCommand/HasOptionDynamicReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/ConsoleCommand/OptionDynamicReturnTypeExtension.php create mode 100644 tests/PHPStan/Type/data/console-commands.php diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/ArgumentDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/ArgumentDynamicReturnTypeExtension.php new file mode 100644 index 000000000..59332d988 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/ArgumentDynamicReturnTypeExtension.php @@ -0,0 +1,106 @@ +getName(), ['argument', 'arguments'], true) + && $methodReflection->getDeclaringClass()->getName() === Command::class; + } + + /** + * Get the type of the requested command arguments. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + // Upstream reads the enclosing class, which mistypes calls on another command. + $commands = $this->consoleApplicationResolver->findCommandsForType($scope->getType($methodCall->var)); + + if ($commands === []) { + return null; + } + + $args = $methodCall->getArgs(); + + if ($args === [] + || $methodReflection->getName() === 'arguments' + || (! $args[0]->unpack && $scope->getType($args[0]->value)->isNull()->yes()) + ) { + return $this->consoleApplicationHelper->getArguments($commands, $scope); + } + + $argStrings = $scope->getType($args[0]->value)->getConstantStrings(); + + if (count($argStrings) === 0) { + return null; + } + + $defaultReturnType = ParametersAcceptorSelector::selectFromArgs($scope, $args, $methodReflection->getVariants())->getReturnType(); + + $returnTypes = []; + + foreach ($argStrings as $argString) { + $argName = $argString->getValue(); + + $argTypes = []; + + foreach ($commands as $command) { + try { + $argument = $command->getDefinition()->getArgument($argName); + + $argTypes[] = $this->consoleApplicationHelper->getArgumentType($scope, $argument); + } catch (InvalidArgumentException) { + // A command without the argument throws rather than returning a value. + } + } + + // Upstream added the partial union for each command, so the result depended on their order. + $returnTypes[] = count($argTypes) > 0 ? TypeCombinator::union(...$argTypes) : $defaultReturnType; + } + + return TypeCombinator::union(...$returnTypes); + } +} diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/HasArgumentDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasArgumentDynamicReturnTypeExtension.php new file mode 100644 index 000000000..2b2fa4882 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasArgumentDynamicReturnTypeExtension.php @@ -0,0 +1,81 @@ +getName() === 'hasArgument' + && $methodReflection->getDeclaringClass()->getName() === Command::class; + } + + /** + * Get whether the command defines the given argument. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + if ($methodCall->getArgs() === []) { + return null; + } + + $constantStrings = $scope->getType($methodCall->getArgs()[0]->value)->getConstantStrings(); + + if (count($constantStrings) !== 1) { + return null; + } + + $returnTypes = []; + + // Upstream reads the enclosing class, which mistypes calls on another command. + foreach ($this->consoleApplicationResolver->findCommandsForType($scope->getType($methodCall->var)) as $command) { + $returnTypes[] = $command->getDefinition()->hasArgument($constantStrings[0]->getValue()); + } + + if (count($returnTypes) === 0) { + return null; + } + + $returnTypes = array_unique($returnTypes); + + return count($returnTypes) === 1 ? new ConstantBooleanType($returnTypes[0]) : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/HasOptionDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasOptionDynamicReturnTypeExtension.php new file mode 100644 index 000000000..16bc27936 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasOptionDynamicReturnTypeExtension.php @@ -0,0 +1,84 @@ +getName() === 'hasOption' + && $methodReflection->getDeclaringClass()->getName() === Command::class; + } + + /** + * Get whether the command defines the given option or negation. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + if ($methodCall->getArgs() === []) { + return null; + } + + $constantStrings = $scope->getType($methodCall->getArgs()[0]->value)->getConstantStrings(); + + if (count($constantStrings) !== 1) { + return null; + } + + $argName = $constantStrings[0]->getValue(); + + $returnTypes = []; + + // Upstream reads the enclosing class, which mistypes calls on another command. + foreach ($this->consoleApplicationResolver->findCommandsForType($scope->getType($methodCall->var)) as $command) { + // Input::hasOption() accepts negations such as no-ansi, but not the shortcuts that upstream accepted. + $returnTypes[] = $command->getDefinition()->hasOption($argName) || $command->getDefinition()->hasNegation($argName); + } + + if (count($returnTypes) === 0) { + return null; + } + + $returnTypes = array_unique($returnTypes); + + return count($returnTypes) === 1 ? new ConstantBooleanType($returnTypes[0]) : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/OptionDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/OptionDynamicReturnTypeExtension.php new file mode 100644 index 000000000..3e050e1aa --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/OptionDynamicReturnTypeExtension.php @@ -0,0 +1,110 @@ +getName(), ['option', 'options'], true) + && $methodReflection->getDeclaringClass()->getName() === Command::class; + } + + /** + * Get the type of the requested command options. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + // Upstream reads the enclosing class, which mistypes calls on another command. + $commands = $this->consoleApplicationResolver->findCommandsForType($scope->getType($methodCall->var)); + + if ($commands === []) { + return null; + } + + $args = $methodCall->getArgs(); + + if ($args === [] + || $methodReflection->getName() === 'options' + || (! $args[0]->unpack && $scope->getType($args[0]->value)->isNull()->yes()) + ) { + return $this->consoleApplicationHelper->getOptions($commands, $scope); + } + + $argStrings = $scope->getType($args[0]->value)->getConstantStrings(); + + if (count($argStrings) === 0) { + return null; + } + + $defaultReturnType = ParametersAcceptorSelector::selectFromArgs($scope, $args, $methodReflection->getVariants())->getReturnType(); + + $returnTypes = []; + + foreach ($argStrings as $argString) { + $argName = $argString->getValue(); + + $argTypes = []; + + foreach ($commands as $command) { + try { + $definition = $command->getDefinition(); + + // Input::getOption() reads negations such as no-ansi, but not the shortcuts that + // upstream accepted. A negatable option takes no value, so the type stays bool|null. + $argument = $definition->getOption($definition->hasNegation($argName) ? $definition->negationToName($argName) : $argName); + + $argTypes[] = $this->consoleApplicationHelper->getOptionType($scope, $argument); + } catch (InvalidArgumentException) { + // A command without the option throws rather than returning a value. + } + } + + // Upstream added the partial union for each command, so the result depended on their order. + $returnTypes[] = count($argTypes) > 0 ? TypeCombinator::union(...$argTypes) : $defaultReturnType; + } + + return TypeCombinator::union(...$returnTypes); + } +} diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php index da803a6a3..b3a47f54d 100644 --- a/tests/PHPStan/Type/GeneralTypeTest.php +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -23,7 +23,10 @@ public static function dataFileAsserts(): iterable { yield from self::gatherAssertTypes(__DIR__ . '/data/app-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/application-make.php'); + // Upstream never registers artisan.php. + yield from self::gatherAssertTypes(__DIR__ . '/data/artisan.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/auth.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/console-commands.php'); // REMOVED: container-array-access.php; container ArrayAccess is unsupported. yield from self::gatherAssertTypes(__DIR__ . '/data/container-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/contracts.php'); @@ -34,6 +37,18 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/data/translator.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/view-exists.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/view.php'); + + // Console Commands + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/BarCommand.php'); + // Upstream never registers BarCommandWithAttribute.php. + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/BarCommandWithAttribute.php'); + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/BazCommand.php'); + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/FooCommand.php'); + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/RegisteredInKernelCommand.php'); + // Hypervel's kernel loads commands from the discovered app/Console/Commands directory. + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/LoadedByKernelCommand.php'); + yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Console/LookupCommand.php'); + yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Console/WithArgumentCommand.php'); } /** @return string[] */ diff --git a/tests/PHPStan/Type/data/console-commands.php b/tests/PHPStan/Type/data/console-commands.php new file mode 100644 index 000000000..857b98dae --- /dev/null +++ b/tests/PHPStan/Type/data/console-commands.php @@ -0,0 +1,30 @@ +argument('argument')); + assertType('true', $foo->hasOption('option')); + + // The base command stands for every registered command, so it keeps the declaration. + assertType('mixed', $command->argument('argument')); + + // The closure alternative could define any name, so the union keeps the declaration. + assertType('mixed', $fooOrClosure->argument('argument')); + assertType('mixed', $fooOrClosure->argument('missing')); + + Artisan::command('closure', function (): void { + // Closure commands share one class across unrelated signatures, so they keep the declaration. + assertType('mixed', $this->argument('argument')); + }); +} From 49b12375e088d0dfcb518f984f9ad4e3fd7c40c7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:52:16 +0000 Subject: [PATCH 040/303] Report undefined console inputs while respecting scope guards Port undefined argument and option diagnostics against the actual command receiver. Preserve custom method behavior and report canonical command names once across aliases. Use existing PHPStan scope information to honor passed hasArgument and hasOption guards, including positional and named forms built from the getter value. Reject shortcuts and accept negated option names consistently with runtime Input. Focused rule fixtures protect invalid names, safe guards, receiver exclusions and cross-command calls. Source, public type and skeleton analysis pass; the analyzer suite passes with 253 tests and 344 assertions. --- .../UndefinedArgumentOrOptionRule.php | 116 ++++++++++++++++++ .../UndefinedArgumentOrOptionRuleTest.php | 67 ++++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php create mode 100644 tests/PHPStan/Rules/UndefinedArgumentOrOptionRuleTest.php diff --git a/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php b/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php new file mode 100644 index 000000000..6902acc82 --- /dev/null +++ b/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php @@ -0,0 +1,116 @@ + */ +#[AutowiredService] +final class UndefinedArgumentOrOptionRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct(private ConsoleApplicationResolver $consoleApplicationResolver) + { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Report arguments and options that the command does not define. + * + * @return RuleError[] errors + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Identifier || ! in_array($node->name->toLowerString(), ['argument', 'option'], true)) { + return []; + } + + if (count($node->getArgs()) !== 1) { + return []; + } + + $receiverType = $scope->getType($node->var); + + if (! (new ObjectType(Command::class))->isSuperTypeOf($receiverType)->yes()) { + return []; + } + + $methodName = $node->name->toLowerString(); + + // A command's own override decides which names it accepts. + if ($scope->getMethodReflection($receiverType, $methodName)?->getDeclaringClass()->getName() !== Command::class) { + return []; + } + + $value = $node->getArgs()[0]->value; + + $argStrings = $scope->getType($value)->getConstantStrings(); + + if (count($argStrings) !== 1) { + return []; + } + + // A hasArgument() or hasOption() guard that is definitely true, or unreachable because + // no command defines the name, keeps the call from commands without it. The scope keys + // the guard as written, so check its positional and named forms. + $guardMethod = $methodName === 'argument' ? 'hasArgument' : 'hasOption'; + + foreach ([new Arg($value), new Arg($value, name: new Identifier('name'))] as $guardArg) { + if ($scope->getType(new MethodCall($node->var, $guardMethod, [$guardArg]))->isFalse()->no()) { + return []; + } + } + + $argName = $argStrings[0]->getValue(); + + $errors = []; + + // Upstream checks the enclosing command, which misreports calls on another command. + foreach ($this->consoleApplicationResolver->findCommandsForType($receiverType) as $command) { + $name = $command->getName(); + + if ($methodName === 'argument') { + if (! $command->getDefinition()->hasArgument($argName)) { + $errors[] = RuleErrorBuilder::message(sprintf('Command "%s" does not have argument "%s".', $name, $argName)) + ->line($node->getStartLine()) + ->identifier('hypervel.console.undefinedArgument') + ->build(); + } + } elseif (! $command->getDefinition()->hasOption($argName) && ! $command->getDefinition()->hasNegation($argName)) { + // Input::getOption() reads negations such as no-ansi, but throws for the shortcuts that upstream accepted. + $errors[] = RuleErrorBuilder::message(sprintf('Command "%s" does not have option "%s".', $name, $argName)) + ->line($node->getStartLine()) + ->identifier('hypervel.console.undefinedOption') + ->build(); + } + } + + return $errors; + } +} diff --git a/tests/PHPStan/Rules/UndefinedArgumentOrOptionRuleTest.php b/tests/PHPStan/Rules/UndefinedArgumentOrOptionRuleTest.php new file mode 100644 index 000000000..fed67364b --- /dev/null +++ b/tests/PHPStan/Rules/UndefinedArgumentOrOptionRuleTest.php @@ -0,0 +1,67 @@ + */ +class UndefinedArgumentOrOptionRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new UndefinedArgumentOrOptionRule(self::getContainer()->getByType(ConsoleApplicationResolver::class)); + } + + public function testRule(): void + { + $this->analyse([ + __DIR__ . '/../application/app/Console/Commands/FooCommand.php', + __DIR__ . '/../application/app/Console/Commands/BarCommand.php', + __DIR__ . '/../application/app/Console/Commands/BazCommand.php', + ], [ + ['Command "foo" does not have argument "foobar".', 23], + // Upstream accepts shortcuts, which option() throws for. + ['Command "foo" does not have option "O".', 34], + ['Command "foo" does not have option "v".', 38], + ]); + } + + public function testRuleWithCommandsRegisteredByTheConsoleKernel(): void + { + // Upstream's fixture console kernel is omitted: Hypervel applications have no kernel + // class, and the kernel discovers app/Console/Commands, as withCommands() configures. + $this->analyse([ + __DIR__ . '/../application/app/Console/Commands/RegisteredInKernelCommand.php', + __DIR__ . '/../application/app/Console/Commands/LoadedByKernelCommand.php', + ], [ + ['Command "registered-in-kernel" does not have argument "undefinedArgument".', 28], + ['Command "registered-in-kernel" does not have option "undefinedOption".', 29], + ['Command "loaded-by-kernel" does not have argument "undefinedArgument".', 28], + ['Command "loaded-by-kernel" does not have option "undefinedOption".', 29], + ]); + } + + public function testRuleChecksTheReceiversCommands(): void + { + $this->analyse([ + __DIR__ . '/../Fixtures/Console/LookupCommand.php', + __DIR__ . '/../Fixtures/Console/WithArgumentCommand.php', + __DIR__ . '/../Type/data/console-commands.php', + ], [ + ['Command "lookup:without" does not have argument "argument".', 20], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/phpstan-rules.neon', + ]; + } +} From 776c6f0f21b22b9c96eb6c1abe79367812af30bb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:52:16 +0000 Subject: [PATCH 041/303] Update the tracked PHPStan plan after console analysis Record the settled console inference and diagnostic behavior, including receiver identity, normalized definitions, aliases, negations, declaration fidelity and scope-aware guards. Update the remaining-file checklist for the completed console flow and retain subsequent porting and final integration obligations. --- .../2026-11-04-hypervel-phpstan-checklist.md | 16 ++++++++-------- docs/plans/2026-11-04-hypervel-phpstan.md | 2 ++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index 478209147..392d444f5 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -26,7 +26,7 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `ClosureTypes/RelationshipQueryCallbackExtension.php` - copy with the queue rules: `Concerns/InspectsQueuedJobs.php` -- copy: `Internal/{ConfigHelper,ConsoleApplicationHelper,ConsoleApplicationResolver,RecursionGuard}.php` +- copy: `Internal/{ConfigHelper,RecursionGuard}.php` - omit: `Internal/ComposerHelper.php` (`Support\Composer::vendorPath()` resolves the actual install) - copy: `Methods/{BuilderHelper,FacadesMethodsExtension,HigherOrderCollectionProxyExtension,HigherOrderTapProxyExtension,Macro,MacroMethodsClassReflectionExtension,ManagersMethodsExtension,ModelFactoryMethodsClassReflectionExtension,StorageMethodsClassReflectionExtension}.php` - merge into moved forwarding extensions: `Methods/{EloquentBuilderForwardsCallsExtension,ModelForwardsCallsExtension,RelationForwardsCallsExtension}.php` @@ -34,8 +34,8 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `Properties/*` (17 files, including `Schema/MySqlDataTypeToPhpTypeConverter.php`) - copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection,ReflectionHelper,StaticMethodReflection}.php` - merge into moved scope reflections: `Reflection/ModelScopeMethodReflection.php` -- copy: `ReturnTypes/*` except the ported auth, container typing and translation files and the omitted `ContainerArrayAccessDynamicMethodReturnTypeExtension`/`ApplicationMakeDynamicReturnTypeExtension` (36 files, including `ConsoleCommand/` and `Helpers/`) -- copy: `Rules/*` except below and the ported auth request-scope, `NoMissingTranslationsRule` and `UnusedViewsRule` rules (24 files, including `Queue/` and `UselessConstructs/`) +- copy: `ReturnTypes/*` except the ported auth, console, container typing and translation files and the omitted `ContainerArrayAccessDynamicMethodReturnTypeExtension`/`ApplicationMakeDynamicReturnTypeExtension` (32 files, including `Helpers/`) +- copy: `Rules/*` except below and the ported auth request-scope, console, `NoMissingTranslationsRule` and `UnusedViewsRule` rules (23 files, including `Queue/` and `UselessConstructs/`) - omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` - copy: `SQL/*` (7 files) - copy: `Support/{ArrKeyLookup,CollectionHelper,ConfigParser,HigherOrderCollectionProxyHelper}.php` @@ -50,8 +50,8 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## stubs -- copy: `common/*` except below and the ported view stubs (76 files) -- merge into `common/Facades.stub` and `common/Helpers.stub` with their topics: the remaining facade and helper entries (only View, Route and `view()` are ported; `trans()`/`__()` are omitted, as the stub explains) +- copy: `common/*` except below, the ported view stubs and `Foundation/Console/Kernel.stub` (its `command()` closure type moved to source; the stub keeps only the empty declaration the `Artisan` mixin needs for stub validation) (75 files) +- merge into `common/Facades.stub` and `common/Helpers.stub` with their topics: the remaining facade and helper entries (only Artisan, View, Route and `view()` are ported; `trans()`/`__()` are omitted, as the stub explains) - add to source with the routing stubs: upstream `common/Route.stub`'s `methods()` and `middleware()` element types as `Route` PHPDoc in `src/routing`, with a `types/` fixture (the stub keeps only the empty declaration stub validation needs) - omit: `common/Redis/{PredisConnection,PredisFacade}.stub`, `common/Gate.stub` (Hypervel's `Gate::forUser()` and its contract already return `static`) - add to source: upstream `common/MailMessage.stub`'s property shapes (`$view`, `$from`, `$replyTo`, `$attachments`, `$rawAttachments`) as `MailMessage` PHPDoc in `src/notifications`, with a `types/` fixture @@ -61,12 +61,12 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## tests - copy: `bootstrap.php`, `enum-definition.php`, then add the test bootstrap to `tests/PHPStan/phpstan-tests.neon`; omit `laravel-test.sh` -- copy: the remaining `application/` fixture app (76 files: app, config, factories, migrations, schema dump) +- copy: the remaining `application/` fixture app (69 files: app, config, factories, migrations, schema dump) - merge into `Integration/IntegrationTest.php`: the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties; copy `Integration/BleedingEdgeIntegrationTest.php` - copy: `Reflection/MacroMethodsClassReflectionExtensionTest.php` -- copy: `Rules/*Test.php` except below and the ported auth, view and translation rule tests (22 classes), the remaining `Rules/data/*`, `Rules/Queue/data/*`, `Rules/UselessConstructs/data/*` +- copy: `Rules/*Test.php` except below and the ported auth, console, view and translation rule tests (21 classes), the remaining `Rules/data/*`, `Rules/Queue/data/*`, `Rules/UselessConstructs/data/*` - omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` -- copy: `Type/*Test.php` except below, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php` (10 classes) with the remaining `Type/data/*` (144 files) +- copy: `Type/*Test.php` except below, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php` (10 classes) with the remaining `Type/data/*` (143 files) - merge into `Type/MethodsClassReflectionExtensionTest.php` with the macro port: `macros.php` and `macros-php-81.php` (unconditional; Hypervel requires PHP 8.4) - merge: the remaining `Type/GeneralTypeTest.php` data entries, and its `data/config-with-migrations.neon` configuration once migrations are ported - merge: `Type/{RedisDriverNullTypeTest,RedisDriverPhpRedisTypeTest}.php` and their data into one phpredis test; omit `Type/RedisDriverPredisTypeTest.php` with `redis-driver-predis.*` diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 1086ab167..8475442a1 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -14,6 +14,7 @@ Remaining work: - Also completed: the package-mode boot and Composer-install fixes, container typing (Bootstrap and runtime discovery) with the source typing fixes it exposed, and the initial `GeneralTypeTest`. - Also completed: view and translation analysis (collectors, the missing-translation and unused-view rules, `view-string` types and stubs, with-method reflection, translation return types) with their tests. Their registration-provider dependencies remain with the shared runtime-input tracking under Result-cache correctness and analysis cost. - Also completed: the authentication flow (guard and user return types, the request-scope rules, the fixture app's auth models and config) with `AuthGuardTypeTest` for configuration-dependent guard typing. +- Also completed: console command analysis (argument/option return types, the undefined-input rule, the fixture commands and the `Artisan` facade stub). - Next: port the checklist's remaining `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. @@ -217,6 +218,7 @@ Port every applicable upstream inference feature, not just the rules in the next | Builder/relation forwarding | Preserve model generics, custom builders, relation-specific fluent returns, attributed scopes, SoftDeletes/Scout and macro callbacks. Merge existing Hypervel capabilities with applicable upstream cases. | | Helpers and collections | Preserve conditional argument behavior, `Arr` shapes, `filter()` narrowing, higher-order proxy callbacks and generic collections. Compare inherited annotations before adding a stub. | | Authentication | One shared guard-type helper serves `Auth::guard()`, `Factory::guard()` and `auth($guard)`, following `AuthManager::resolve()`: a booted custom creator's declared return (`ClosureTypeFactory`, never invoked), else the booted manager's `create{Driver}Driver()` declaration, else the declared return. `viaRequest()`'s creator declares `RequestGuard`, so request guards are typed; an untyped creator keeps the declared return. Never claim `StatefulGuard` for an unmodelled guard: only `SessionGuard` is stateful, and token/request/Sanctum/JWT/Passport guards are not. An omitted, null or empty name is the union of every configured guard's type, because `shouldUse()` changes the default per coroutine; so is an unknown or unpacked name, because resolving an unconfigured name throws. A known set of string or enum names gives the union of those guards. `auth()` and `auth(null)` type as the booted factory concrete; unlike `guard(null)`, a maybe-null or unpacked `auth()` argument can return the factory, so it keeps the declared conditional. Guard names come only from `guard()` on the factory or facade, the global `auth()` (resolved function identity) and `Request::user()`; enum cases map like `enum_value()` where the signature accepts enums. User models are provider-based inference: an Eloquent provider gives its model for any guard driver; a database provider gives `GenericUser` only for the stock session/token drivers (Sanctum accepts session-guard users when its provider has no `getModel()`). A guard without a modelled provider, or one whose provider driver has a custom creator, keeps the call's declaration. Preserve narrower declarations; manager and facade `user()` calls are typed only when they forward the `Guard` contract's method. Omit `AuthsMethodsExtension`: `AuthManager`'s mixins already forward guard methods, and contracts do not gain implementation methods. | +| Console commands | Argument/option types and the undefined-input rule read the booted kernel's commands (`$kernel->all()`: `withCommands()` discovery, `command()` closures and providers' `starting()` registrations; a standalone application is the fallback when the kernel cannot list them). Look up the call's receiver, not the enclosing class; exactly `Command` and `ClosureCommand` receivers keep the declaration, because they name every command or unrelated per-instance signatures. Merge each matched command's application definition once at lookup, as `run()` does, so bulk shapes include `command` and the global options regardless of analysis order. A name lookup is the union over matching commands that define it (missing ones throw), else the declaration; option names follow `Input`, so negations resolve to their option and shortcuts are not names. Aliases list a command once. A definitely-null key is a bulk lookup; overridden methods keep their declarations for typing and diagnostics. The rule skips a call whose same-receiver `hasArgument()`/`hasOption()` guard is definitely true or unreachable in scope, because the call never reaches the commands without the name; Components' `GeneratorCommand` and `CreatesMatchingTest` guard `--force` this way. Scope keys the guard as written, so check its positional and `name:` forms built from the getter's value, never the getter's own argument nodes. A receiver union with an alternative that has no known definition (e.g. `FooCommand|ClosureCommand`) keeps the declaration. `@param-closure-this` for `command()` lives on the Kernel contract and Foundation Kernel. | | Views and translations | Retain `view-string` and live view-finder namespaces. Keep translation typing and the adapted missing-key rule. The optional unused-view rule reports absent statically discovered usage, not proof that a view can be deleted. | | Custom PHPDoc types | Port `model-property`, `relation-of`, `builder-of`, `collection-of`, `view-string`, their parsers, templates and applicable tests. Keep these analyzer-only annotations in stubs where adding them to framework source would require this dev package. | | Dates and environment | Keep native/source `CarbonInterface` boundaries for configurable dates and exact immutable types for framework-owned values. Do not infer configurable dates from `get_class(now())`, or assume an `env()` default dictates the supplied environment value's type. | From d9d8c5ebf27e380bb849135c4fa1f8d1b5557996 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:23:47 +0000 Subject: [PATCH 042/303] Resolve auth helper calls case-insensitively Normalize resolved function names before identifying the global auth helper in guard user inference and the request-scope rule. Preserve namespace identity while accepting PHP case-insensitive calls such as Auth(). Add focused rule and type regressions that failed before the correction. Keep the accompanying reviewed comment changes in these whole files. Full source, public type and skeleton analysis pass; the analyzer suite passes with 254 tests and 345 assertions. --- src/phpstan/src/ReturnTypes/GuardExtension.php | 5 +++-- .../src/Rules/NoAuthHelperInRequestScopeRule.php | 11 +++++------ .../Rules/NoAuthHelperInRequestScopeRuleTest.php | 1 + .../data/no-auth-helper-in-request-scope-rule.php | 8 ++++++++ tests/PHPStan/Type/data/auth.php | 9 +++------ 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/phpstan/src/ReturnTypes/GuardExtension.php b/src/phpstan/src/ReturnTypes/GuardExtension.php index e549cbd7b..bd9ed2ae0 100644 --- a/src/phpstan/src/ReturnTypes/GuardExtension.php +++ b/src/phpstan/src/ReturnTypes/GuardExtension.php @@ -28,6 +28,7 @@ use function array_map; use function count; +use function strtolower; #[AutowiredService] final class GuardExtension implements DynamicMethodReturnTypeExtension @@ -70,7 +71,7 @@ public function getTypeFromMethodCall( ): ?Type { $declaredType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); - // A guard declaring a narrower user() than the contract keeps its declaration. + // Unlike upstream, a guard declaring a narrower user() than the contract keeps its declaration. if (! $declaredType->isSuperTypeOf(TypeCombinator::addNull(new ObjectType(Authenticatable::class)))->yes()) { return null; } @@ -124,7 +125,7 @@ private function getGuardNamesFromMethodCall(Scope $scope, MethodCall $methodCal if ($call instanceof FuncCall && $call->name instanceof Name - && $this->reflectionProvider->resolveFunctionName($call->name, $scope) === 'auth' + && strtolower((string) $this->reflectionProvider->resolveFunctionName($call->name, $scope)) === 'auth' ) { return $this->authGuardHelper->getGuardNames($this->getArgument($call, 'guard'), $scope, acceptsEnums: false); } diff --git a/src/phpstan/src/Rules/NoAuthHelperInRequestScopeRule.php b/src/phpstan/src/Rules/NoAuthHelperInRequestScopeRule.php index 6c4430357..f04a6eb04 100644 --- a/src/phpstan/src/Rules/NoAuthHelperInRequestScopeRule.php +++ b/src/phpstan/src/Rules/NoAuthHelperInRequestScopeRule.php @@ -21,6 +21,7 @@ use function in_array; use function sprintf; +use function strtolower; /** @implements Rule */ #[Conditional(attribute: new AutowiredService, if: '%checkAuthCallsWhenInRequestScope%')] @@ -67,9 +68,9 @@ public function processNode(Node $node, Scope $scope): array return []; } - // Upstream matches the written name, which also matches a namespaced auth() function. + // Upstream compares the written name, which matches a namespaced auth() function and misses a miscased call. if (! $node->var->name instanceof Name - || $this->reflectionProvider->resolveFunctionName($node->var->name, $scope) !== 'auth' + || strtolower((string) $this->reflectionProvider->resolveFunctionName($node->var->name, $scope)) !== 'auth' ) { return []; } @@ -86,10 +87,8 @@ public function processNode(Node $node, Scope $scope): array 'guest' => 'Do not use auth()->guest() in a class that has access to the request. Use $%s->user() === null instead.', }; - // Upstream offers an automatic fix for both cases below. Hypervel omits it: - // Auth::resolveUsersUsing() or a request's own resolver changes what the - // request returns, so the replacement is not proven equivalent. Upstream - // also suggests $this->user() in static contexts, which have no $this. + // Upstream's automatic fix is omitted: a custom user resolver can make the request return another + // user. Upstream also suggests $this->user() in static contexts, which have no $this. if ( $scope->isInClass() && $scope->getClassReflection()->is(Request::class) diff --git a/tests/PHPStan/Rules/NoAuthHelperInRequestScopeRuleTest.php b/tests/PHPStan/Rules/NoAuthHelperInRequestScopeRuleTest.php index 0e5314f6c..598743d67 100644 --- a/tests/PHPStan/Rules/NoAuthHelperInRequestScopeRuleTest.php +++ b/tests/PHPStan/Rules/NoAuthHelperInRequestScopeRuleTest.php @@ -24,6 +24,7 @@ public function testRule(): void ['Do not use auth()->guest() in a class that has access to the request. Use $request->user() === null instead.', 33], ['Do not use auth()->check() in a class that has access to the request. Use $this->user() !== null instead.', 51], ['Do not use auth()->check() in a class that has access to the request. Use $request->user() !== null instead.', 64], + ['Do not use auth()->check() in a class that has access to the request. Use $request->user() !== null instead.', 72], ]); } diff --git a/tests/PHPStan/Rules/data/no-auth-helper-in-request-scope-rule.php b/tests/PHPStan/Rules/data/no-auth-helper-in-request-scope-rule.php index 3310a101a..217825dc4 100644 --- a/tests/PHPStan/Rules/data/no-auth-helper-in-request-scope-rule.php +++ b/tests/PHPStan/Rules/data/no-auth-helper-in-request-scope-rule.php @@ -65,6 +65,14 @@ public function check(Request $request): bool } } +class MixedCaseFunction +{ + public function check(Request $request): bool + { + return Auth()->check(); + } +} + namespace Hypervel\Tests\PHPStan\Rules\Data\NoAuthHelperInRequestScope\NamespacedHelper; use Hypervel\Auth\AuthManager; diff --git a/tests/PHPStan/Type/data/auth.php b/tests/PHPStan/Type/data/auth.php index ba2772668..61480b37f 100644 --- a/tests/PHPStan/Type/data/auth.php +++ b/tests/PHPStan/Type/data/auth.php @@ -63,9 +63,8 @@ function test( assertType('App\Admin|App\User|null', Auth::user()); assertType('bool', Auth::check()); assertType('int|string|null', Auth::id()); - // Upstream types the default guard from auth.defaults.guard. shouldUse() changes the - // default per coroutine, so Hypervel types it as any configured guard, and this app - // has no passport driver, so the default keeps the declared Guard without logout(). + // Upstream types the default from auth.defaults.guard, but shouldUse() changes it per coroutine, + // and this app's untyped passport guard makes the union of every guard the declared Guard. assertType(Guard::class, Auth::guard()); assertType('null', Auth::guard('web')->logout()); assertType(SessionGuard::class, Auth::guard('web')); @@ -81,7 +80,6 @@ function test( assertType('App\Admin|null', Auth::guard('admin')->user()); assertType('null', Auth::guard('web')->login($user)); - // A guard name may be an enum case, as AuthManager::guard() reads it with enum_value(). assertType(SessionGuard::class, Auth::guard(GuardName::Admin)); assertType('App\Admin|null', Auth::guard(GuardName::Admin)->user()); assertType(SessionGuard::class, Auth::guard(UnitGuardName::admin)); @@ -95,14 +93,13 @@ function test( assertType(Guard::class, auth('passport')); assertType('App\Admin|null', auth('admin')->user()); assertType('App\Admin|null', auth(guard: 'admin')->user()); + assertType('App\Admin|null', Auth('admin')->user()); assertType(TokenGuard::class, auth()->guard('api')); assertType('App\Admin|null', auth()->guard(GuardName::Admin)->user()); - // A known set of names gives each named guard. assertType('Hypervel\Auth\SessionGuard|Hypervel\Auth\TokenGuard', Auth::guard($adminOrApi)); assertType('App\Admin|App\User|null', Auth::guard($adminOrApi)->user()); - // Narrower or explicit declarations keep their types. assertType(SessionGuard::class, $sessionGuardFactory->guard('api')); assertType('App\Admin|null', $adminGuard->user()); assertType('App\User|null', $userAuthManager->user()); From fdd360790651d76c70a4842a091169491516355e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:23:47 +0000 Subject: [PATCH 043/303] Clarify analyzer comments while preserving upstream context Shorten explanations that restate nearby implementation while retaining the reasons for inference boundaries, bootstrap handling and intentional upstream differences. Describe reordered named arguments precisely in the view collectors. Restore original upstream comments required by the porting policy and retain omission notes for future synchronization. These files change comments only. --- src/phpstan/bootstrap.php | 4 ++-- src/phpstan/src/ApplicationResolver.php | 13 ++++--------- .../src/Collectors/UsedEmailViewCollector.php | 2 +- .../src/Collectors/UsedRouteFacadeViewCollector.php | 2 +- .../src/Collectors/UsedTranslationViewCollector.php | 11 ++--------- .../src/Collectors/UsedViewFacadeMakeCollector.php | 2 +- .../src/Collectors/UsedViewFunctionCollector.php | 4 ++-- .../src/Collectors/UsedViewMakeCollector.php | 2 +- .../src/Concerns/CollectsTranslationLookups.php | 6 ++---- src/phpstan/src/Concerns/LoadsAuthModel.php | 12 ++++-------- src/phpstan/src/HypervelStubFilesExtension.php | 6 ++---- .../src/Internal/ConsoleApplicationResolver.php | 10 ++++------ src/phpstan/src/ReturnTypes/AppMakeHelper.php | 6 ++---- src/phpstan/src/ReturnTypes/AuthExtension.php | 2 +- .../src/ReturnTypes/AuthManagerExtension.php | 2 +- .../ArgumentDynamicReturnTypeExtension.php | 4 ++-- .../HasArgumentDynamicReturnTypeExtension.php | 2 +- .../HasOptionDynamicReturnTypeExtension.php | 2 +- .../OptionDynamicReturnTypeExtension.php | 4 ++-- .../ContainerMakeDynamicReturnTypeExtension.php | 11 ++++------- .../GuardDynamicMethodReturnTypeExtension.php | 6 ++---- .../GuardDynamicStaticMethodReturnTypeExtension.php | 5 ++--- .../src/ReturnTypes/Helpers/AuthExtension.php | 6 ++---- .../UndefinedArgumentOrOptionRule.php | 7 +++---- .../src/Rules/NoAuthFacadeInRequestScopeRule.php | 6 ++---- src/phpstan/src/Rules/NoMissingTranslationsRule.php | 7 ++----- src/phpstan/src/Support/BootstrapErrorHandler.php | 6 ++---- src/phpstan/stubs/common/Helpers.stub | 5 ++--- 28 files changed, 57 insertions(+), 98 deletions(-) diff --git a/src/phpstan/bootstrap.php b/src/phpstan/bootstrap.php index ff168cf25..ed81621d8 100644 --- a/src/phpstan/bootstrap.php +++ b/src/phpstan/bootstrap.php @@ -11,14 +11,14 @@ try { $applicationPath = getcwd() . '/bootstrap/app.php'; - if (file_exists($applicationPath)) { + if (file_exists($applicationPath)) { // Applications and Local Dev $app = require $applicationPath; } else { // Composer's root package path also covers custom vendor directories. $applicationPath = InstalledVersions::getRootPackage()['install_path'] . '/bootstrap/app.php'; if (file_exists($applicationPath)) { $app = require $applicationPath; - } elseif (class_exists(Bootstrapper::class)) { + } elseif (class_exists(Bootstrapper::class)) { // Packages $app = ApplicationResolver::resolve(); } else { // Upstream continues without an application, which silently drops every diff --git a/src/phpstan/src/ApplicationResolver.php b/src/phpstan/src/ApplicationResolver.php index 6b451ca55..994d06525 100644 --- a/src/phpstan/src/ApplicationResolver.php +++ b/src/phpstan/src/ApplicationResolver.php @@ -14,11 +14,8 @@ use function getcwd; /** - * Upstream links the package's vendor directory into the Testbench skeleton and - * rebuilds a missing package manifest. Hypervel's Testbench bootstrapper already - * links the package's actual Composer vendor directory into its disposable runtime - * copy, and the package manifest rebuilds itself when missing, so neither step is - * repeated here. + * Upstream's vendor symlink and manifest rebuild are omitted: Hypervel's Testbench + * bootstrapper links the package's vendor directory, and the manifest rebuilds itself. * * @internal */ @@ -41,10 +38,8 @@ public static function resolve(): Application /** @var Config $config bootstrap() always loads the configuration. */ $config = Bootstrapper::getConfiguration(); - // Boot the disposable runtime copy rather than a configured skeleton, so - // analysis never writes caches into committed files. The analyzed package's - // own providers and aliases are discovered like an installing application's; - // the configuration's dont-discover list still decides package discovery. + // Boot the disposable runtime copy, so analysis never writes caches into committed files, and + // discover the package's own providers and aliases as an installing application would. return Testbench::create(options: [ 'extra' => $config->getExtraAttributes(), 'discovers_root_package' => true, diff --git a/src/phpstan/src/Collectors/UsedEmailViewCollector.php b/src/phpstan/src/Collectors/UsedEmailViewCollector.php index 5b0463280..deea4749a 100644 --- a/src/phpstan/src/Collectors/UsedEmailViewCollector.php +++ b/src/phpstan/src/Collectors/UsedEmailViewCollector.php @@ -73,7 +73,7 @@ public function processNode(Node $node, Scope $scope): ?string return null; } - // Named arguments can be reordered, so the view is matched by name or position. The + // Upstream reads the first argument by position, which misses reordered named arguments. The // mailable and mail message text() methods name it textView; the others name it view. $parameter = $name->name === 'text' && ! $isContent ? 'textView' : 'view'; diff --git a/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php b/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php index f8a78593d..f4662672f 100644 --- a/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php +++ b/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php @@ -45,7 +45,7 @@ public function processNode(Node $node, Scope $scope): ?string return null; } - // Named arguments can be reordered, so the view is matched by name or position. + // Upstream reads the second argument by position, which misses reordered named arguments. $template = $node->getArg('view', 1)?->value; if (! $template instanceof String_) { diff --git a/src/phpstan/src/Collectors/UsedTranslationViewCollector.php b/src/phpstan/src/Collectors/UsedTranslationViewCollector.php index 5f4a74690..69e2a445b 100644 --- a/src/phpstan/src/Collectors/UsedTranslationViewCollector.php +++ b/src/phpstan/src/Collectors/UsedTranslationViewCollector.php @@ -60,9 +60,6 @@ final class UsedTranslationViewCollector */ private const string PHP_BLOCK_REGEX = '/(? */ diff --git a/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php b/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php index 020ac5755..9e9894072 100644 --- a/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php +++ b/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php @@ -45,7 +45,7 @@ public function processNode(Node $node, Scope $scope): ?string return null; } - // Named arguments can be reordered, so the view is matched by name or position. + // Upstream reads the first argument by position, which misses reordered named arguments. $template = $node->getArg('view', 0)?->value; if (! $template instanceof String_) { diff --git a/src/phpstan/src/Collectors/UsedViewFunctionCollector.php b/src/phpstan/src/Collectors/UsedViewFunctionCollector.php index d281842d0..56958c71c 100644 --- a/src/phpstan/src/Collectors/UsedViewFunctionCollector.php +++ b/src/phpstan/src/Collectors/UsedViewFunctionCollector.php @@ -50,12 +50,12 @@ public function processNode(Node $node, Scope $scope): ?string return null; } - // Function names are case-insensitive. + // Upstream compares the name as written, but function names are case-insensitive. if (strtolower((string) $this->reflectionProvider->resolveFunctionName($funcName, $scope)) !== 'view') { return null; } - // Named arguments can be reordered, so the view is matched by name or position. + // Upstream reads the first argument by position, which misses reordered named arguments. $template = $node->getArg('view', 0)?->value; if ($template instanceof String_) { diff --git a/src/phpstan/src/Collectors/UsedViewMakeCollector.php b/src/phpstan/src/Collectors/UsedViewMakeCollector.php index 060716ddf..206d83ae7 100644 --- a/src/phpstan/src/Collectors/UsedViewMakeCollector.php +++ b/src/phpstan/src/Collectors/UsedViewMakeCollector.php @@ -44,7 +44,7 @@ public function processNode(Node $node, Scope $scope): ?string return null; } - // Named arguments can be reordered, so the view is matched by name or position. + // Upstream reads the first argument by position, which misses reordered named arguments. $template = $node->getArg('view', 0)?->value; if (! $template instanceof String_) { diff --git a/src/phpstan/src/Concerns/CollectsTranslationLookups.php b/src/phpstan/src/Concerns/CollectsTranslationLookups.php index 339f44f4a..283304415 100644 --- a/src/phpstan/src/Concerns/CollectsTranslationLookups.php +++ b/src/phpstan/src/Concerns/CollectsTranslationLookups.php @@ -15,10 +15,8 @@ use function array_map; /** - * Upstream collects only the translation key and checks it against every locale's - * files. The missing-translation rule resolves each lookup through the booted - * translator instead, so the locale, fallback and operation the call actually uses - * are collected with the key. A lookup whose locale cannot be known is not collected. + * Upstream collects only the key. The missing-translation rule resolves each lookup through the + * booted translator, so the call's locale, fallback and operation are collected with it. * * @internal * diff --git a/src/phpstan/src/Concerns/LoadsAuthModel.php b/src/phpstan/src/Concerns/LoadsAuthModel.php index 543d5630c..f7f65cc61 100644 --- a/src/phpstan/src/Concerns/LoadsAuthModel.php +++ b/src/phpstan/src/Concerns/LoadsAuthModel.php @@ -14,15 +14,11 @@ use function is_string; /** - * Infers user types from the configured guard providers. + * Infers user types from the configured guard providers, assuming a guard returns its + * provider's users. Requires the HasContainer concern. * - * This is provider-based inference: a guard is assumed to return its configured - * provider's users, although a request guard's callback or setUser() may return - * another Authenticatable. Requires the HasContainer concern. - * - * Upstream's AuthsMethodsExtension also uses these models to give the auth user - * contracts their models' methods. It is omitted so contracts stay honest; the - * user() return types carry the model instead. + * Upstream's AuthsMethodsExtension, which gives the auth user contracts their models' + * methods, is omitted so contracts stay honest; user() return types carry the model. */ trait LoadsAuthModel { diff --git a/src/phpstan/src/HypervelStubFilesExtension.php b/src/phpstan/src/HypervelStubFilesExtension.php index b01ea2ec6..530ebca98 100644 --- a/src/phpstan/src/HypervelStubFilesExtension.php +++ b/src/phpstan/src/HypervelStubFilesExtension.php @@ -14,10 +14,8 @@ final class HypervelStubFilesExtension implements StubFilesExtension /** * Get the stub files to load. * - * Upstream selects stub directories by Laravel version and Redis stubs by client, - * configured through its redisDriver parameter. Hypervel has one supported - * framework version and only the phpredis client, so every stub applies and - * that parameter is omitted. + * Upstream selects stubs by Laravel version and by its redisDriver parameter. Hypervel has + * one supported version and only the phpredis client, so every stub applies. */ public function getFiles(): array { diff --git a/src/phpstan/src/Internal/ConsoleApplicationResolver.php b/src/phpstan/src/Internal/ConsoleApplicationResolver.php index f9e9b48cf..a33f1a66b 100644 --- a/src/phpstan/src/Internal/ConsoleApplicationResolver.php +++ b/src/phpstan/src/Internal/ConsoleApplicationResolver.php @@ -64,9 +64,8 @@ public function findCommands(ClassReflection $classReflection): array continue; } - // Upstream merges the application definition only for single-name lookups, so the - // shared command's bulk argument and option shapes depended on analysis order. - // Merge it once here, as run() does before binding the input. + // Upstream merges the application definition only for single-name lookups, so bulk + // shapes depended on analysis order. Merge it once, as run() does before binding input. $command->mergeApplicationDefinition(); $commands[$id] = $command; @@ -120,9 +119,8 @@ private function getCommands(): array try { return $this->commands = $kernel->all(); } catch (Throwable) { - // The kernel cannot be asked for its commands, e.g. because one of them cannot be resolved - // in the analysis environment. Fall back to a standalone Artisan application, which only - // sees the commands registered through Artisan::starting(). + // A command that cannot be resolved during analysis stops the kernel listing any. A + // standalone application still sees the commands registered through Artisan::starting(). } } diff --git a/src/phpstan/src/ReturnTypes/AppMakeHelper.php b/src/phpstan/src/ReturnTypes/AppMakeHelper.php index fa9941a43..ce192ef4b 100644 --- a/src/phpstan/src/ReturnTypes/AppMakeHelper.php +++ b/src/phpstan/src/ReturnTypes/AppMakeHelper.php @@ -68,10 +68,8 @@ public function resolveTypeFromCall(FuncCall|MethodCall|StaticCall $call, Scope // Not every class-string has a reflection; PHPStan reports a missing class itself. $class = $constantString->getClassStringObjectType()->getObjectClassReflections()[0] ?? null; - // A class whose binding needs runtime context keeps its declared type. Upstream - // falls back for any class-string, but booted facade aliases make service keys - // such as 'session' class names, so only a key naming the class itself qualifies. - // Unknown keys, unknown classes and scalar bindings are mixed, as the container declares. + // A class that does not resolve to an object keeps its type. Upstream applies this to any class-string, + // but facade aliases make keys such as 'session' class names, so only the class's own name qualifies. if ($class === null || strcasecmp(ltrim($constantString->getValue(), '\\'), $class->getName()) !== 0) { return new MixedType; } diff --git a/src/phpstan/src/ReturnTypes/AuthExtension.php b/src/phpstan/src/ReturnTypes/AuthExtension.php index f86252c91..3d106d850 100644 --- a/src/phpstan/src/ReturnTypes/AuthExtension.php +++ b/src/phpstan/src/ReturnTypes/AuthExtension.php @@ -39,7 +39,7 @@ public function getClass(): string */ public function isStaticMethodSupported(MethodReflection $methodReflection): bool { - // A facade subclass's own user() method keeps its declaration. + // Unlike upstream, a facade subclass's own user() method keeps its declaration. return $methodReflection->getName() === 'user' && $methodReflection->getDeclaringClass()->getName() === Guard::class; } diff --git a/src/phpstan/src/ReturnTypes/AuthManagerExtension.php b/src/phpstan/src/ReturnTypes/AuthManagerExtension.php index 93aa2032d..1a1467594 100644 --- a/src/phpstan/src/ReturnTypes/AuthManagerExtension.php +++ b/src/phpstan/src/ReturnTypes/AuthManagerExtension.php @@ -38,7 +38,7 @@ public function getClass(): string */ public function isMethodSupported(MethodReflection $methodReflection): bool { - // A subclass's own user() method keeps its declaration. + // Unlike upstream, a subclass's own user() method keeps its declaration. return $methodReflection->getName() === 'user' && $methodReflection->getDeclaringClass()->getName() === Guard::class; } diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/ArgumentDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/ArgumentDynamicReturnTypeExtension.php index 59332d988..1fcd2d94a 100644 --- a/src/phpstan/src/ReturnTypes/ConsoleCommand/ArgumentDynamicReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/ArgumentDynamicReturnTypeExtension.php @@ -43,7 +43,7 @@ public function getClass(): string */ public function isMethodSupported(MethodReflection $methodReflection): bool { - // A command's own override keeps its declaration. + // Unlike upstream, a command's own override keeps its declaration. return in_array($methodReflection->getName(), ['argument', 'arguments'], true) && $methodReflection->getDeclaringClass()->getName() === Command::class; } @@ -93,7 +93,7 @@ public function getTypeFromMethodCall( $argTypes[] = $this->consoleApplicationHelper->getArgumentType($scope, $argument); } catch (InvalidArgumentException) { - // A command without the argument throws rather than returning a value. + // noop } } diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/HasArgumentDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasArgumentDynamicReturnTypeExtension.php index 2b2fa4882..e27b7ea95 100644 --- a/src/phpstan/src/ReturnTypes/ConsoleCommand/HasArgumentDynamicReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasArgumentDynamicReturnTypeExtension.php @@ -40,7 +40,7 @@ public function getClass(): string */ public function isMethodSupported(MethodReflection $methodReflection): bool { - // A command's own override keeps its declaration. + // Unlike upstream, a command's own override keeps its declaration. return $methodReflection->getName() === 'hasArgument' && $methodReflection->getDeclaringClass()->getName() === Command::class; } diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/HasOptionDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasOptionDynamicReturnTypeExtension.php index 16bc27936..c6e3c449e 100644 --- a/src/phpstan/src/ReturnTypes/ConsoleCommand/HasOptionDynamicReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasOptionDynamicReturnTypeExtension.php @@ -40,7 +40,7 @@ public function getClass(): string */ public function isMethodSupported(MethodReflection $methodReflection): bool { - // A command's own override keeps its declaration. + // Unlike upstream, a command's own override keeps its declaration. return $methodReflection->getName() === 'hasOption' && $methodReflection->getDeclaringClass()->getName() === Command::class; } diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/OptionDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/OptionDynamicReturnTypeExtension.php index 3e050e1aa..cb1df8c4b 100644 --- a/src/phpstan/src/ReturnTypes/ConsoleCommand/OptionDynamicReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/OptionDynamicReturnTypeExtension.php @@ -43,7 +43,7 @@ public function getClass(): string */ public function isMethodSupported(MethodReflection $methodReflection): bool { - // A command's own override keeps its declaration. + // Unlike upstream, a command's own override keeps its declaration. return in_array($methodReflection->getName(), ['option', 'options'], true) && $methodReflection->getDeclaringClass()->getName() === Command::class; } @@ -97,7 +97,7 @@ public function getTypeFromMethodCall( $argTypes[] = $this->consoleApplicationHelper->getOptionType($scope, $argument); } catch (InvalidArgumentException) { - // A command without the option throws rather than returning a value. + // noop } } diff --git a/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php index bf29a38cb..bb99ad766 100644 --- a/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php @@ -15,11 +15,9 @@ use function in_array; /** - * PHPStan applies this extension to the Application contract, which extends the - * container contract. Upstream's separate ApplicationMakeDynamicReturnTypeExtension - * is omitted because it would resolve every application call a second time. - * ContainerArrayAccessDynamicMethodReturnTypeExtension is omitted because the - * Hypervel container does not support ArrayAccess. + * This also covers the Application contract, which extends the container contract, so upstream's + * ApplicationMakeDynamicReturnTypeExtension is omitted. Upstream's ContainerArrayAccessDynamicMethodReturnTypeExtension + * is omitted because the container does not support ArrayAccess. */ #[AutowiredService] final class ContainerMakeDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -46,8 +44,7 @@ public function getClass(): string public function isMethodSupported(MethodReflection $methodReflection): bool { // Hypervel's get() and makeTransient() resolve the same bindings as make(), and - // factory() returns a closure that does. Booted facade aliases otherwise give - // their keys the facade's type through the class-string return declarations. + // factory() returns a closure that does. return in_array($methodReflection->getName(), ['make', 'makeWith', 'makeTransient', 'get', 'resolve', 'factory'], true); } diff --git a/src/phpstan/src/ReturnTypes/GuardDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/GuardDynamicMethodReturnTypeExtension.php index 2fb9794e4..d82c39ef3 100644 --- a/src/phpstan/src/ReturnTypes/GuardDynamicMethodReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/GuardDynamicMethodReturnTypeExtension.php @@ -14,10 +14,8 @@ use PHPStan\Type\Type; /** - * Types guard() on the auth factory the way the Auth facade's guard() is typed. - * - * Upstream types only the facade, leaving auth()->guard('web') with the - * declared Guard while Auth::guard('web') resolves the session guard. + * Types guard() on the auth factory like the Auth facade's guard(). Upstream types only + * the facade, so auth()->guard('web') kept the declared Guard. */ #[AutowiredService] class GuardDynamicMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension diff --git a/src/phpstan/src/ReturnTypes/GuardDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/GuardDynamicStaticMethodReturnTypeExtension.php index c4a53bfe4..f47df244f 100644 --- a/src/phpstan/src/ReturnTypes/GuardDynamicStaticMethodReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/GuardDynamicStaticMethodReturnTypeExtension.php @@ -48,9 +48,8 @@ public function getTypeFromStaticMethodCall( StaticCall $methodCall, Scope $scope, ): ?Type { - // Upstream falls back to StatefulGuard and types a missing name as the configured - // default. Only the session guard is stateful, and shouldUse() changes the default - // per coroutine, so unresolved guards keep the declared Guard|StatefulGuard. + // Upstream falls back to StatefulGuard, which only the session guard is, and types a missing + // name as the configured default, which shouldUse() changes per coroutine. return $this->authGuardHelper->getGuardType( $methodCall->getArgs()[0] ?? null, $scope, diff --git a/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php b/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php index 7dec8d209..87716993c 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php @@ -50,16 +50,14 @@ public function getTypeFromFunctionCall( ): ?Type { $guard = $functionCall->getArgs()[0] ?? null; - // Unlike guard(null), which selects the default guard, auth() returns the factory - // for a null guard, whether omitted or passed explicitly. + // Unlike guard(null), auth() and auth(null) return the factory rather than the default guard. if ($guard === null || (! $guard->unpack && $scope->getType($guard->value)->isNull()->yes())) { $factory = $this->resolve(Factory::class); return is_object($factory) ? new ObjectType($factory::class) : null; } - // A guard that may be null, or an unpacked argument that may be empty, can still - // return the factory, so the declared conditional return applies. + // A maybe-null or unpacked guard can still return the factory, so the declared conditional applies. if ($guard->unpack || ! $scope->getType($guard->value)->isNull()->no()) { return null; } diff --git a/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php b/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php index 6902acc82..578772cf7 100644 --- a/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php +++ b/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php @@ -63,7 +63,7 @@ public function processNode(Node $node, Scope $scope): array $methodName = $node->name->toLowerString(); - // A command's own override decides which names it accepts. + // Unlike upstream, a command's own override decides which names it accepts. if ($scope->getMethodReflection($receiverType, $methodName)?->getDeclaringClass()->getName() !== Command::class) { return []; } @@ -76,9 +76,8 @@ public function processNode(Node $node, Scope $scope): array return []; } - // A hasArgument() or hasOption() guard that is definitely true, or unreachable because - // no command defines the name, keeps the call from commands without it. The scope keys - // the guard as written, so check its positional and named forms. + // A definitely true or unreachable hasArgument()/hasOption() guard keeps the call from commands + // without the name. The scope keys the guard as written, so check its positional and named forms. $guardMethod = $methodName === 'argument' ? 'hasArgument' : 'hasOption'; foreach ([new Arg($value), new Arg($value, name: new Identifier('name'))] as $guardArg) { diff --git a/src/phpstan/src/Rules/NoAuthFacadeInRequestScopeRule.php b/src/phpstan/src/Rules/NoAuthFacadeInRequestScopeRule.php index e3cdcfae0..33dab002f 100644 --- a/src/phpstan/src/Rules/NoAuthFacadeInRequestScopeRule.php +++ b/src/phpstan/src/Rules/NoAuthFacadeInRequestScopeRule.php @@ -70,10 +70,8 @@ public function processNode(Node $node, Scope $scope): array 'guest' => 'Do not use Auth::guest() in a class that has access to the request. Use $%s->user() === null instead.', }; - // Upstream offers an automatic fix for both cases below. Hypervel omits it: - // Auth::resolveUsersUsing() or a request's own resolver changes what the - // request returns, so the replacement is not proven equivalent. Upstream - // also suggests $this->user() in static contexts, which have no $this. + // Upstream's automatic fix is omitted: a custom user resolver can make the request return another + // user. Upstream also suggests $this->user() in static contexts, which have no $this. if ( $scope->isInClass() && $scope->getClassReflection()->is(Request::class) diff --git a/src/phpstan/src/Rules/NoMissingTranslationsRule.php b/src/phpstan/src/Rules/NoMissingTranslationsRule.php index 6f5b0f239..3e88f8785 100644 --- a/src/phpstan/src/Rules/NoMissingTranslationsRule.php +++ b/src/phpstan/src/Rules/NoMissingTranslationsRule.php @@ -34,11 +34,8 @@ use function sprintf; /** - * Upstream compares each key with every key it finds in the application's lang - * files. This rule asks the booted translator instead, so fallback locales, JSON - * translations, namespaces with their vendor overrides and lines added during boot - * all count. It only reports structured keys whose group exists for the lookup's - * locales: a key such as "Welcome back." is ordinary text that no group defines. + * Upstream compares each key with the keys in the lang files. This rule asks the booted translator, + * so fallbacks, JSON, namespaces and boot-added lines count, and reports only keys whose group exists. * * @phpstan-import-type TranslationLookup from CollectsTranslationLookups * diff --git a/src/phpstan/src/Support/BootstrapErrorHandler.php b/src/phpstan/src/Support/BootstrapErrorHandler.php index 0fbde6817..2059022f8 100644 --- a/src/phpstan/src/Support/BootstrapErrorHandler.php +++ b/src/phpstan/src/Support/BootstrapErrorHandler.php @@ -151,10 +151,8 @@ private function renderWithSymfonyStyle(Throwable $throwable, OutputInterface $o /** * Format a bootstrap failure. * - * Upstream blames the application or the framework depending on whether the failing - * file is under a /vendor/ path. That misreports custom vendor directories and - * path-repository installs, and a dependency's exception rarely means the - * framework is at fault, so every failure gets the same guidance. + * Upstream blames the framework for failures under a /vendor/ path, which misreports custom + * vendor directories and path repositories, so every failure gets the same guidance. */ private function formatError(Throwable $throwable, bool $includeErrorDetails = true, bool $decorated = true): string { diff --git a/src/phpstan/stubs/common/Helpers.stub b/src/phpstan/stubs/common/Helpers.stub index 8ff5cdb62..9101b50a9 100644 --- a/src/phpstan/stubs/common/Helpers.stub +++ b/src/phpstan/stubs/common/Helpers.stub @@ -13,6 +13,5 @@ use Hypervel\Contracts\View\View as ViewContract; */ function view(?string $view = null, array|Arrayable $data = [], array $mergeData = []): ViewFactory|ViewContract {} -// trans() and __() are omitted: their source signatures already declare the -// conditional return, and upstream's replacement type excludes the objects -// Hypervel's translator accepts. +// Upstream's trans() and __() are omitted: the source already declares their conditional +// return, and upstream's replacement type rejects objects Hypervel's translator accepts. From 6a3494b3e9918a74f70960c2b9a6ba64f7243d5c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:23:47 +0000 Subject: [PATCH 044/303] Remove redundant analyzer fixture commentary Remove test comments that narrate assertions or duplicate explanations already owned by the source. Retain configuration-dependent expectations, fixture isolation requirements and the registration order needed by console tests. Clarify the upstream fixture move and shorten bootstrap notes without changing test behavior or coverage. --- tests/PHPStan/BootstrapTest.php | 1 - tests/PHPStan/Fixtures/Bootstrap/evidence.php | 1 - tests/PHPStan/Fixtures/Console/LookupCommand.php | 2 -- ...directResponseMethodsClassReflectionExtensionTest.php | 1 - tests/PHPStan/Rules/NoMissingTranslationsRuleTest.php | 8 -------- tests/PHPStan/Type/AuthGuardTypeTest.php | 5 ++--- tests/PHPStan/Type/GeneralTypeTest.php | 2 +- tests/PHPStan/Type/data/app-make.php | 1 - tests/PHPStan/Type/data/console-commands.php | 1 - tests/PHPStan/Type/data/container-make.php | 1 - tests/PHPStan/Type/data/translate.php | 1 - tests/PHPStan/Type/data/translator.php | 1 - tests/PHPStan/phpunit-bootstrap.php | 9 +++------ 13 files changed, 6 insertions(+), 28 deletions(-) diff --git a/tests/PHPStan/BootstrapTest.php b/tests/PHPStan/BootstrapTest.php index 96ebdb197..93733fc16 100644 --- a/tests/PHPStan/BootstrapTest.php +++ b/tests/PHPStan/BootstrapTest.php @@ -52,7 +52,6 @@ public function testPackageAnalysisBootsTestbenchInEveryProcess(): void $evidence = $this->evidence(); - // The main process and its workers each boot their own application. $this->assertGreaterThanOrEqual(2, count($evidence)); $this->assertCount(count($evidence), array_unique(array_column($evidence, 'pid'))); diff --git a/tests/PHPStan/Fixtures/Bootstrap/evidence.php b/tests/PHPStan/Fixtures/Bootstrap/evidence.php index 088852dab..82b4fb7e5 100644 --- a/tests/PHPStan/Fixtures/Bootstrap/evidence.php +++ b/tests/PHPStan/Fixtures/Bootstrap/evidence.php @@ -6,7 +6,6 @@ use Hypervel\Contracts\Foundation\Application; use Hypervel\Support\Str; -// Record what the analyzer bootstrap left behind in this PHPStan process. $app = Container::getInstance(); file_put_contents( diff --git a/tests/PHPStan/Fixtures/Console/LookupCommand.php b/tests/PHPStan/Fixtures/Console/LookupCommand.php index 925e7e981..f0fba33ff 100644 --- a/tests/PHPStan/Fixtures/Console/LookupCommand.php +++ b/tests/PHPStan/Fixtures/Console/LookupCommand.php @@ -19,8 +19,6 @@ public function handle(): void // WithArgumentCommand's definition supplies the type. assertType('string', $this->argument('argument')); - // A passed guard keeps each call from the commands without the name, and the - // last one is never reached. if ($this->hasArgument('argument')) { $this->argument('argument'); } diff --git a/tests/PHPStan/Reflection/RedirectResponseMethodsClassReflectionExtensionTest.php b/tests/PHPStan/Reflection/RedirectResponseMethodsClassReflectionExtensionTest.php index cbdb1f490..57853611c 100644 --- a/tests/PHPStan/Reflection/RedirectResponseMethodsClassReflectionExtensionTest.php +++ b/tests/PHPStan/Reflection/RedirectResponseMethodsClassReflectionExtensionTest.php @@ -65,7 +65,6 @@ public function itWillHaveCorrectMethodReflection(string $methodName): void $this->assertFalse($methodReflection->isStatic()); $this->assertFalse($methodReflection->isPrivate()); $this->assertTrue($methodReflection->isPublic()); - // The call stores the value on the response, so an unused result is not a useless statement. $this->assertTrue($methodReflection->hasSideEffects()->yes()); $this->assertCount(1, $parametersAcceptor->getParameters()); $this->assertSame('mixed', $parametersAcceptor->getParameters()[0]->getType()->describe(VerbosityLevel::value())); diff --git a/tests/PHPStan/Rules/NoMissingTranslationsRuleTest.php b/tests/PHPStan/Rules/NoMissingTranslationsRuleTest.php index 89f98844c..d5f6d4b4a 100644 --- a/tests/PHPStan/Rules/NoMissingTranslationsRuleTest.php +++ b/tests/PHPStan/Rules/NoMissingTranslationsRuleTest.php @@ -188,22 +188,15 @@ public function testLookupArguments(): void ['Translation "lookups.missing" has not been found.', 10], ['Translation "lookups.missing" has not been found.', 11], ['Translation "lookups.missing" has not been found.', 12], - // Blade comments, verbatim blocks, escaped echoes and directives, and other objects' - // trans() methods are not translator lookups; directives in HTML comments still run. ['Translation "lookups.missing" has not been found.', 21], - // Literal text, JavaScript, string literals and attributes bound outside components do not run as PHP. ['Translation "lookups.missing" has not been found.', 26], ['Translation "lookups.missing" has not been found.', 27], ['Translation "lookups.missing" has not been found.', 28], ['Translation "lookups.missing" has not been found.', 29], ['Translation "lookups.missing" has not been found.', 30], ['Translation "lookups.missing" has not been found.', 32], - // Line 34's @lang is text inside another directive's arguments, and Blade leaves line 35's unknown directive as text. ['Translation "lookups.missing" has not been found.', 36], - // Line 37 creates a closure rather than looking a key up. ['Translation "lookups.missing" has not been found.', 38], - // Blade removes line 39's comment, PHP tags included, and line 40's @push is the custom directive. - // A comment removes the blocks inside it on lines 41 and 43; a verbatim block's PHP still runs. ['Translation "lookups.missing" has not been found.', 42], ]; @@ -224,7 +217,6 @@ public function testLookupArguments(): void ['Translation "lookups.missing" has not been found for locale "fr".', 36], ['Translation "lookups.missing" has not been found for locale "fr".', 37], ['Translation "lookups.missing" has not been found for locale "fr".', 38], - // trans_choice() falls back to the en.json line; __() reads JSON only for the requested locale. ['Translation "lookups.apples" has not been found for locale "fr".', 42], ]); } finally { diff --git a/tests/PHPStan/Type/AuthGuardTypeTest.php b/tests/PHPStan/Type/AuthGuardTypeTest.php index 8caac012d..efd427795 100644 --- a/tests/PHPStan/Type/AuthGuardTypeTest.php +++ b/tests/PHPStan/Type/AuthGuardTypeTest.php @@ -63,9 +63,8 @@ public static function dataFileAsserts(): iterable $manager->provider('remote', static fn () => throw new LogicException('Analysis never creates providers.')); $app->instance('auth', $manager); - // These services read the application's auth setup. This test's own config file gives - // it its own service instances, so their swapped container and cached guard types - // never reach other tests. + // This test's own config file gives it its own instances of these services, so the + // swapped container and their cached guard types never reach other tests. foreach ([AuthGuardHelper::class, GuardExtension::class, AuthExtension::class] as $service) { self::getContainer()->getByType($service)->setContainer($app); } diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php index b3a47f54d..2bb62d7cc 100644 --- a/tests/PHPStan/Type/GeneralTypeTest.php +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -45,7 +45,7 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/BazCommand.php'); yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/FooCommand.php'); yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/RegisteredInKernelCommand.php'); - // Hypervel's kernel loads commands from the discovered app/Console/Commands directory. + // Moved from upstream's LoadedCommands directory: Hypervel's kernel discovers app/Console/Commands. yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/LoadedByKernelCommand.php'); yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Console/LookupCommand.php'); yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Console/WithArgumentCommand.php'); diff --git a/tests/PHPStan/Type/data/app-make.php b/tests/PHPStan/Type/data/app-make.php index 1f0fe4c09..b804370fd 100644 --- a/tests/PHPStan/Type/data/app-make.php +++ b/tests/PHPStan/Type/data/app-make.php @@ -12,7 +12,6 @@ function test(): void assertType(ApplicationResolver::class, App::make(ApplicationResolver::class)); assertType(ApplicationResolver::class, App::makeWith(ApplicationResolver::class)); - // Hypervel's other resolution methods. assertType(ApplicationResolver::class, App::makeTransient(ApplicationResolver::class)); assertType(ApplicationResolver::class, App::get(ApplicationResolver::class)); assertType('Closure(): Hypervel\PHPStan\ApplicationResolver', App::factory(ApplicationResolver::class)); diff --git a/tests/PHPStan/Type/data/console-commands.php b/tests/PHPStan/Type/data/console-commands.php index 857b98dae..5b0b69d57 100644 --- a/tests/PHPStan/Type/data/console-commands.php +++ b/tests/PHPStan/Type/data/console-commands.php @@ -16,7 +16,6 @@ function test(FooCommand $foo, Command $command, FooCommand|ClosureCommand $fooO assertType('string', $foo->argument('argument')); assertType('true', $foo->hasOption('option')); - // The base command stands for every registered command, so it keeps the declaration. assertType('mixed', $command->argument('argument')); // The closure alternative could define any name, so the union keeps the declaration. diff --git a/tests/PHPStan/Type/data/container-make.php b/tests/PHPStan/Type/data/container-make.php index 10fd18fcc..93a38df27 100644 --- a/tests/PHPStan/Type/data/container-make.php +++ b/tests/PHPStan/Type/data/container-make.php @@ -28,7 +28,6 @@ function test( assertType('mixed', $container->makeWith($foo)); assertType('mixed', $container->resolve($foo)); - // Hypervel's other resolution methods. assertType('Hypervel\Config\Repository', $container2->get(Repository::class)); assertType('Hypervel\Config\Repository', $container2->makeTransient(Repository::class)); assertType('Closure(): Hypervel\Config\Repository', $container2->factory(Repository::class)); diff --git a/tests/PHPStan/Type/data/translate.php b/tests/PHPStan/Type/data/translate.php index 43e092da9..ab0d1e12f 100644 --- a/tests/PHPStan/Type/data/translate.php +++ b/tests/PHPStan/Type/data/translate.php @@ -17,7 +17,6 @@ function test(?string $key): void assertType('(array|string)', __('foo')); assertType('(array|string)', __('Hi :name', ['name' => 'Niek'])); - // An explicit or possibly null key keeps the declared conditional return. assertType('Hypervel\Translation\Translator', trans(null)); assertType('array|Hypervel\Translation\Translator|string', trans($key)); assertType('null', __(null)); diff --git a/tests/PHPStan/Type/data/translator.php b/tests/PHPStan/Type/data/translator.php index d0bfa793e..f48e2eb8c 100644 --- a/tests/PHPStan/Type/data/translator.php +++ b/tests/PHPStan/Type/data/translator.php @@ -15,7 +15,6 @@ function test(Translator $trans, TranslatorContract $transContract, StringTransl assertType('(array|string)', $trans->get('language.string')); assertType('(array|string)', $transContract->get('language.string')); assertType('(array|string)', Lang::get('language.string')); - // A narrower declared return is kept. assertType('string', $stringTranslator->get('language.string')); assertType('string', StringLang::get('language.string')); } diff --git a/tests/PHPStan/phpunit-bootstrap.php b/tests/PHPStan/phpunit-bootstrap.php index 50e15788e..68f949978 100644 --- a/tests/PHPStan/phpunit-bootstrap.php +++ b/tests/PHPStan/phpunit-bootstrap.php @@ -25,7 +25,6 @@ $filesystem = new Filesystem; -// The console kernel discovers app/Console/Commands, as it does for an application. foreach (['app/Console/Commands', 'lang', 'resources/views'] as $directory) { $filesystem->deleteDirectory(BASE_PATH . '/' . $directory); $filesystem->copyDirectory(__DIR__ . '/application/' . $directory, BASE_PATH . '/' . $directory); @@ -34,8 +33,7 @@ // Fixture config files replace the skeleton's files of the same name; the rest stay. $filesystem->copyDirectory(__DIR__ . '/application/config', BASE_PATH . '/config'); -// Providers register package commands through Application::starting(). These fixtures are -// registered only that way, in this order, which the command lookup tests rely on. +// Registered only as a provider would, in this order, which the command lookup tests rely on. Application::starting(static function (Application $artisan): void { $artisan->resolveCommands([WithoutArgumentCommand::class, WithArgumentCommand::class]); $artisan->resolve(new ClosureCommand('lookup:closure', static function (): void {})); @@ -45,7 +43,6 @@ // AUTH_MODEL names one, which would override the fixture auth configuration. $_SERVER['AUTH_MODEL'] = $_ENV['AUTH_MODEL'] = 'App\User'; -// PHPStan boots the analyzed application once per process when a test first loads -// the extension's bootstrap file. Booting it before any test runs keeps its error -// and exception handlers out of PHPUnit's per-test handler checks. +// Boot the analyzed application before any test, so its error and exception handlers +// stay out of PHPUnit's per-test handler checks. require_once __DIR__ . '/../../src/phpstan/bootstrap.php'; From 39c8a756f39105f9274fa3c7ae85169b246752a8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:23:48 +0000 Subject: [PATCH 045/303] Align the PHPStan port plan with upstream comment preservation State that upstream comments are preserved unless incorrect, matching the repository porting rules. Keep the separate requirement for useful explanations in newly written code and retain the remaining implementation plan. --- docs/plans/2026-11-04-hypervel-phpstan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 8475442a1..c523d3c56 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -20,7 +20,7 @@ Remaining work: ## Design decisions -- Port Larastan's architecture, file organization, naming, algorithms and applicable tests; track its upstream. Use `cp`, read the complete copy, then edit, one file at a time in alphabetical order. Use `mv` for existing Hypervel extensions. Preserve upstream member order and useful comments. Fix inherited defects at their owning boundary. +- Port Larastan's architecture, file organization, naming, algorithms and applicable tests; track its upstream. Use `cp`, read the complete copy, then edit, one file at a time in alphabetical order. Use `mv` for existing Hypervel extensions. Preserve upstream member order and comments (unless incorrect). Fix inherited defects at their owning boundary. - Boot the actual application; use Hypervel Testbench for packages. Retain runtime discovery of container bindings, macros, facade roots, authentication configuration and console definitions. Do not build a second static service-container simulator or require an alternative no-boot mode. - Retain the upstream `extension.neon`, parameter schemas, PHPStan DI attributes and applicable rule switches. Select stricter Hypervel defaults. Keep settings for retained features, including paths and scanning/cache options; omit settings only when their feature is omitted. Do not implement anti-override mechanisms, non-ignorable policy errors, multiple strictness profiles, or a custom configuration language. - Register `extension.neon` in `extra.phpstan.includes`. Document explicit inclusion and optional discovery through `phpstan/extension-installer`; installing the analyzer alone does not install or authorize that Composer plugin. From dc5d435dc5d07a9e66a99f0e6495f9a32f1e9f8f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:19:37 +0000 Subject: [PATCH 046/303] docs: plan nullable typed configuration getters Describe explicit nullable getter methods alongside the existing strict and untyped configuration APIs. Preserve null without coercing empty values and retain type-specific failures for invalid configuration. Include repository and contract APIs, facade metadata, PHPStan support, focused behavior coverage and usage guidance without introducing a fluent nullable proxy. --- docs/todo.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/todo.md b/docs/todo.md index d4ce09969..a046cfb65 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -29,6 +29,7 @@ - Find a clean, simple framework-wide solution for configuration-dependent services resolved before worker configuration reload. `server:reload` refreshes the existing configuration repository, but objects that have already copied configuration into their own state remain stale. For example, `SentryServiceProvider` eagerly resolves a worker-lifetime Hub and client during boot, so DSN, environment, and sampling changes are not applied until a full restart; resolving `Cache::store('some-store')` from a service provider populates `CacheManager`'s store cache before reload, so changes to that store's driver, connection, prefix, or other captured configuration are likewise not applied. Define the reload contract, audit framework-owned eager resolutions and manager caches, and solve the lifecycle at their shared owning boundary instead of adding package-specific refresh hooks or application workarounds. - Investigate where requiring and directly using a PHP extension would make framework code significantly faster than its current pure-PHP implementation. The framework already declares bundled extensions it depends on, so the question is which hot paths are doing in PHP what a C extension does natively. The worked example is `ext-gmp` for identifier encoding: UUID and ULID string conversion and any base32/base58/base62 short-id work exceed 64 bits, so `ramsey/uuid` and `symfony/uid` convert them digit by digit in PHP, while `gmp_init()`/`gmp_strval()` do arbitrary-base conversion natively — a hand-rolled base-36 UUID conversion measured 14.6 µs against 0.5 µs for the GMP equivalent with byte-identical output. Anything that fits in a 64-bit int (snowflakes, timestamps, counters) needs no extension, and hashing, encryption, and signatures are already C. Measure `Str::uuid()`/`Str::ulid()` and the other candidates before adding a requirement, and weigh each new extension against installation cost. - Convert untyped `$config->get()` calls across `src/` to the typed getters (`string()`, `integer()`, `float()`, `boolean()`, `array()`) without call-site defaults, for every key that isn't genuinely nullable. Defaults live in the merged config files — declare any key currently defaulted only at a call site in its package's config file as part of the conversion. Typed getters throw `InvalidArgumentException` naming the key on misconfiguration instead of letting a wrong type propagate silently, and give phpstan real return types. Bootstrap code that runs before config merging keeps its call-site defaults. Approved modernization per the Porting Packages policy in `AGENTS.md`; new code already follows the rule. +- Add nullable typed configuration getters, such as `nullableString()`, `nullableInteger()`, `nullableFloat()`, `nullableBoolean()` and `nullableArray()`, on the config repository and its contract, with matching Config facade metadata and PHPStan support. Accept the corresponding type or null, return null for missing keys without a default, and reject other types with the same key-specific exceptions as the existing strict getters. Preserve empty strings, empty arrays, false and zero rather than converting them to null. Keep `get()`/`config()` for mixed or other union values; prefer explicit nullable getters over a fluent `nullable()` proxy. Cover missing keys, explicit null, valid values, defaults and invalid types, and document when to choose strict, nullable or untyped access. - Audit unmatched PHPStan inline ignores and global patterns with `reportUnmatchedIgnoredErrors` enabled — currently 196 unmatched inline ignores across 99 files plus 5 unmatched global patterns. Remove only suppressions that no longer match after tracing the underlying code; do not replace correct source with runtime branches or wider types merely to keep static analysis green. Decide as part of the work whether `phpstan.neon.dist` should then set `reportUnmatchedIgnoredErrors: true` permanently, since leaving it `false` lets the suppressions rot again. - Create a `hypervel/phpstan` package of PHPStan rules for Hypervel's coroutine and worker-lifetime patterns, so mistakes no longer depend on agent steering and review alone. The rules check how code is written rather than one package's API, so the framework, applications, private packages and third-party packages must all be able to use them. Make it a development-only split package modeled on Larastan, with its own split repository and Packagist entry: require the supported `phpstan/phpstan` range and register the rules in an `extension.neon` listed under `extra.phpstan.includes`, so `phpstan/extension-installer` loads them after `composer require --dev hypervel/phpstan`. Include it from Components' own `phpstan.neon.dist`. Keep package-owned type extensions, such as those in Database, Foundation, Head and Scout, with their packages: they describe those packages' APIs and must load whenever the package is installed, while these rules report errors in the consumer's own code and must be opted into. Test each rule with PHPStan's `RuleTestCase` and fixtures covering both defects and valid code. - Start with cancellation checks that miss cleanup exception chains around callbacks, listeners, framework I/O and `releaseIdleConnections()`, where `Coroutine::causedByCancellation()` is needed to prevent retries or continued work after cancellation. Detect a `catch (CanceledException)` that rethrows followed by a broader catch that reports, falls back, records an outcome or retries; `instanceof CanceledException` on a throwable other than one just caught as that type; `getPrevious() instanceof CanceledException`; and failure collectors such as `$failure ??= $exception` that should use `Coroutine::preferCancellation()`. Exempt Swoole and Hypervel native waits (channels, `WaitGroup`, `Coroutine::join()` and `Waiter`) only when they are the only work in the `try` block; other valid exact checks use `@phpstan-ignore` with a reason. Use the cancellation regression cases to assess detection and false positives, and preserve valid native-only checks, cleanup-error reporting and foreign-cancellation boundaries. Typed domain catches that can receive a wrapped cancellation, failure observers dispatched before a rethrow and ownership decisions remain review responsibilities rather than being inferred automatically. From 66633afcc28dd6982e85fc859111748e69d31648 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:59:39 +0000 Subject: [PATCH 047/303] fix: preserve nested configuration beneath framework defaults Avoid reapplying unchanged framework-only entries after application files load, which discarded nested configuration files. Preserve residual values added or changed by protected loader overrides in both Foundation and Testbench. Centralize the publishable config-stub location and retain the existing absent-stub fallback in config:publish. Validation: Foundation and Testbench loader regressions and config publication tests passed as part of the reviewed checkpoint. --- .../src/Bootstrap/LoadConfiguration.php | 20 ++++++- .../src/Console/ConfigPublishCommand.php | 5 +- .../src/Bootstrap/LoadConfiguration.php | 11 +++- .../Bootstrap/LoadConfigurationTest.php | 40 ++++++++++++++ .../nested-config/services/stripe.php | 8 +++ .../Console/ConfigPublishCommandTest.php | 6 +-- .../Bootstrap/LoadConfigurationTest.php | 53 +++++++++++++++++++ .../nested-config/services/stripe.php | 8 +++ 8 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 tests/Foundation/Fixtures/nested-config/services/stripe.php create mode 100644 tests/Testbench/Bootstrap/LoadConfigurationTest.php create mode 100644 tests/Testbench/Fixtures/nested-config/services/stripe.php diff --git a/src/foundation/src/Bootstrap/LoadConfiguration.php b/src/foundation/src/Bootstrap/LoadConfiguration.php index e52e35cd7..f48e815d1 100644 --- a/src/foundation/src/Bootstrap/LoadConfiguration.php +++ b/src/foundation/src/Bootstrap/LoadConfiguration.php @@ -111,7 +111,9 @@ protected function loadConfigurationFiles(Application $app, RepositoryContract $ ? $this->getBaseConfiguration() : []; - foreach ((new Collection($base))->diffKeys($files) as $name => $config) { + $baseOnly = (new Collection($base))->diffKeys($files)->all(); + + foreach ($baseOnly as $name => $config) { $repository->set($name, $config); } @@ -119,8 +121,13 @@ protected function loadConfigurationFiles(Application $app, RepositoryContract $ $base = $this->loadConfigurationFile($repository, $name, $path, $base); } + // Unlike Laravel, an unchanged base-only entry is not set again: it is already set, + // and setting it again discards the nested files loaded below it. Entries an + // overridden loadConfigurationFile() adds or changes still apply. foreach ($base as $name => $config) { - $repository->set($name, $config); + if (! array_key_exists($name, $baseOnly) || $baseOnly[$name] !== $config) { + $repository->set($name, $config); + } } } @@ -226,6 +233,15 @@ public static function frameworkConfigPath(): string return $path; } + /** + * Get the directory of the templates config:publish uses in place of framework + * configuration files when the framework configuration is merged. + */ + public static function frameworkConfigStubPath(): string + { + return dirname(__DIR__, 2) . '/config-stubs'; + } + /** * Get the base configuration files. * diff --git a/src/foundation/src/Console/ConfigPublishCommand.php b/src/foundation/src/Console/ConfigPublishCommand.php index 27c01f409..22d1b19ef 100644 --- a/src/foundation/src/Console/ConfigPublishCommand.php +++ b/src/foundation/src/Console/ConfigPublishCommand.php @@ -6,6 +6,7 @@ use Hypervel\Console\Command; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\Bootstrap\LoadConfiguration; use Hypervel\Support\Collection; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; @@ -103,11 +104,11 @@ protected function getBaseConfigurationFiles(): array $shouldMergeConfiguration = $this->hypervel->shouldMergeFrameworkConfiguration(); - foreach (Finder::create()->files()->name('*.php')->in(__DIR__ . '/../../config') as $file) { + foreach (Finder::create()->files()->name('*.php')->in(LoadConfiguration::frameworkConfigPath()) as $file) { $path = $file->getPathname(); $name = basename($path, '.php'); - $config[$name] = ($shouldMergeConfiguration === true && file_exists($stubPath = (__DIR__ . '/../../config-stubs/' . $name . '.php'))) + $config[$name] = ($shouldMergeConfiguration === true && file_exists($stubPath = LoadConfiguration::frameworkConfigStubPath() . '/' . $name . '.php')) ? $stubPath : $path; } diff --git a/src/testbench/src/Bootstrap/LoadConfiguration.php b/src/testbench/src/Bootstrap/LoadConfiguration.php index d464af46a..47c916e51 100644 --- a/src/testbench/src/Bootstrap/LoadConfiguration.php +++ b/src/testbench/src/Bootstrap/LoadConfiguration.php @@ -29,6 +29,7 @@ public function bootstrap(Application $app): void $config->set('database.connections.testing', [ 'driver' => 'sqlite', 'database' => ':memory:', + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (this default connection is configuration, set while configuration loads) 'foreign_key_constraints' => (bool) Env::get('DB_FOREIGN_KEYS', false), ]); } @@ -50,7 +51,9 @@ protected function loadConfigurationFiles(Application $app, RepositoryContract $ ? $this->getBaseConfiguration() : []; - foreach ((new Collection($base))->diffKeys($files) as $name => $config) { + $baseOnly = (new Collection($base))->diffKeys($files)->all(); + + foreach ($baseOnly as $name => $config) { $repository->set($name, $config); } @@ -58,8 +61,12 @@ protected function loadConfigurationFiles(Application $app, RepositoryContract $ $base = $this->loadConfigurationFile($repository, $name, $path, $base); } + // An unchanged base-only entry is already set; setting it again discards the nested + // files loaded below it, as the foundation loader explains. foreach ($base as $name => $config) { - $repository->set($name, $config); + if (! array_key_exists($name, $baseOnly) || $baseOnly[$name] !== $config) { + $repository->set($name, $config); + } } } diff --git a/tests/Foundation/Bootstrap/LoadConfigurationTest.php b/tests/Foundation/Bootstrap/LoadConfigurationTest.php index 689d27d72..26a854595 100644 --- a/tests/Foundation/Bootstrap/LoadConfigurationTest.php +++ b/tests/Foundation/Bootstrap/LoadConfigurationTest.php @@ -191,6 +191,33 @@ public function testAppConfigOverridesBaseConfigValues(): void $this->assertNotNull($config->get('auth')); } + public function testKeepsNestedFilesBelowFrameworkOnlyConfiguration(): void + { + $app = new Application(__DIR__ . '/../Fixtures'); + $app->useConfigPath(__DIR__ . '/../Fixtures/nested-config'); + + (new LoadConfiguration)->bootstrap($app); + + $config = $app->make('config'); + + $this->assertSame('stripe-key', $config->string('services.stripe.key')); + $this->assertIsArray($config->get('services.postmark')); + } + + public function testAppliesBaseEntriesAnOverriddenFileLoaderAddsOrChanges(): void + { + $app = new Application(__DIR__ . '/../Fixtures'); + $app->useConfigPath(__DIR__ . '/../Fixtures/nested-config'); + + (new ResidualBaseLoadConfiguration)->bootstrap($app); + + $config = $app->make('config'); + + $this->assertSame(['enabled' => true], $config->get('additional')); + $this->assertSame('override', $config->string('session.driver')); + $this->assertSame('stripe-key', $config->string('services.stripe.key')); + } + public function testFailedReloadRestoresThePreviousRepositoryAndException(): void { $app = new Application; @@ -213,6 +240,19 @@ public function testFailedReloadRestoresThePreviousRepositoryAndException(): voi } } +class ResidualBaseLoadConfiguration extends LoadConfiguration +{ + protected function loadConfigurationFile(RepositoryContract $repository, string $name, string $path, array $base): array + { + $base = parent::loadConfigurationFile($repository, $name, $path, $base); + + $base['additional'] = ['enabled' => true]; + $base['session']['driver'] = 'override'; + + return $base; + } +} + class FailingLoadConfiguration extends LoadConfiguration { public function __construct(protected RuntimeException $exception) diff --git a/tests/Foundation/Fixtures/nested-config/services/stripe.php b/tests/Foundation/Fixtures/nested-config/services/stripe.php new file mode 100644 index 000000000..1f6b36bab --- /dev/null +++ b/tests/Foundation/Fixtures/nested-config/services/stripe.php @@ -0,0 +1,8 @@ + 'stripe-key', +]; diff --git a/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php b/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php index 3abe75fd2..d47e3eb09 100644 --- a/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php +++ b/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php @@ -6,10 +6,10 @@ use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\Bootstrap\LoadConfiguration; use Hypervel\Foundation\Console\ConfigPublishCommand; use Hypervel\Testbench\Concerns\InteractsWithPublishedFiles; use Mockery as m; -use ReflectionClass; use RuntimeException; use Symfony\Component\Console\Application as ConsoleApplication; use Symfony\Component\Console\Tester\CommandTester; @@ -37,7 +37,7 @@ protected function setUp(): void { parent::setUp(); - $this->baseConfigPath = dirname((new ReflectionClass(ConfigPublishCommand::class))->getFileName(), 3) . '/config'; + $this->baseConfigPath = LoadConfiguration::frameworkConfigPath(); } protected function tearDown(): void @@ -171,7 +171,7 @@ public function testForcedPublicationPreservesExistingPermissions(): void public function testSourceReadFailurePreservesExistingDestinationAndReportsNoSuccess(): void { $name = 'hashing'; - $source = dirname((new ReflectionClass(ConfigPublishCommand::class))->getFileName()) . "/../../config/{$name}.php"; + $source = "{$this->baseConfigPath}/{$name}.php"; $sourceContents = file_get_contents($source); $destination = $this->app->configPath("{$name}.php"); $this->preserveConfigFile($name); diff --git a/tests/Testbench/Bootstrap/LoadConfigurationTest.php b/tests/Testbench/Bootstrap/LoadConfigurationTest.php new file mode 100644 index 000000000..f5b4f1f7d --- /dev/null +++ b/tests/Testbench/Bootstrap/LoadConfigurationTest.php @@ -0,0 +1,53 @@ +useConfigPath(__DIR__ . '/../Fixtures/nested-config'); + + (new LoadConfiguration)->bootstrap($app); + + $config = $app->make('config'); + + $this->assertSame('stripe-key', $config->string('services.stripe.key')); + $this->assertIsArray($config->get('services.postmark')); + } + + public function testAppliesBaseEntriesAnOverriddenFileLoaderAddsOrChanges(): void + { + $app = new Application(__DIR__ . '/../Fixtures'); + $app->useConfigPath(__DIR__ . '/../Fixtures/nested-config'); + + (new ResidualBaseLoadConfiguration)->bootstrap($app); + + $config = $app->make('config'); + + $this->assertSame(['enabled' => true], $config->get('additional')); + $this->assertSame('override', $config->string('session.driver')); + $this->assertSame('stripe-key', $config->string('services.stripe.key')); + } +} + +class ResidualBaseLoadConfiguration extends LoadConfiguration +{ + protected function loadConfigurationFile(RepositoryContract $repository, string $name, string $path, array $base): array + { + $base = parent::loadConfigurationFile($repository, $name, $path, $base); + + $base['additional'] = ['enabled' => true]; + $base['session']['driver'] = 'override'; + + return $base; + } +} diff --git a/tests/Testbench/Fixtures/nested-config/services/stripe.php b/tests/Testbench/Fixtures/nested-config/services/stripe.php new file mode 100644 index 000000000..1f6b36bab --- /dev/null +++ b/tests/Testbench/Fixtures/nested-config/services/stripe.php @@ -0,0 +1,8 @@ + 'stripe-key', +]; From 65f541e92c35804a5b207777ec7ff0decfc327f6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:59:39 +0000 Subject: [PATCH 048/303] fix: describe environment reader values independently of defaults Declare the environment readers' boolean, string, null and generic default return types in source. Closure defaults contribute their evaluated return type without changing runtime signatures. Keep helper delegations and app-id defaults explicit about their intentional environment reads. Add source type fixtures covering ordinary and closure defaults. Validation: main and dedicated type-fixture PHPStan analyses passed for the combined checkpoint. --- src/foundation/src/helpers.php | 7 +++++-- src/support/src/Env.php | 6 ++++++ src/support/src/helpers.php | 10 +++++++++- types/Support/helpers.php | 7 +++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index 0c4ee0310..9d5ba4ee7 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -43,6 +43,7 @@ use Hypervel\Session\SessionManager; use Hypervel\Support\Defer\DeferredCallback; use Hypervel\Support\Defer\DeferredCallbackCollection; +use Hypervel\Support\Env; use Hypervel\Support\Facades\Date; use Hypervel\Support\Facades\Route; use Hypervel\Support\HtmlString; @@ -161,7 +162,8 @@ function app(?string $abstract = null, array $parameters = []) */ function app_id(): string { - $id = env('APP_ID'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (app_id() supplies configuration defaults while configuration loads) + $id = Env::get('APP_ID'); if ($id !== null && $id !== '') { $id = (string) $id; @@ -175,7 +177,8 @@ function app_id(): string return $id; } - return Str::slug(Str::snake((string) env('APP_NAME', 'hypervel')), '_') ?: 'hypervel'; + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (app_id() supplies configuration defaults while configuration loads) + return Str::slug(Str::snake((string) Env::get('APP_NAME', 'hypervel')), '_') ?: 'hypervel'; } } diff --git a/src/support/src/Env.php b/src/support/src/Env.php index caa00063d..a64d9c9b2 100644 --- a/src/support/src/Env.php +++ b/src/support/src/Env.php @@ -188,6 +188,11 @@ protected static function buildAdapters(): array /** * Get the value of an environment variable. + * + * @template TDefault + * + * @param (Closure(): TDefault)|TDefault $default + * @return null|bool|string|TDefault */ public static function get(string $key, mixed $default = null): mixed { @@ -204,6 +209,7 @@ public static function get(string $key, mixed $default = null): mixed */ public static function getArray(string $key, array $default = []): array { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (getArray() is an Env reader built on get()) $value = static::get($key); if (! filled($value)) { diff --git a/src/support/src/helpers.php b/src/support/src/helpers.php index ea54cf4a4..b515e8f69 100644 --- a/src/support/src/helpers.php +++ b/src/support/src/helpers.php @@ -140,10 +140,16 @@ function e(\BackedEnum|DeferringDisplayableValue|\Stringable|float|Htmlable|int| if (! function_exists('env')) { /** - * Gets the value of an environment variable. + * Get the value of an environment variable. + * + * @template TDefault + * + * @param (Closure(): TDefault)|TDefault $default + * @return null|bool|string|TDefault */ function env(string $key, mixed $default = null): mixed { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (env() is the helper for Env::get()) return Env::get($key, $default); } } @@ -157,6 +163,7 @@ function env(string $key, mixed $default = null): mixed */ function env_array(string $key, array $default = []): array { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (env_array() is the helper for Env::getArray()) return Env::getArray($key, $default); } } @@ -169,6 +176,7 @@ function env_array(string $key, array $default = []): array */ function env_or_fail(string $key): mixed { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (env_or_fail() is the helper for Env::getOrFail()) return Env::getOrFail($key); } } diff --git a/types/Support/helpers.php b/types/Support/helpers.php index 2fd25cc6a..efcbebedd 100644 --- a/types/Support/helpers.php +++ b/types/Support/helpers.php @@ -4,6 +4,7 @@ use DateTimeImmutable; use Exception; +use Hypervel\Support\Env; use Throwable; use function PHPStan\Testing\assertType; @@ -21,6 +22,12 @@ assertType('bool|float|int|non-empty-string', $value); } +// The default does not decide the type of a value that is set. +assertType('bool|string|null', env('FOO')); +assertType('1|bool|string|null', env('FOO', 1)); +assertType('1|bool|string|null', env('FOO', fn () => 1)); +assertType('1|bool|string|null', Env::get('FOO', fn () => 1)); + assertType('User', object_get(new User, null)); assertType('User', object_get(new User, '')); assertType('mixed', object_get(new User, 'name')); From b5d1d2deb3a1ff135a2a420f0ce94f0c5082732c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:59:39 +0000 Subject: [PATCH 049/303] chore: document intentional bootstrap environment reads Explain narrowly scoped environment-policy exceptions for application paths, cache locations, process context, environment-file selection, decryption and Composer discovery. Retain the existing bootstrap and process transport behavior while allowing the environment rule to remain enabled across framework source. Validation: PHPStan and the affected application, environment, Composer and console tests passed for the combined checkpoint. --- src/console/src/ConsoleServiceProvider.php | 1 + src/foundation/src/Application.php | 3 +++ src/foundation/src/Bootstrap/HandleExceptions.php | 1 + src/foundation/src/Bootstrap/LoadEnvironmentVariables.php | 1 + src/foundation/src/Console/EnvironmentDecryptCommand.php | 1 + src/support/src/Composer.php | 1 + 6 files changed, 8 insertions(+) diff --git a/src/console/src/ConsoleServiceProvider.php b/src/console/src/ConsoleServiceProvider.php index 75c0c357d..6b782dc70 100644 --- a/src/console/src/ConsoleServiceProvider.php +++ b/src/console/src/ConsoleServiceProvider.php @@ -42,6 +42,7 @@ public function register(): void public function boot(Dispatcher $events): void { if (! $this->app->runningInConsole() + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parent process passes its context to child commands through the environment) || ($encoded = Env::get('__HYPERVEL_CONTEXT')) === null) { return; } diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index a7e317258..cfce74217 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -250,6 +250,7 @@ public static function inferBasePath(): string return match (true) { isset($_ENV['APP_BASE_PATH']) => $_ENV['APP_BASE_PATH'], isset($_SERVER['APP_BASE_PATH']) => $_SERVER['APP_BASE_PATH'], + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the base path is needed before the environment file and configuration load) is_string($basePath = Env::get('APP_BASE_PATH')) => $basePath, default => dirname(array_values(array_filter( array_keys(ClassLoader::getRegisteredLoaders()), @@ -753,6 +754,7 @@ public function getCachedEventsPath(): string */ protected function normalizeCachePath(string $key, string $default): string { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (cache paths are read before configuration loads, including the configuration cache's own) if (is_null($env = Env::get($key))) { return $this->bootstrapPath($default); } @@ -839,6 +841,7 @@ public function runningInConsole(): bool return $this->isRunningInConsole; } + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the server command sets this in the process environment its workers inherit) return $this->isRunningInConsole = (bool) (Env::get('APP_RUNNING_IN_CONSOLE') ?? true); } diff --git a/src/foundation/src/Bootstrap/HandleExceptions.php b/src/foundation/src/Bootstrap/HandleExceptions.php index 45563a0fe..111fab5bd 100644 --- a/src/foundation/src/Bootstrap/HandleExceptions.php +++ b/src/foundation/src/Bootstrap/HandleExceptions.php @@ -191,6 +191,7 @@ protected function shouldIgnoreDeprecationErrors(): bool return ! class_exists(LogManager::class) || static::$app === null || ! static::$app->hasBeenBootstrapped() + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the test runner sets this for the test process) || (static::$app->runningUnitTests() && ! Env::get('LOG_DEPRECATIONS_WHILE_TESTING')); } diff --git a/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php b/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php index f7a4dd741..9536a0a35 100644 --- a/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php +++ b/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php @@ -45,6 +45,7 @@ protected function checkForSpecificEnvironmentFile(Application $app): void return; } + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (selects the environment file before it loads) $environment = Env::get('APP_ENV'); if (! $environment) { diff --git a/src/foundation/src/Console/EnvironmentDecryptCommand.php b/src/foundation/src/Console/EnvironmentDecryptCommand.php index d7e7192bf..a400dfefe 100644 --- a/src/foundation/src/Console/EnvironmentDecryptCommand.php +++ b/src/foundation/src/Console/EnvironmentDecryptCommand.php @@ -44,6 +44,7 @@ public function __construct( */ public function handle(): void { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the key decrypts the environment file, so configuration cannot hold it) $key = $this->option('key') ?: Env::get('HYPERVEL_ENV_ENCRYPTION_KEY'); if (! $key && $this->input->isInteractive()) { diff --git a/src/support/src/Composer.php b/src/support/src/Composer.php index 2cfcfeee5..b19255535 100644 --- a/src/support/src/Composer.php +++ b/src/support/src/Composer.php @@ -306,6 +306,7 @@ protected static function findLoader(): ClassLoader */ public static function vendorPath(string $rootPath): string { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Composer sets this in the process environment) $vendorPath = Env::get('COMPOSER_VENDOR_DIR'); if (is_string($vendorPath) && $vendorPath !== '') { From 2cc871bd2c99806a267be3c01bff7d0576453d8d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:59:39 +0000 Subject: [PATCH 050/303] chore: make test infrastructure environment reads explicit Use Env::get consistently for ordinary test infrastructure reads and explain deliberate process-environment exceptions in reusable support code. Preserve helper-specific tests and test-runner token semantics. Cover Testbench boot and subprocess settings, service integration traits, Redis configuration and local fixture servers without changing their runtime inputs. Validation: the Testbench package suite, affected support and database tests, and gRPC/Reverb fixture-server integration tests passed for the combined checkpoint. --- .../Testing/Concerns/InteractsWithAlgolia.php | 10 +++++++--- .../Concerns/InteractsWithMeilisearch.php | 16 +++++++++++----- .../Testing/Concerns/InteractsWithMercure.php | 4 +++- .../InteractsWithParallelDatabase.php | 7 +++++-- .../Testing/Concerns/InteractsWithServer.php | 7 +++++-- .../Concerns/InteractsWithTypesense.php | 19 +++++++++++++------ .../src/Testing/RedisTestConfiguration.php | 11 +++++++---- .../src/Testing/RedisTestDatabases.php | 7 +++++-- .../src/Bootstrap/HandleExceptions.php | 2 ++ src/testbench/src/Bootstrapper.php | 3 +++ .../InteractsWithSqliteDatabaseFile.php | 7 +++++-- src/testbench/src/Concerns/HandlesRoutes.php | 1 + src/testbench/src/Factories/UserFactory.php | 4 +++- src/testbench/src/Features/ParallelRunner.php | 1 + .../Bootstrap/EnsuresDefaultConfiguration.php | 1 + .../Bootstrap/LoadMigrationsFromArray.php | 1 + .../Concerns/HandlesDatabaseConnections.php | 2 ++ .../Console/Concerns/CopyTestbenchFiles.php | 1 + .../Console/PurgeSkeletonCommand.php | 1 + .../src/Foundation/Console/ServeCommand.php | 1 + src/testbench/src/Foundation/Env.php | 2 ++ .../src/Foundation/PackageManifest.php | 1 + src/testbench/src/Workbench/Workbench.php | 1 + src/testbench/src/functions.php | 1 + tests/Ai/Fixtures/helpers.php | 3 ++- .../Database/Sqlite/SqliteTestCase.php | 9 +++++---- tests/Integration/Grpc/Fixtures/server.php | 9 +++++---- tests/Integration/Reverb/Fixtures/server.php | 11 ++++++----- .../ParallelDatabaseRequirementsTest.php | 3 ++- tests/Testbench/Factories/UserFactoryTest.php | 2 +- 30 files changed, 104 insertions(+), 44 deletions(-) diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php b/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php index ea5e13e5b..7f1d83b7c 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php @@ -10,6 +10,7 @@ use Algolia\AlgoliaSearch\Http\HttpClientInterface; use Algolia\AlgoliaSearch\Model\Search\GetTaskResponse; use GuzzleHttp\Client as GuzzleClient; +use Hypervel\Support\Env; use RuntimeException; use Throwable; @@ -45,8 +46,10 @@ trait InteractsWithAlgolia */ protected function setUpInteractsWithAlgolia(): void { - $appId = env('ALGOLIA_APP_ID'); - $secret = env('ALGOLIA_SECRET'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + $appId = Env::get('ALGOLIA_APP_ID'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + $secret = Env::get('ALGOLIA_SECRET'); if ($appId === null || $secret === null || $appId === '' || $secret === '') { $this->markTestSkipped( @@ -109,7 +112,8 @@ protected function createAlgoliaHttpClient(): HttpClientInterface protected function computeAlgoliaTestPrefix(): void { $base = 'test_'; - $token = env('TEST_TOKEN', ''); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN', ''); $this->algoliaTestPrefix = $token !== '' ? "{$base}{$token}_" : $base; } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php b/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php index b8be65e12..327eecd90 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Support\Env; use Meilisearch\Client as MeilisearchClient; use RuntimeException; @@ -34,7 +35,8 @@ trait InteractsWithMeilisearch */ protected function setUpInteractsWithMeilisearch(): void { - if (env('MEILISEARCH_HOST') === null) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + if (Env::get('MEILISEARCH_HOST') === null) { $this->markTestSkipped( 'Set MEILISEARCH_HOST to run Meilisearch integration tests for ' . static::class ); @@ -74,7 +76,8 @@ protected function initializeMeilisearchClient(): void { $this->meilisearch = new MeilisearchClient( $this->getMeilisearchHost(), - env('MEILISEARCH_KEY', '') + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + Env::get('MEILISEARCH_KEY', '') ); } @@ -84,7 +87,8 @@ protected function initializeMeilisearchClient(): void protected function computeMeilisearchTestPrefix(): void { $base = 'test_'; - $token = env('TEST_TOKEN', ''); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN', ''); $this->meilisearchTestPrefix = $token !== '' ? "{$base}{$token}_" : $base; } @@ -96,8 +100,10 @@ protected function computeMeilisearchTestPrefix(): void */ protected function getMeilisearchHost(): string { - $host = env('MEILISEARCH_HOST', '127.0.0.1'); - $port = env('MEILISEARCH_PORT', '7700'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + $host = Env::get('MEILISEARCH_HOST', '127.0.0.1'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + $port = Env::get('MEILISEARCH_PORT', '7700'); return "http://{$host}:{$port}"; } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithMercure.php b/src/foundation/src/Testing/Concerns/InteractsWithMercure.php index 139cd03ad..7b806d209 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithMercure.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithMercure.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Support\Env; use Hypervel\Testing\ParallelTesting; /** @@ -18,7 +19,8 @@ trait InteractsWithMercure */ protected function setUpInteractsWithMercure(): void { - if (env('MERCURE_URL') === null) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + if (Env::get('MERCURE_URL') === null) { $this->markTestSkipped('Set MERCURE_URL to run Mercure integration tests for ' . static::class); } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php index ed714032b..e907c2e00 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php @@ -8,6 +8,7 @@ use Hypervel\Database\ConfigurationUrlParser; use Hypervel\Database\QueryException; use Hypervel\Database\SQLiteDatabase; +use Hypervel\Support\Env; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; use InvalidArgumentException; @@ -45,7 +46,8 @@ protected function configureParallelDatabaseName($app): void return; } - $token = env('TEST_TOKEN'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN'); if ($token === null) { return; @@ -98,7 +100,8 @@ protected function ensureParallelDatabaseExists(): void return; } - $token = env('TEST_TOKEN'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN'); if ($token === null) { return; diff --git a/src/foundation/src/Testing/Concerns/InteractsWithServer.php b/src/foundation/src/Testing/Concerns/InteractsWithServer.php index 005fc6b81..efb1320ab 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithServer.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithServer.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Support\Env; use RuntimeException; use Throwable; @@ -28,7 +29,8 @@ trait InteractsWithServer */ protected function setUpInteractsWithServer(): void { - if (env('TEST_SERVER_HOST') === null) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + if (Env::get('TEST_SERVER_HOST') === null) { $this->markTestSkipped( 'Set TEST_SERVER_HOST to run server integration tests for ' . static::class ); @@ -65,7 +67,8 @@ protected function canConnectToServer(): bool */ protected function getServerHost(): string { - return (string) env('TEST_SERVER_HOST'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + return (string) Env::get('TEST_SERVER_HOST'); } /** diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTypesense.php b/src/foundation/src/Testing/Concerns/InteractsWithTypesense.php index b3a77cc5a..d1187a6f5 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithTypesense.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithTypesense.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Support\Env; use Typesense\Client as TypesenseClient; /** @@ -33,7 +34,8 @@ trait InteractsWithTypesense */ protected function setUpInteractsWithTypesense(): void { - if (env('TYPESENSE_HOST') === null) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + if (Env::get('TYPESENSE_HOST') === null) { $this->markTestSkipped( 'Set TYPESENSE_HOST to run Typesense integration tests for ' . static::class ); @@ -80,12 +82,16 @@ protected function initializeTypesenseClient(): void protected function getTypesenseClientSettings(): array { return [ - 'api_key' => env('TYPESENSE_API_KEY', ''), + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + 'api_key' => Env::get('TYPESENSE_API_KEY', ''), 'nodes' => [ [ - 'host' => env('TYPESENSE_HOST', '127.0.0.1'), - 'port' => (string) env('TYPESENSE_PORT', '8108'), - 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + 'host' => Env::get('TYPESENSE_HOST', '127.0.0.1'), + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + 'port' => (string) Env::get('TYPESENSE_PORT', '8108'), + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + 'protocol' => Env::get('TYPESENSE_PROTOCOL', 'http'), ], ], 'connection_timeout_seconds' => 2, @@ -98,7 +104,8 @@ protected function getTypesenseClientSettings(): array protected function computeTypesenseTestPrefix(): void { $base = 'test_'; - $token = env('TEST_TOKEN', ''); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN', ''); $this->typesenseTestPrefix = $token !== '' ? "{$base}{$token}_" : $base; } diff --git a/src/foundation/src/Testing/RedisTestConfiguration.php b/src/foundation/src/Testing/RedisTestConfiguration.php index e25ed4bad..0cf1295d3 100644 --- a/src/foundation/src/Testing/RedisTestConfiguration.php +++ b/src/foundation/src/Testing/RedisTestConfiguration.php @@ -5,6 +5,7 @@ namespace Hypervel\Foundation\Testing; use Hypervel\Contracts\Config\Repository; +use Hypervel\Support\Env; use RuntimeException; class RedisTestConfiguration @@ -14,8 +15,8 @@ class RedisTestConfiguration */ public static function isConfigured(): bool { - return env('REDIS_CLUSTER_HOSTS_AND_PORTS') !== null - || env('REDIS_HOST') !== null; + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Redis integration tests are configured through the test process environment) + return static::usesCluster() || Env::get('REDIS_HOST') !== null; } /** @@ -23,7 +24,8 @@ public static function isConfigured(): bool */ public static function usesCluster(): bool { - return env('REDIS_CLUSTER_HOSTS_AND_PORTS') !== null; + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Redis integration tests are configured through the test process environment) + return Env::get('REDIS_CLUSTER_HOSTS_AND_PORTS') !== null; } /** @@ -33,7 +35,8 @@ public static function usesCluster(): bool */ public static function clusterSeeds(): array { - $value = env('REDIS_CLUSTER_HOSTS_AND_PORTS'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Redis integration tests are configured through the test process environment) + $value = Env::get('REDIS_CLUSTER_HOSTS_AND_PORTS'); if (! is_string($value) || trim($value) === '') { throw new RuntimeException('REDIS_CLUSTER_HOSTS_AND_PORTS must be a comma-separated list of non-empty Redis Cluster seeds.'); diff --git a/src/foundation/src/Testing/RedisTestDatabases.php b/src/foundation/src/Testing/RedisTestDatabases.php index f696db112..86a9501b5 100644 --- a/src/foundation/src/Testing/RedisTestDatabases.php +++ b/src/foundation/src/Testing/RedisTestDatabases.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing; +use Hypervel\Support\Env; use RuntimeException; class RedisTestDatabases @@ -37,7 +38,8 @@ public static function maximumDatabase(): int */ public static function configuredSecondaryDatabase(): ?int { - $value = env('REDIS_TEST_SECONDARY_DB'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Redis integration tests are configured through the test process environment) + $value = Env::get('REDIS_TEST_SECONDARY_DB'); return $value === null ? null @@ -137,7 +139,8 @@ public static function workerIndex(string $token): int */ public static function integerEnvironment(string $key, int $default): int { - $value = env($key); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Redis integration tests are configured through the test process environment) + $value = Env::get($key); if ($value === null) { return $default; diff --git a/src/testbench/src/Bootstrap/HandleExceptions.php b/src/testbench/src/Bootstrap/HandleExceptions.php index 1d355f83a..1c68e8f48 100644 --- a/src/testbench/src/Bootstrap/HandleExceptions.php +++ b/src/testbench/src/Bootstrap/HandleExceptions.php @@ -29,6 +29,7 @@ public function handleDeprecationError(string $message, string $file, int $line, parent::handleDeprecationError($message, $file, $line, $level); }, report: false); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the test runner sets this for the test process) $testbenchConvertDeprecationsToExceptions = (bool) Env::get( 'TESTBENCH_CONVERT_DEPRECATIONS_TO_EXCEPTIONS', false @@ -78,6 +79,7 @@ protected function shouldIgnoreDeprecationErrors(): bool return ! class_exists(LogManager::class) || self::$app === null || ! self::$app->hasBeenBootstrapped() + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the test runner sets this for the test process) || ! (bool) Env::get('LOG_DEPRECATIONS_WHILE_TESTING', true); } } diff --git a/src/testbench/src/Bootstrapper.php b/src/testbench/src/Bootstrapper.php index 485b43e89..22e16a868 100644 --- a/src/testbench/src/Bootstrapper.php +++ b/src/testbench/src/Bootstrapper.php @@ -142,6 +142,7 @@ public static function isRuntimeCopy(string $path): bool */ public static function explicitApplicationBasePath(): ?string { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the base path is needed before the application exists) $basePath = $_ENV['APP_BASE_PATH'] ?? $_SERVER['APP_BASE_PATH'] ?? Env::get('APP_BASE_PATH'); return is_string($basePath) && $basePath !== '' ? $basePath : null; @@ -218,6 +219,7 @@ protected static function hasConfigurationFile(string $workingPath, string $file */ protected static function exportAbsoluteComposerVendorDirectory(string $workingPath): void { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Composer sets this in the process environment) $vendorPath = Env::get('COMPOSER_VENDOR_DIR'); if (! is_string($vendorPath) || $vendorPath === '' || str_starts_with($vendorPath, '/')) { @@ -437,6 +439,7 @@ protected static function copyPackageEnvironmentFile(Filesystem $filesystem, str */ protected static function testbenchEnvironmentFile(): string { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (selects the environment file before it loads) $environmentFile = Env::get('TESTBENCH_ENVIRONMENT_FILENAME', '.env'); return is_string($environmentFile) && $environmentFile !== '' diff --git a/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php b/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php index 00fe9c37f..727c850b9 100644 --- a/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php +++ b/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php @@ -7,6 +7,7 @@ use Hypervel\Database\SQLiteDatabase; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Collection; +use Hypervel\Support\Env; use Hypervel\Support\Facades\DB; use Hypervel\Testbench\Concerns\InteractsWithPublishedFiles; use InvalidArgumentException; @@ -44,7 +45,8 @@ protected function purgeSqliteConnection(): void protected function baseSqliteDatabasePath(): string { $database = $this->app->make('config')->string('database.connections.sqlite.database'); - $token = env('TEST_TOKEN'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN'); if ($token === null) { return $database; @@ -68,7 +70,8 @@ protected function baseSqliteDatabasePath(): string protected function activeSqliteDatabasePath(): string { $database = $this->baseSqliteDatabasePath(); - $token = env('TEST_TOKEN'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN'); if ($token === null || $database === '' || SQLiteDatabase::isInMemory($database)) { return $database; diff --git a/src/testbench/src/Concerns/HandlesRoutes.php b/src/testbench/src/Concerns/HandlesRoutes.php index 08a0141e2..84de500b9 100644 --- a/src/testbench/src/Concerns/HandlesRoutes.php +++ b/src/testbench/src/Concerns/HandlesRoutes.php @@ -134,6 +134,7 @@ protected function defineCacheRoutes(Closure|string $route, bool $cached = true) if ($this->app instanceof HypervelApplication) { $cachedRoutesPath = $this->app->getCachedRoutesPath(); } else { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (cache paths are read before configuration loads, as Application::normalizeCachePath() does) $configuredCachedRoutesPath = Env::get('APP_ROUTES_CACHE'); $cachedRoutesPath = $configuredCachedRoutesPath === null diff --git a/src/testbench/src/Factories/UserFactory.php b/src/testbench/src/Factories/UserFactory.php index fc722e5b9..c647bc159 100644 --- a/src/testbench/src/Factories/UserFactory.php +++ b/src/testbench/src/Factories/UserFactory.php @@ -7,6 +7,7 @@ use Hypervel\Database\Eloquent\Factories\Factory; use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Auth\User; +use Hypervel\Support\Env; use Hypervel\Support\Facades\Hash; use Hypervel\Support\Str; @@ -57,7 +58,8 @@ public function unverified(): static */ public function modelName(): string { - return $this->model ?? config('auth.providers.users.model') ?? env('AUTH_MODEL', User::class); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (falls back to the variable the auth configuration reads when that key is missing) + return $this->model ?? config('auth.providers.users.model') ?? Env::get('AUTH_MODEL', User::class); } /** diff --git a/src/testbench/src/Features/ParallelRunner.php b/src/testbench/src/Features/ParallelRunner.php index ed78bc67b..5474fc4db 100644 --- a/src/testbench/src/Features/ParallelRunner.php +++ b/src/testbench/src/Features/ParallelRunner.php @@ -23,6 +23,7 @@ class ParallelRunner extends BaseParallelRunner protected function createApplication(): ApplicationContract { if (! defined('TESTBENCH_WORKING_PATH')) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parent test process passes its working path through the environment) $workingPath = Env::get('TESTBENCH_WORKING_PATH'); if (is_string($workingPath) && $workingPath !== '') { diff --git a/src/testbench/src/Foundation/Bootstrap/EnsuresDefaultConfiguration.php b/src/testbench/src/Foundation/Bootstrap/EnsuresDefaultConfiguration.php index ebdc6fcc6..4285f5c36 100644 --- a/src/testbench/src/Foundation/Bootstrap/EnsuresDefaultConfiguration.php +++ b/src/testbench/src/Foundation/Bootstrap/EnsuresDefaultConfiguration.php @@ -41,6 +41,7 @@ public function bootstrap(Application $app): void */ private function includesDefaultConfigurations(Application $app): bool { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (decides which configuration loads, so configuration cannot hold it) return Env::get('TESTBENCH_WITHOUT_DEFAULT_VARIABLES') !== true; } } diff --git a/src/testbench/src/Foundation/Bootstrap/LoadMigrationsFromArray.php b/src/testbench/src/Foundation/Bootstrap/LoadMigrationsFromArray.php index dd47275c8..782ca0315 100644 --- a/src/testbench/src/Foundation/Bootstrap/LoadMigrationsFromArray.php +++ b/src/testbench/src/Foundation/Bootstrap/LoadMigrationsFromArray.php @@ -110,6 +110,7 @@ private function bootstrapMigrations(Application $app): void private function includesDefaultMigrations(Application $app): bool { return workbench()['install'] === true + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (a Testbench switch set in the test process environment) && Env::get('TESTBENCH_WITHOUT_DEFAULT_MIGRATIONS') !== true && rescue(static fn (): bool => is_dir(default_migration_path()), false, false); } diff --git a/src/testbench/src/Foundation/Concerns/HandlesDatabaseConnections.php b/src/testbench/src/Foundation/Concerns/HandlesDatabaseConnections.php index 593715690..8f5a26ce1 100644 --- a/src/testbench/src/Foundation/Concerns/HandlesDatabaseConnections.php +++ b/src/testbench/src/Foundation/Concerns/HandlesDatabaseConnections.php @@ -44,6 +44,7 @@ final protected function usesDatabaseConnectionsEnvironmentVariables(Repository /** @var string $environmentSuffix */ $environmentSuffix = $options['env']; $environmentVariable = "{$keyword}_{$environmentSuffix}"; + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (builds database configuration from the environment, as a config file does) $port = Env::get($environmentVariable); if ($port === null || $port === '') { @@ -69,6 +70,7 @@ final protected function usesDatabaseConnectionsEnvironmentVariables(Repository /** @var mixed $configuration */ $configuration = (new Collection(Arr::wrap($options['env']))) + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (builds database configuration from the environment, as a config file does) ->transform(static fn (string $value): mixed => Env::get("{$keyword}_{$value}")) ->first( $options['rules'] ?? static fn ($value): bool => ! empty($value) && \is_string($value) diff --git a/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php b/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php index 724ee074b..e347ef51c 100644 --- a/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php +++ b/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php @@ -123,6 +123,7 @@ protected function testbenchEnvironmentFile(): string { return match (true) { property_exists($this, 'environmentFile') => $this->environmentFile, /* @phpstan-ignore function.alreadyNarrowedType */ + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (selects the environment file to copy) Env::has('TESTBENCH_ENVIRONMENT_FILENAME') => Env::get('TESTBENCH_ENVIRONMENT_FILENAME'), default => '.env', }; diff --git a/src/testbench/src/Foundation/Console/PurgeSkeletonCommand.php b/src/testbench/src/Foundation/Console/PurgeSkeletonCommand.php index 3d70aab5e..5c5cd934c 100644 --- a/src/testbench/src/Foundation/Console/PurgeSkeletonCommand.php +++ b/src/testbench/src/Foundation/Console/PurgeSkeletonCommand.php @@ -62,6 +62,7 @@ public function handle(Filesystem $filesystem, ConfigContract $config): int ['files' => $files, 'directories' => $directories] = $config->getPurgeAttributes(); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (selects the environment file to delete) $environmentFile = Env::get('TESTBENCH_ENVIRONMENT_FILENAME', '.env'); $run(fn () => (new Actions\DeleteFiles( diff --git a/src/testbench/src/Foundation/Console/ServeCommand.php b/src/testbench/src/Foundation/Console/ServeCommand.php index 5bc4f5036..c70485c8a 100644 --- a/src/testbench/src/Foundation/Console/ServeCommand.php +++ b/src/testbench/src/Foundation/Console/ServeCommand.php @@ -38,6 +38,7 @@ protected function configure(): void // Like Laravel's development server, listen on the loopback address unless // SERVER_HOST or --host says otherwise. The framework default listens on // every interface, which would expose the Workbench login helpers. + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the development server reads its host from the process environment, as Laravel's does) $this->getDefinition()->getOption('host')->setDefault(Env::get('SERVER_HOST', '127.0.0.1')); } diff --git a/src/testbench/src/Foundation/Env.php b/src/testbench/src/Foundation/Env.php index 55c743de1..a0b87fd8c 100644 --- a/src/testbench/src/Foundation/Env.php +++ b/src/testbench/src/Foundation/Env.php @@ -14,6 +14,7 @@ class Env extends \Hypervel\Support\Env */ public static function has(string $key): bool { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (has() is an Env reader built on get()) return static::get($key, new UndefinedValue) instanceof UndefinedValue === false; } @@ -38,6 +39,7 @@ public static function forget(string $key): bool */ public static function forward(string $key, mixed $default = new UndefinedValue): mixed { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (forward() is an Env reader built on get()) $value = static::get($key, $default); if ($value instanceof UndefinedValue) { diff --git a/src/testbench/src/Foundation/PackageManifest.php b/src/testbench/src/Foundation/PackageManifest.php index 255a4c793..693c0e7c0 100644 --- a/src/testbench/src/Foundation/PackageManifest.php +++ b/src/testbench/src/Foundation/PackageManifest.php @@ -132,6 +132,7 @@ public static function discoversRootPackageAt(string $basePath, bool $optedIn = // A child sharing its parent's runtime follows the parent's decision, so its own // CLI identity cannot change the manifest the parent and its subprocesses read. if (Bootstrapper::isRuntimeCopy($basePath) && ! Bootstrapper::ownsRuntimePath($basePath)) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parent process passes its decision to child processes through the environment) return Env::get('TESTBENCH_DISCOVERS_ROOT_PACKAGE') === true; } diff --git a/src/testbench/src/Workbench/Workbench.php b/src/testbench/src/Workbench/Workbench.php index 878737fff..79add907e 100644 --- a/src/testbench/src/Workbench/Workbench.php +++ b/src/testbench/src/Workbench/Workbench.php @@ -311,6 +311,7 @@ public static function applicationExceptionHandler(): ?string public static function applicationUserModel(): ?string { if (\is_null(static::$cachedUserModel)) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Workbench detects the user model while configuration loads) $authModel = Env::get('AUTH_MODEL'); /** @var class-string|false $userModel */ diff --git a/src/testbench/src/functions.php b/src/testbench/src/functions.php index 977b86625..ab484e096 100644 --- a/src/testbench/src/functions.php +++ b/src/testbench/src/functions.php @@ -301,6 +301,7 @@ function package_path(array|string $path = ''): string $argumentCount = func_num_args(); $workingPath = once(static function (): string { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the test process sets the package working path in its environment) $configuredWorkingPath = Env::get('TESTBENCH_WORKING_PATH'); $resolvedPath = realpath(match (true) { diff --git a/tests/Ai/Fixtures/helpers.php b/tests/Ai/Fixtures/helpers.php index 014c5c223..eaa74814d 100644 --- a/tests/Ai/Fixtures/helpers.php +++ b/tests/Ai/Fixtures/helpers.php @@ -10,13 +10,14 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application; use Hypervel\Http\Client\Request; +use Hypervel\Support\Env; use Hypervel\Support\Facades\Http; use PHPUnit\Framework\Assert; function requiresApiKey(string ...$keys): void { foreach ($keys as $key) { - if (empty(env($key))) { + if (empty(Env::get($key))) { Assert::markTestSkipped("Missing {$key} — skipping external test."); } } diff --git a/tests/Integration/Database/Sqlite/SqliteTestCase.php b/tests/Integration/Database/Sqlite/SqliteTestCase.php index d06d8d3fb..46093b65c 100644 --- a/tests/Integration/Database/Sqlite/SqliteTestCase.php +++ b/tests/Integration/Database/Sqlite/SqliteTestCase.php @@ -6,6 +6,7 @@ use Hypervel\Database\SQLiteDatabase; use Hypervel\Filesystem\Filesystem; +use Hypervel\Support\Env; use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Tests\Integration\Database\DatabaseTestCase; use Override; @@ -24,7 +25,7 @@ protected function setUp(): void /** * Check if configured for in-memory SQLite database. * - * Uses env() so it can be called before the app boots. + * Reads the environment so it can be called before the app boots. */ protected function isConfiguredForInMemoryDatabase(): bool { @@ -34,11 +35,11 @@ protected function isConfiguredForInMemoryDatabase(): bool /** * Get the configured SQLite database path from env. * - * Uses env() so it can be called before the app boots. + * Reads the environment so it can be called before the app boots. */ protected function getConfiguredDatabasePath(): string { - return env('DB_DATABASE', ':memory:'); + return Env::get('DB_DATABASE', ':memory:'); } /** @@ -51,7 +52,7 @@ protected function ensureSqliteDatabaseFileExists(): void { // This runs before the class-level RequiresDatabase skip, so another driver's // database name must not be created as a file in the working directory. - if (env('DB_CONNECTION', 'sqlite') !== 'sqlite') { + if (Env::get('DB_CONNECTION', 'sqlite') !== 'sqlite') { return; } diff --git a/tests/Integration/Grpc/Fixtures/server.php b/tests/Integration/Grpc/Fixtures/server.php index 28df3830d..a127e6989 100644 --- a/tests/Integration/Grpc/Fixtures/server.php +++ b/tests/Integration/Grpc/Fixtures/server.php @@ -6,6 +6,7 @@ use Hypervel\Grpc\GrpcServiceProvider; use Hypervel\Grpc\Health\HealthStatusProvider; use Hypervel\Server\ServerFactory; +use Hypervel\Support\Env; use Hypervel\Testbench\Bootstrapper; use Hypervel\Testbench\Foundation\Application as TestbenchApplication; use Hypervel\Tests\Integration\Grpc\Fixtures\TestHealthStatusProvider; @@ -24,10 +25,10 @@ $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; -$port = (int) (env('GRPC_TEST_SERVER_PORT') ?: 19520); -$certificate = env('GRPC_TEST_SERVER_CERT'); -$privateKey = env('GRPC_TEST_SERVER_KEY'); -$compression = env('GRPC_TEST_SERVER_COMPRESSION'); +$port = (int) (Env::get('GRPC_TEST_SERVER_PORT') ?: 19520); +$certificate = Env::get('GRPC_TEST_SERVER_CERT'); +$privateKey = Env::get('GRPC_TEST_SERVER_KEY'); +$compression = Env::get('GRPC_TEST_SERVER_COMPRESSION'); $app = TestbenchApplication::create(); diff --git a/tests/Integration/Reverb/Fixtures/server.php b/tests/Integration/Reverb/Fixtures/server.php index 9edc7d4f5..f56df8fe2 100644 --- a/tests/Integration/Reverb/Fixtures/server.php +++ b/tests/Integration/Reverb/Fixtures/server.php @@ -27,6 +27,7 @@ use Hypervel\Reverb\Servers\Hypervel\ReverbRouter; use Hypervel\Reverb\Webhooks\Jobs\WebhookDeliveryJob; use Hypervel\Server\ServerFactory; +use Hypervel\Support\Env; use Hypervel\Support\Facades\Queue; use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\Bootstrapper; @@ -53,9 +54,9 @@ $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; // Read port, scaling flag, and worker count from environment. -$port = (string) (env('REVERB_SERVER_PORT') ?: '19510'); -$scaling = env('REVERB_SCALING_ENABLED') === true; -$workerNum = (int) (env('REVERB_TEST_WORKER_NUM') ?: 1); +$port = (string) (Env::get('REVERB_SERVER_PORT') ?: '19510'); +$scaling = Env::get('REVERB_SCALING_ENABLED') === true; +$workerNum = (int) (Env::get('REVERB_TEST_WORKER_NUM') ?: 1); $label = $scaling ? 'Reverb Redis test server' : ($workerNum > 1 ? "Reverb multi-worker test server ({$workerNum} workers)" : 'Reverb test server'); // Set Reverb app env vars BEFORE boot so the config file picks them up. @@ -77,7 +78,7 @@ ]; foreach ($defaults as $key => $value) { - if (env($key) === null) { + if (Env::get($key) === null) { putenv("{$key}={$value}"); $_ENV[$key] = $value; $_SERVER[$key] = $value; @@ -288,7 +289,7 @@ public function onMessage(\Swoole\WebSocket\Server $server, \Swoole\WebSocket\Fr }; }); -$port = env('REVERB_SERVER_PORT', 19510); +$port = Env::get('REVERB_SERVER_PORT', 19510); echo "Starting {$label} on 0.0.0.0:{$port}...\n"; diff --git a/tests/Testbench/Databases/ParallelDatabaseRequirementsTest.php b/tests/Testbench/Databases/ParallelDatabaseRequirementsTest.php index 9283dfe90..2eff74cc9 100644 --- a/tests/Testbench/Databases/ParallelDatabaseRequirementsTest.php +++ b/tests/Testbench/Databases/ParallelDatabaseRequirementsTest.php @@ -6,6 +6,7 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Testing\DatabaseMigrations; +use Hypervel\Support\Env; use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Testbench\TestCase as TestbenchTestCase; use Hypervel\Testing\ParallelTesting; @@ -23,7 +24,7 @@ public function testDatabaseExistsBeforeVersionRequirementsRun(): void touch($database); $environment = [ - 'TEST_TOKEN' => (string) (env('TEST_TOKEN') ?? 'requirements'), + 'TEST_TOKEN' => (string) (Env::get('TEST_TOKEN') ?? 'requirements'), 'DB_CONNECTION' => 'sqlite', 'DB_DATABASE' => $database, ]; diff --git a/tests/Testbench/Factories/UserFactoryTest.php b/tests/Testbench/Factories/UserFactoryTest.php index 26124e81b..c74b5bde5 100644 --- a/tests/Testbench/Factories/UserFactoryTest.php +++ b/tests/Testbench/Factories/UserFactoryTest.php @@ -22,7 +22,7 @@ class UserFactoryTest extends TestCase public function itHasTheDefaultConfiguration(): void { $this->assertSame(User::class, config('auth.providers.users.model')); - $this->assertNull(env('AUTH_MODEL')); + $this->assertNull(Env::get('AUTH_MODEL')); } #[Test] From be21d92704a5e782947854c6a2c735e513d8dbb6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:59:39 +0000 Subject: [PATCH 051/303] fix: preserve configurable package value contracts during analysis Describe consumer-configurable values at their owning read boundaries instead of treating shipped defaults as the only valid values. Preserve watcher forms, configured commands and origins, and validation of signal handlers, environment modes and data settings. Keep Sanctum's nullable expiration behavior with a local explained diagnostic exception until nullable typed getters are available. Validation: main PHPStan and the affected Data, Doctor, Mcp, Sanctum, Signal, Telescope and Tinker tests passed for the combined checkpoint. --- src/data/src/DataServiceProvider.php | 6 +++++- src/doctor/src/EnvironmentMode.php | 6 +++++- src/mcp/src/Server/Middleware/ValidateOrigin.php | 6 +++++- src/sanctum/src/Console/Commands/PruneExpired.php | 1 + src/signal/src/SignalManager.php | 2 ++ src/telescope/src/RegistersWatchers.php | 7 ++++++- src/tinker/src/Console/TinkerCommand.php | 6 +++++- 7 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/data/src/DataServiceProvider.php b/src/data/src/DataServiceProvider.php index 73e8ca3e9..6c7056e5a 100644 --- a/src/data/src/DataServiceProvider.php +++ b/src/data/src/DataServiceProvider.php @@ -47,7 +47,11 @@ public function boot(Repository $config): void }); } - $enableVarDumperCaster = match ($config->string('data.var_dumper_caster_mode')) { + // Published configuration may set any mode, which the match validates. + /** @var string $varDumperCasterMode */ + $varDumperCasterMode = $config->string('data.var_dumper_caster_mode'); + + $enableVarDumperCaster = match ($varDumperCasterMode) { 'enabled' => true, 'development' => $this->app->environment('local', 'testing'), 'disabled' => false, diff --git a/src/doctor/src/EnvironmentMode.php b/src/doctor/src/EnvironmentMode.php index a39936044..7eb4471c0 100644 --- a/src/doctor/src/EnvironmentMode.php +++ b/src/doctor/src/EnvironmentMode.php @@ -36,7 +36,11 @@ private static function configuredEnvironments(): array { $configured = []; - foreach (config()->array('doctor.environments') as $mode => $environments) { + // Published configuration may group environments under any keys, which the loop validates. + /** @var array $modes */ + $modes = config()->array('doctor.environments'); + + foreach ($modes as $mode => $environments) { $resolved = is_string($mode) ? self::tryFrom($mode) : null; if ($resolved === null) { diff --git a/src/mcp/src/Server/Middleware/ValidateOrigin.php b/src/mcp/src/Server/Middleware/ValidateOrigin.php index 045067ddd..2b492ca37 100644 --- a/src/mcp/src/Server/Middleware/ValidateOrigin.php +++ b/src/mcp/src/Server/Middleware/ValidateOrigin.php @@ -40,7 +40,11 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - foreach ($this->config->array('mcp.allowed_origins') as $allowedOrigin) { + // Published configuration may list more origins; the default lists none. + /** @var list $allowedOrigins */ + $allowedOrigins = $this->config->array('mcp.allowed_origins'); + + foreach ($allowedOrigins as $allowedOrigin) { if ($origin === $this->origin($allowedOrigin)) { return $next($request); } diff --git a/src/sanctum/src/Console/Commands/PruneExpired.php b/src/sanctum/src/Console/Commands/PruneExpired.php index 147d6190b..8b5f10189 100644 --- a/src/sanctum/src/Console/Commands/PruneExpired.php +++ b/src/sanctum/src/Console/Commands/PruneExpired.php @@ -45,6 +45,7 @@ public function handle(): int $expiration = config()->get('sanctum.expiration'); if ($expiration !== null) { + // @phpstan-ignore hypervel.configType (published configuration may set minutes; the null default is handled above) $expiration = config()->integer('sanctum.expiration'); } diff --git a/src/signal/src/SignalManager.php b/src/signal/src/SignalManager.php index 4be7d3656..6f2c58836 100644 --- a/src/signal/src/SignalManager.php +++ b/src/signal/src/SignalManager.php @@ -149,6 +149,8 @@ protected function resolveHandlers(string $process): array */ protected function getQueue(): SplPriorityQueue { + // Published configuration may register handlers in any form, which the loop validates. + /** @var array $handlers */ $handlers = $this->config->array('signal.handlers'); $queue = new SplPriorityQueue; diff --git a/src/telescope/src/RegistersWatchers.php b/src/telescope/src/RegistersWatchers.php index 6cb26f226..05000a01c 100644 --- a/src/telescope/src/RegistersWatchers.php +++ b/src/telescope/src/RegistersWatchers.php @@ -5,6 +5,7 @@ namespace Hypervel\Telescope; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Telescope\Watchers\Watcher; trait RegistersWatchers { @@ -38,7 +39,11 @@ public static function flushWatchers(): void */ protected static function registerWatchers(Application $app): void { - foreach (config()->array('telescope.watchers') as $key => $watcher) { + // Published configuration may enable watchers with booleans, omit enabled, or list classes. + /** @var array|int, array|bool|class-string> $watchers */ + $watchers = config()->array('telescope.watchers'); + + foreach ($watchers as $key => $watcher) { if (is_string($key) && $watcher === false) { continue; } diff --git a/src/tinker/src/Console/TinkerCommand.php b/src/tinker/src/Console/TinkerCommand.php index c1993a7f7..ab4fea6d5 100644 --- a/src/tinker/src/Console/TinkerCommand.php +++ b/src/tinker/src/Console/TinkerCommand.php @@ -116,7 +116,11 @@ protected function getCommands(): array $config = $this->getHypervel()->make('config'); - foreach ($config->array('tinker.commands', []) as $command) { + // Published configuration may list more commands; the default lists none. + /** @var list> $configuredCommands */ + $configuredCommands = $config->array('tinker.commands', []); + + foreach ($configuredCommands as $command) { if (($command = $this->getApplication()->addCommand( $this->getHypervel()->make($command) )) !== null) { From 969011f130af4a0b01d2f5766d5f7740b7f7c834 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:59:49 +0000 Subject: [PATCH 052/303] feat: compose static configuration sources for PHPStan Discover application files, framework defaults and provider merges from boot metadata and cached source syntax. Compose shallow and recursive merges, retain open-array uncertainty, and widen values affected by recognized boot-time writes. Resolve application config reads against load order, preserve uncertainty for deferred bodies and unresolved package loading, and track file dependencies through lazy expression typing and recursion protection. Bind provider arguments by name and distinguish read callables from possible mutations. Validation: reviewed config source, lookup, merge, recursion and dependency fixtures pass within the analyzer suite; main PHPStan passes. --- src/phpstan/src/Internal/RecursionGuard.php | 43 + src/phpstan/src/Support/ConfigArray.php | 32 + src/phpstan/src/Support/ConfigParser.php | 1310 +++++++++++++++++ .../src/Support/ConfigSourceResolver.php | 1141 ++++++++++++++ 4 files changed, 2526 insertions(+) create mode 100644 src/phpstan/src/Internal/RecursionGuard.php create mode 100644 src/phpstan/src/Support/ConfigArray.php create mode 100644 src/phpstan/src/Support/ConfigParser.php create mode 100644 src/phpstan/src/Support/ConfigSourceResolver.php diff --git a/src/phpstan/src/Internal/RecursionGuard.php b/src/phpstan/src/Internal/RecursionGuard.php new file mode 100644 index 000000000..1e4d26bca --- /dev/null +++ b/src/phpstan/src/Internal/RecursionGuard.php @@ -0,0 +1,43 @@ + */ + private static array $context = []; + + /** + * Run the callback, or return null when the key is already running. + * + * @param callable(): T $callback + * + * @return null|T + * + * @template T + */ + public static function run(string $key, callable $callback): mixed + { + if (isset(self::$context[$key])) { + return null; + } + + try { + self::$context[$key] = true; + + return $callback(); + } finally { + unset(self::$context[$key]); + } + } +} diff --git a/src/phpstan/src/Support/ConfigArray.php b/src/phpstan/src/Support/ConfigArray.php new file mode 100644 index 000000000..aeda38a0d --- /dev/null +++ b/src/phpstan/src/Support/ConfigArray.php @@ -0,0 +1,32 @@ + $entries + * @param array $optionalKeys the keys that may be absent + * @param bool $open whether keys besides the entries may be present + */ + public function __construct( + public array $entries = [], + public array $optionalKeys = [], + public bool $open = false, + ) { + } +} diff --git a/src/phpstan/src/Support/ConfigParser.php b/src/phpstan/src/Support/ConfigParser.php new file mode 100644 index 000000000..1354dd18c --- /dev/null +++ b/src/phpstan/src/Support/ConfigParser.php @@ -0,0 +1,1310 @@ + + */ + private ?array $configFiles = null; + + /** + * Key prefixes with config files below them, whose values Hypervel merges + * from several files. + * + * @var array + */ + private array $mergedConfigKeys = []; + + /** + * The framework config files merged beneath the application's, keyed by name. + * + * @var array + */ + private array $frameworkConfigFiles = []; + + /** + * Each config key's presence and value type, or null when its value is unknown. A read + * while an application config file loads is keyed by that file's key, a NUL byte and the key. + * + * @var array + */ + private array $parsedConfigs = []; + + /** + * Each top-level key's composed value, held in an otherwise empty root array, keyed as + * the parsed values are. + * + * @var array + */ + private array $composedConfigs = []; + + /** + * Each config file's return statement, the statements before it and its namespace, + * or null when the file has no single return value. + * + * @var array, null|string}> + */ + private array $parsedConfigFiles = []; + + /** + * The scope at each config file's return statement, or null when the + * statements before it always terminate. + * + * @var array + */ + private array $returnScopes = []; + + /** + * Files the statements before a config file's return depend on, such as + * the config files they read. + * + * @var array> + */ + private array $configFileDependencies = []; + + /** + * Files a config key's value depends on besides the files that can hold + * the key, such as config files its expression reads. + * + * @var array> + */ + private array $keyDependencies = []; + + /** + * Files each top-level key's composed value depends on. + * + * @var array> + */ + private array $compositionDependencies = []; + + /** + * The config file each value expression is in, keyed by the expression's object ID. + * + * @var array + */ + private array $expressionFiles = []; + + /** + * Each value expression's type, or null when unknown, keyed by its object ID. + * + * @var array + */ + private array $expressionTypes = []; + + /** + * Files each value expression's type depends on, keyed by its object ID. + * + * @var array> + */ + private array $expressionDependencies = []; + + /** + * Dependencies collected for the config files and keys being typed, so + * a cached result can declare them again to every file reading it. + * + * @var list> + */ + private array $dependencyCollectors = []; + + /** @var array> */ + private array $configFileCandidates = []; + + /** + * How many lookups found their config file, expression or composed value already + * being typed further up the call stack. Results typed meanwhile are not cached. + */ + private int $inProgressLookups = 0; + + /** + * Create a new parser instance. + */ + public function __construct( + private FileHelper $fileHelper, + #[AutowiredParameter(ref: '@currentPhpVersionRichParser')] + private Parser $parser, + private FileTypeMapper $fileTypeMapper, + private ScopeFactory $scopeFactory, + private NodeScopeResolver $nodeScopeResolver, + private ConfigSourceResolver $configSourceResolver, + #[AutowiredParameter] + private bool $treatPhpDocTypesAsCertain, + ) { + } + + /** + * Get whether a config key is present and its value's type, or null when unknown. + * + * The presence decides whether a lookup's default can apply: a present key returns + * its value even when it is null, and an absent one returns the default. + * + * @param DependencyTracker&Scope $scope + * @return null|array{TrinaryLogic, Type} + */ + public function getValue(string $key, Scope $scope): ?array + { + // Every analysed file reading the key depends on the config files, parsed or served from the cache. + $this->trackFileDependencies($scope, $this->getConfigFileCandidates($key)); + $this->loadConfigFiles(); + + // A config file reads the configuration as it stands while the file loads. Framework + // files load before any value is set, and providers merge package files in an order + // the analysis does not follow, so only an application file's reads are typed. + $file = $this->fileHelper->normalizePath($scope->getFile()); + $loadingKey = array_search($file, $this->configFiles ?? [], true); + + if ($loadingKey === false && (in_array($file, $this->frameworkConfigFiles, true) || $this->configSourceResolver->isPackageConfigFile($file))) { + return null; + } + + // A function or closure body may run while the file loads or long after. + if ($loadingKey !== false && ($scope->getFunction() !== null || $scope->isInAnonymousFunction())) { + return null; + } + + $loadingKey = $loadingKey === false ? null : (string) $loadingKey; + $cacheKey = $loadingKey === null ? $key : $loadingKey . "\0" . $key; + + if (array_key_exists($cacheKey, $this->parsedConfigs)) { + $this->trackFileDependencies($scope, $this->keyDependencies[$cacheKey] ?? []); + + return $this->parsedConfigs[$cacheKey]; + } + + // Writes run only once every config file has loaded. + if (array_key_exists($key, $this->mergedConfigKeys) || ($loadingKey === null && $this->configSourceResolver->hasUnknownWrites())) { + return null; + } + + $inProgressLookups = $this->inProgressLookups; + $segments = explode('.', $key); + + [$value, $dependencies] = $this->collectFileDependencies(function () use ($segments, $loadingKey, $scope): ?array { + $root = $this->getComposedConfig($segments[0], $loadingKey, $scope); + + return $root === null ? null : $this->lookup($root, $segments, $scope); + }); + + $this->trackFileDependencies($scope, $dependencies); + + if ($this->inProgressLookups === $inProgressLookups) { + $this->keyDependencies[$cacheKey] = $dependencies; + $this->parsedConfigs[$cacheKey] = $value; + } + + return $value; + } + + /** + * Walk a composed value to a key, as Arr::get() does. + * + * @param list $segments + * @param DependencyTracker&Scope $scope + * @return null|array{TrinaryLogic, Type} + */ + private function lookup(ConfigArray $root, array $segments, Scope $scope): ?array + { + $presence = TrinaryLogic::createYes(); + $value = $root; + + foreach ($segments as $segment) { + $key = $this->toArrayKey($segment); + + if ($value instanceof Expr) { + $value = $this->getExpressionType($value, $scope); + } + + if ($value instanceof ConfigArray) { + if (! array_key_exists($key, $value->entries)) { + return $value->open ? null : [TrinaryLogic::createNo(), new NeverType]; + } + + if (isset($value->optionalKeys[$key])) { + $presence = $presence->and(TrinaryLogic::createMaybe()); + } + + $value = $value->entries[$key]; + + continue; + } + + if ($value === null) { + return null; + } + + if ($value->isArray()->yes()) { + $offset = $this->getKeyType($key); + $hasOffset = $value->hasOffsetValueType($offset); + + if ($hasOffset->no()) { + return [TrinaryLogic::createNo(), new NeverType]; + } + + $presence = $presence->and($hasOffset); + $value = $value->getOffsetValueType($offset); + + continue; + } + + // Arr::get() stops at a value it cannot index into, and returns the default. + if ($value->isArray()->no() && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($value)->no()) { + return [TrinaryLogic::createNo(), new NeverType]; + } + + return null; + } + + $type = $this->toType($value, $scope); + + return $type === null ? null : [$presence, $type]; + } + + /** + * Get the root array holding a top-level key's composed value, or null while it is + * being composed further up the call stack. + * + * @param DependencyTracker&Scope $scope + */ + private function getComposedConfig(string $name, ?string $loadingKey, Scope $scope): ?ConfigArray + { + $cacheKey = $loadingKey === null ? $name : $loadingKey . "\0" . $name; + + if (array_key_exists($cacheKey, $this->composedConfigs)) { + $this->trackFileDependencies($scope, $this->compositionDependencies[$cacheKey]); + + return $this->composedConfigs[$cacheKey]; + } + + $inProgressLookups = $this->inProgressLookups; + + [$result, $dependencies] = $this->collectFileDependencies(fn (): ?array => $this->guard( + 'hypervel.configComposition.' . $cacheKey, + fn (): ConfigArray => $this->composeConfig($name, $loadingKey, $scope), + )); + + if ($result === null) { + return null; + } + + if ($this->inProgressLookups === $inProgressLookups) { + $this->compositionDependencies[$cacheKey] = $dependencies; + $this->composedConfigs[$cacheKey] = $result[0]; + } + + return $result[0]; + } + + /** + * Compose a top-level key's value in the order Hypervel loads it, then widen it for the + * writes and merges that may or may not run. + * + * With a loading key, compose only what is set while that application config file loads: + * the framework entries without a file of their own and the files before it in load order. + * Providers merge and write only once every file has loaded. + * + * @param DependencyTracker&Scope $scope + */ + private function composeConfig(string $name, ?string $loadingKey, Scope $scope): ConfigArray + { + $root = new ConfigArray; + $frameworkFile = $this->frameworkConfigFiles[$name] ?? null; + $applicationFile = $this->configFiles[$name] ?? null; + $mergeableOptions = $this->configSourceResolver->getMergeableOptions($name); + $isLoaded = static fn (string $configKey): bool => $loadingKey === null || strnatcmp($configKey, $loadingKey) < 0; + + // The framework entry is set with the application file, and files nested below come after it. + if ($applicationFile !== null && ! $isLoaded($name)) { + return $root; + } + + if ($frameworkFile !== null) { + $this->trackFileDependencies($scope, [$frameworkFile]); + $root = new ConfigArray([$name => $this->getFileValue($frameworkFile)]); + } + + if ($applicationFile !== null) { + $value = $this->getFileValue($applicationFile); + + $root = new ConfigArray([$name => $frameworkFile === null ? $value : $this->merge( + $root->entries[$name], + $value, + $mergeableOptions, + $scope, + )]); + } + + foreach ($this->configFiles ?? [] as $configKey => $path) { + if (str_starts_with((string) $configKey, $name . '.') && $isLoaded((string) $configKey)) { + $root = $this->setPath($root, explode('.', (string) $configKey), fn (): ConfigArray|Expr|Type|null => $this->getFileValue($path), $scope); + } + } + + if ($loadingKey !== null) { + return $root; + } + + $merges = array_filter( + $this->configSourceResolver->getMerges(), + static fn (array $merge): bool => explode('.', $merge['key'])[0] === $name, + ); + + foreach ($merges as $merge) { + if ($merge['definite'] && $merge['path'] !== null) { + $path = $merge['path']; + $this->trackFileDependencies($scope, [$path]); + + $root = $this->setPath($root, explode('.', $merge['key']), function (ConfigArray|Expr|Type|null $current, bool $exists) use ($merge, $path, $scope): ?ConfigArray { + $package = $this->getFileValue($path); + $current = $exists ? $current : new ConfigArray; + + return $merge['recursive'] + ? $this->replaceRecursive($package, $current, $scope) + : $this->merge($package, $current, $merge['options'], $scope); + }, $scope); + } + } + + // Framework code adds named entries to mergeable options at runtime, such as log channels. + foreach ($mergeableOptions as $option) { + $root = $this->openRegistry($root, [$name, $option], $scope); + } + + foreach ($merges as $merge) { + foreach ($merge['options'] as $option) { + $root = $this->openRegistry($root, [...explode('.', $merge['key']), $option], $scope); + } + + if (! $merge['definite']) { + $root = $this->widenForMerge($root, explode('.', $merge['key']), $merge['options'], $merge['recursive'], $scope); + } + } + + foreach ($this->configSourceResolver->getWrittenKeys() as $writtenKey) { + $segments = explode('.', $writtenKey); + + if ($segments[0] === $name) { + $root = $this->widenForWrite($root, $segments, $scope); + } + } + + return $root; + } + + /** + * Update the value at a path as Arr::set() reaches it: a missing or non-array + * intermediate becomes an empty array. + * + * @param list $segments + * @param callable(null|ConfigArray|Expr|Type, bool): (null|ConfigArray|Expr|Type) $update receives the current value and whether it exists + * @param DependencyTracker&Scope $scope + */ + private function setPath(ConfigArray $array, array $segments, callable $update, Scope $scope): ConfigArray + { + $key = $this->toArrayKey($segments[0]); + $exists = array_key_exists($key, $array->entries); + $current = $exists ? $array->entries[$key] : null; + + if (count($segments) === 1) { + $value = $update($current, $exists && ! isset($array->optionalKeys[$key])); + } else { + $child = $exists ? $this->toConfigArray($current, $scope) : new ConfigArray; + + if ($child === null || isset($array->optionalKeys[$key])) { + // An intermediate that may not be an array keeps its other keys only if it is one. + $child = $this->isArray($current, $scope)->no() ? new ConfigArray : new ConfigArray(open: true); + } + + $value = $this->setPath($child, array_slice($segments, 1), $update, $scope); + } + + $entries = $array->entries; + $entries[$key] = $value; + $optionalKeys = $array->optionalKeys; + unset($optionalKeys[$key]); + + return new ConfigArray($entries, $optionalKeys, $array->open); + } + + /** + * Widen a composed value for a write that may run: values at and below the key become + * unknown, as does the first intermediate that is not known to be an array. + * + * @param list $segments + * @param DependencyTracker&Scope $scope + */ + private function widenForWrite(ConfigArray $array, array $segments, Scope $scope): ConfigArray + { + return $this->widenPath($array, $segments, static fn (): null => null, $scope); + } + + /** + * Widen a composed value for a merge that may run. + * + * array_merge() keeps the known string keys at the key and at the provider's mergeable + * options and adds others, so those arrays become open. It renumbers integer keys, so + * an array with any becomes unknown. array_replace_recursive() opens every level. + * + * @param list $segments + * @param list $options + * @param DependencyTracker&Scope $scope + */ + private function widenForMerge(ConfigArray $array, array $segments, array $options, bool $recursive, Scope $scope): ConfigArray + { + return $this->widenPath($array, $segments, function (ConfigArray|Expr|Type|null $current) use ($options, $recursive, $scope): ?ConfigArray { + $current = $this->toConfigArray($current, $scope); + + if ($current === null) { + return null; + } + + if ($recursive) { + return $this->openRecursively($current, $scope); + } + + if (array_any(array_keys($current->entries), is_int(...))) { + return null; + } + + $entries = $current->entries; + + foreach ($options as $option) { + if (array_key_exists($option, $entries)) { + $optionArray = $this->toConfigArray($entries[$option], $scope); + + $entries[$option] = $optionArray === null || array_any(array_keys($optionArray->entries), is_int(...)) + ? null + : new ConfigArray($optionArray->entries, $optionArray->optionalKeys, true); + } + } + + return new ConfigArray($entries, $current->optionalKeys, true); + }, $scope); + } + + /** + * Replace the existing value at a path for an update that may run. A missing key, or + * an intermediate not known to be an array, becomes unknown instead. + * + * @param list $segments + * @param callable(null|ConfigArray|Expr|Type): (null|ConfigArray|Expr|Type) $update + * @param DependencyTracker&Scope $scope + */ + private function widenPath(ConfigArray $array, array $segments, callable $update, Scope $scope): ConfigArray + { + $key = $this->toArrayKey($segments[0]); + $entries = $array->entries; + $optionalKeys = $array->optionalKeys; + + if (! array_key_exists($key, $entries)) { + if ($array->open) { + return $array; + } + + $entries[$key] = null; + $optionalKeys[$key] = true; + + return new ConfigArray($entries, $optionalKeys); + } + + if (count($segments) === 1) { + $entries[$key] = $update($entries[$key]); + } else { + $child = $this->toConfigArray($entries[$key], $scope); + $entries[$key] = $child === null ? null : $this->widenPath($child, array_slice($segments, 1), $update, $scope); + } + + return new ConfigArray($entries, $optionalKeys, $array->open); + } + + /** + * Open a mergeable option's array, which framework code extends with named entries. + * + * HandleExceptions fills a log channel whose value is falsy, so an entry that may be + * falsy becomes unknown. + * + * @param list $segments + * @param DependencyTracker&Scope $scope + */ + private function openRegistry(ConfigArray $array, array $segments, Scope $scope): ConfigArray + { + $key = $this->toArrayKey($segments[0]); + + if (! array_key_exists($key, $array->entries)) { + return $array; + } + + $entries = $array->entries; + $current = $entries[$key]; + + if (count($segments) > 1) { + $child = $this->toConfigArray($current, $scope); + $entries[$key] = $child === null ? $current : $this->openRegistry($child, array_slice($segments, 1), $scope); + } elseif (($registry = $this->toConfigArray($current, $scope)) !== null) { + $registryEntries = []; + + foreach ($registry->entries as $entryKey => $entry) { + $registryEntries[$entryKey] = $this->isTruthy($entry, $scope) ? $entry : null; + } + + $entries[$key] = new ConfigArray($registryEntries, $registry->optionalKeys, true); + } else { + $entries[$key] = $this->isArray($current, $scope)->no() ? $current : null; + } + + return new ConfigArray($entries, $array->optionalKeys, $array->open); + } + + /** + * Open an array and every array below it; a value that may be an array becomes unknown. + * + * @param DependencyTracker&Scope $scope + */ + private function openRecursively(ConfigArray $array, Scope $scope): ConfigArray + { + $entries = []; + + foreach ($array->entries as $key => $entry) { + $entries[$key] = match (true) { + $entry instanceof ConfigArray => $this->openRecursively($entry, $scope), + $this->isArray($entry, $scope)->no() => $entry, + default => null, + }; + } + + return new ConfigArray($entries, $array->optionalKeys, true); + } + + /** + * Merge two values as array_merge() does, then merge the options both set the same way. + * + * @param list $options + * @param DependencyTracker&Scope $scope + */ + private function merge(ConfigArray|Expr|Type|null $lower, ConfigArray|Expr|Type|null $higher, array $options, Scope $scope): ?ConfigArray + { + $lowerArray = $this->toConfigArray($lower, $scope); + $higherArray = $this->toConfigArray($higher, $scope); + + if ($lowerArray === null || $higherArray === null) { + return null; + } + + $open = $lowerArray->open || $higherArray->open; + $entries = []; + $optionalKeys = []; + + foreach ([$lowerArray, $higherArray] as $array) { + foreach ($array->entries as $key => $entry) { + $optional = isset($array->optionalKeys[$key]); + + if (is_int($key)) { + // array_merge() renumbers integer keys, which an optional or unknown entry would shift. + if ($optional || $open) { + return null; + } + + $entries[] = $entry; + } elseif ($optional && array_key_exists($key, $entries)) { + $entries[$key] = TypeCombinator::union($this->toType($entries[$key], $scope) ?? new MixedType, $this->toType($entry, $scope) ?? new MixedType); + } else { + $entries[$key] = $entry; + + if ($optional) { + $optionalKeys[$key] = true; + } else { + unset($optionalKeys[$key]); + } + } + } + } + + // The higher array's unknown entries may replace any lower entry it does not list. + if ($higherArray->open) { + foreach (array_keys($lowerArray->entries) as $key) { + if (! array_key_exists($key, $higherArray->entries)) { + $entries[$key] = null; + } + } + } + + foreach ($options as $option) { + if (! array_key_exists($option, $lowerArray->entries) || ! array_key_exists($option, $higherArray->entries)) { + continue; + } + + $lowerNull = $this->isNull($lowerArray->entries[$option], $scope); + $higherNull = $this->isNull($higherArray->entries[$option], $scope); + + // The option is merged only when isset() holds on both sides. + if ($lowerNull->yes() || $higherNull->yes()) { + continue; + } + + $entries[$option] = $lowerNull->no() && $higherNull->no() + ? $this->merge($lowerArray->entries[$option], $higherArray->entries[$option], [], $scope) + : null; + } + + return new ConfigArray($entries, $optionalKeys, $open); + } + + /** + * Merge two values as array_replace_recursive() does. + * + * @param DependencyTracker&Scope $scope + */ + private function replaceRecursive(ConfigArray|Expr|Type|null $lower, ConfigArray|Expr|Type|null $higher, Scope $scope): ?ConfigArray + { + $lowerArray = $this->toConfigArray($lower, $scope); + $higherArray = $this->toConfigArray($higher, $scope); + + if ($lowerArray === null || $higherArray === null) { + return null; + } + + $entries = $lowerArray->entries; + $optionalKeys = $lowerArray->optionalKeys; + + foreach ($higherArray->entries as $key => $entry) { + $higherOptional = isset($higherArray->optionalKeys[$key]); + + if (! array_key_exists($key, $entries)) { + $entries[$key] = $entry; + + if ($higherOptional) { + $optionalKeys[$key] = true; + } + + continue; + } + + // Either side may be absent, so the result may be either value or both merged. + if ($higherOptional || isset($optionalKeys[$key])) { + $entries[$key] = null; + + if (! $higherOptional) { + unset($optionalKeys[$key]); + } + + continue; + } + + $bothArrays = $this->isArray($entries[$key], $scope)->and($this->isArray($entry, $scope)); + + $entries[$key] = match (true) { + $bothArrays->yes() => $this->replaceRecursive($entries[$key], $entry, $scope), + $bothArrays->no() => $entry, + default => null, + }; + } + + // The higher array's unknown entries may replace or merge into any lower entry it does not list. + if ($higherArray->open) { + foreach (array_keys($lowerArray->entries) as $key) { + if (! array_key_exists($key, $higherArray->entries)) { + $entries[$key] = null; + } + } + } + + return new ConfigArray($entries, $optionalKeys, $lowerArray->open || $higherArray->open); + } + + /** + * Get a value as a config array when it is one array of known keys. + * + * @param DependencyTracker&Scope $scope + */ + private function toConfigArray(ConfigArray|Expr|Type|null $value, Scope $scope): ?ConfigArray + { + if ($value instanceof Expr) { + $value = $this->getExpressionType($value, $scope); + } + + if (! $value instanceof Type) { + return $value; + } + + $constantArrays = $value->getConstantArrays(); + + if (count($constantArrays) !== 1 || ! $value->isConstantArray()->yes()) { + return null; + } + + $entries = []; + $optionalKeys = []; + $valueTypes = $constantArrays[0]->getValueTypes(); + + foreach ($constantArrays[0]->getKeyTypes() as $index => $keyType) { + $entries[$keyType->getValue()] = $valueTypes[$index]; + + if ($constantArrays[0]->isOptionalKey($index)) { + $optionalKeys[$keyType->getValue()] = true; + } + } + + return new ConfigArray($entries, $optionalKeys, $constantArrays[0]->isUnsealed()->yes()); + } + + /** + * Get the type of a value, or null when it is unknown. + * + * @param DependencyTracker&Scope $scope + */ + private function toType(ConfigArray|Expr|Type|null $value, Scope $scope): ?Type + { + if ($value instanceof Expr) { + return $this->getExpressionType($value, $scope); + } + + if (! $value instanceof ConfigArray) { + return $value; + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($value->entries as $key => $entry) { + $builder->setOffsetValueType($this->getKeyType($key), $this->toType($entry, $scope) ?? new MixedType, isset($value->optionalKeys[$key])); + } + + if ($value->open) { + $builder->makeUnsealed(new UnionType([new IntegerType, new StringType]), new MixedType); + } + + return $builder->getArray(); + } + + /** + * Determine whether a value is an array. + * + * @param DependencyTracker&Scope $scope + */ + private function isArray(ConfigArray|Expr|Type|null $value, Scope $scope): TrinaryLogic + { + if ($value instanceof ConfigArray) { + return TrinaryLogic::createYes(); + } + + return $this->toType($value, $scope)?->isArray() ?? TrinaryLogic::createMaybe(); + } + + /** + * Determine whether a value is null. + * + * @param DependencyTracker&Scope $scope + */ + private function isNull(ConfigArray|Expr|Type|null $value, Scope $scope): TrinaryLogic + { + if ($value instanceof ConfigArray) { + return TrinaryLogic::createNo(); + } + + return $this->toType($value, $scope)?->isNull() ?? TrinaryLogic::createMaybe(); + } + + /** + * Determine whether a value is known to be truthy. + * + * @param DependencyTracker&Scope $scope + */ + private function isTruthy(ConfigArray|Expr|Type|null $value, Scope $scope): bool + { + if ($value instanceof ConfigArray) { + return array_any(array_keys($value->entries), static fn (int|string $key): bool => ! isset($value->optionalKeys[$key])); + } + + return $this->toType($value, $scope)?->toBoolean()->isTrue()->yes() ?? false; + } + + /** + * Get the value a config file returns: an array of its literal keys, its @return + * type, or the expression it returns. Null when the file has no single return value. + */ + private function getFileValue(string $path): ConfigArray|Expr|Type|null + { + if (! array_key_exists($path, $this->parsedConfigFiles)) { + $this->parsedConfigFiles[$path] = $this->parseConfigFile($path); + } + + $parsedConfigFile = $this->parsedConfigFiles[$path]; + + // We could not parse the file or couldn't find the return array + if ($parsedConfigFile === null) { + return null; + } + + [$return] = $parsedConfigFile; + + // Check if we have a type from the docblock + $docComment = $return->getDocComment(); + + if ($docComment !== null && $this->treatPhpDocTypesAsCertain) { + // Names in the docblock are resolved with the config file's imports + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $path, + null, + null, + null, + $docComment->getText(), + ); + + $returnTag = $resolvedPhpDoc->getReturnTag(); + + if ($returnTag !== null) { + return $returnTag->getType(); + } + } + + return $return->expr === null ? null : $this->toValue($return->expr, $path); + } + + /** + * Get an expression in a config file as a config array when it is an array literal + * with literal keys, so its values are typed only when a lookup reaches them. + */ + private function toValue(Expr $expr, string $path): ConfigArray|Expr + { + if ($expr instanceof Array_) { + $entries = []; + + // PHP normalizes the keys and numbers the items without one, and the last + // item with a key wins, as in the config file. + foreach ($expr->items as $item) { + if ($item->unpack || ($item->key !== null && ! $item->key instanceof String_ && ! $item->key instanceof Int_)) { + $entries = null; + + break; + } + + if ($item->key === null) { + $entries[] = $this->toValue($item->value, $path); + } else { + $entries[$item->key->value] = $this->toValue($item->value, $path); + } + } + + if ($entries !== null) { + return new ConfigArray($entries); + } + } + + $this->expressionFiles[spl_object_id($expr)] = $path; + + return $expr; + } + + /** + * Get the type of an expression a config file returns, in the scope at its return + * statement. Null when unknown. + * + * @param DependencyTracker&Scope $scope + */ + private function getExpressionType(Expr $expr, Scope $scope): ?Type + { + $id = spl_object_id($expr); + + if (array_key_exists($id, $this->expressionTypes)) { + $this->trackFileDependencies($scope, $this->expressionDependencies[$id]); + + return $this->expressionTypes[$id]; + } + + $inProgressLookups = $this->inProgressLookups; + $path = $this->expressionFiles[$id]; + + [$result, $dependencies] = $this->collectFileDependencies(fn (): ?array => $this->guard('hypervel.configExpression.' . $id, function () use ($expr, $path, $scope): ?Type { + $returnScope = $this->getReturnScope($path); + + if ($returnScope === null) { + return null; + } + + $this->trackFileDependencies($scope, $this->configFileDependencies[$path] ?? []); + + // An undefined variable, such as one an included file assigns, is unknown rather than an error + return TypeTraverser::map($returnScope->getType($expr), static function (Type $type, callable $traverse): Type { + if ($type instanceof ErrorType) { + return new MixedType; + } + + return $traverse($type); + }); + })); + + $this->trackFileDependencies($scope, $dependencies); + + $type = $result[0] ?? null; + + if ($this->inProgressLookups === $inProgressLookups) { + $this->expressionDependencies[$id] = $dependencies; + $this->expressionTypes[$id] = $type; + } + + return $type; + } + + /** + * Run a callback under the recursion guard, counting a refused run as an in-progress lookup. + * + * @template T + * + * @param callable(): T $callback + * @return null|array{T} + */ + private function guard(string $key, callable $callback): ?array + { + $result = RecursionGuard::run($key, static fn (): array => [$callback()]); + + if ($result === null) { + ++$this->inProgressLookups; + } + + return $result; + } + + /** + * Get the array key PHP stores a config key segment as: a decimal integer string + * becomes an integer. + */ + private function toArrayKey(string $segment): int|string + { + $integer = (int) $segment; + + return (string) $integer === $segment ? $integer : $segment; + } + + /** + * Get the constant type of an array key. + */ + private function getKeyType(int|string $key): ConstantIntegerType|ConstantStringType + { + return is_int($key) ? new ConstantIntegerType($key) : new ConstantStringType($key); + } + + /** + * Get the scope at the config file's return statement, as the statements before + * it leave it. The file is typed on its own, never in the scope of a file + * reading it, so the variables, imports and magic constants are its own. + * + * Returns null when the statements before the return always terminate, or + * while the file's statements are being processed, when they read the file + * itself. + */ + private function getReturnScope(string $path): ?Scope + { + if (array_key_exists($path, $this->returnScopes)) { + return $this->returnScopes[$path]; + } + + $result = $this->guard('hypervel.configFile.' . $path, function () use ($path): ?Scope { + /** @var array{Return_, list, null|string} $parsedConfigFile a file with a value expression parsed */ + $parsedConfigFile = $this->parsedConfigFiles[$path]; + [$return, $statements, $namespace] = $parsedConfigFile; + + $scope = $this->scopeFactory->create(ScopeContext::create($path)); + + if ($namespace !== null) { + $scope = $scope->enterNamespace($namespace); + } + + if ($statements === []) { + $this->configFileDependencies[$path] = []; + + return $scope; + } + + [$statementResult, $dependencies] = $this->collectFileDependencies(fn () => $this->nodeScopeResolver->processStmtNodes( + $return, + $statements, + $scope, + static function (): void { + }, + StatementContext::createTopLevel(), + )); + + $this->configFileDependencies[$path] = $dependencies; + + return $statementResult->isAlwaysTerminating() ? null : $statementResult->getScope(); + }); + + if ($result === null) { + return null; + } + + return $this->returnScopes[$path] = $result[0]; + } + + /** + * Declare the files on the scope and on the config files and keys being + * typed, whose cached results declare them again later. + * + * @param DependencyTracker&Scope $scope + * @param list $files + */ + private function trackFileDependencies(Scope $scope, array $files): void + { + foreach ($files as $file) { + $scope->trackFileDependency($file); + + foreach (array_keys($this->dependencyCollectors) as $index) { + $this->dependencyCollectors[$index][$file] = true; + } + } + } + + /** + * Run the callback, collecting the files declared while it runs. + * + * @param callable(): T $callback + * + * @return array{T, list} the callback's result and the files declared while it ran + * + * @template T + */ + private function collectFileDependencies(callable $callback): array + { + $this->dependencyCollectors[] = []; + + try { + $result = $callback(); + } finally { + $dependencies = array_keys(array_pop($this->dependencyCollectors)); + } + + return [$result, $dependencies]; + } + + /** + * Load the application's and the framework's config files on first use, once the + * application has booted. + */ + private function loadConfigFiles(): void + { + if ($this->configFiles !== null) { + return; + } + + $configFiles = []; + + foreach ($this->configSourceResolver->getConfigDirectories() as $configPath) { + if (! is_dir($configPath)) { + continue; + } + + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath, FilesystemIterator::SKIP_DOTS)); + + /** @var SplFileInfo $file */ + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $relativePath = substr($files->getSubPathname(), 0, -strlen('.php')); + $configKey = str_replace(['/', DIRECTORY_SEPARATOR], '.', $relativePath); + + // The first config directory that has a file for a key wins + $configFiles[$configKey] ??= $this->fileHelper->normalizePath($file->getPathname()); + + $keyParts = explode('.', $configKey); + + for ($length = count($keyParts) - 1; $length > 0; --$length) { + $this->mergedConfigKeys[implode('.', array_slice($keyParts, 0, $length))] = true; + } + } + } + + // LoadConfiguration loads the files in natural key order, so a file comes before the files nested below it. + ksort($configFiles, SORT_NATURAL); + + $this->configFiles = $configFiles; + + if ($this->configSourceResolver->shouldMergeFrameworkConfiguration()) { + foreach (glob(LoadConfiguration::frameworkConfigPath() . '/*.php') ?: [] as $file) { + $this->frameworkConfigFiles[basename($file, '.php')] = $this->fileHelper->normalizePath($file); + } + } + } + + /** + * Get where a file holding the config key's value can be: one path for every + * prefix of the key in every config directory, existing or not, so a + * created file that changes which file holds the value is noticed. + * + * Files created below the key's own directory, which make its value a + * merged one, and new directories matching a glob config path are not + * covered: there is no fixed set of paths to watch for them. + * + * @return list + */ + private function getConfigFileCandidates(string $key): array + { + if (array_key_exists($key, $this->configFileCandidates)) { + return $this->configFileCandidates[$key]; + } + + $candidates = []; + $keyParts = explode('.', $key); + + for ($length = count($keyParts); $length > 0; --$length) { + $relativePath = implode('/', array_slice($keyParts, 0, $length)) . '.php'; + + foreach ($this->configSourceResolver->getConfigDirectories() as $configPath) { + $candidates[] = $configPath . '/' . $relativePath; + } + } + + return $this->configFileCandidates[$key] = $candidates; + } + + /** + * Find the value the config file returns: its only return statement + * outside functions and classes, at the top level of the file or of its + * namespace, so it always runs once the statements before it do. + * + * @return null|array{Return_, list, null|string} the return statement, the statements before it and the file's namespace + */ + private function parseConfigFile(string $path): ?array + { + try { + $stmts = $this->parser->parseFile($path); + } catch (ParserErrorsException) { + return null; + } + + $visitor = new class extends NodeVisitorAbstract { + /** @var list */ + public array $returns = []; + + public function enterNode(Node $node): ?int + { + if ($node instanceof Node\FunctionLike || $node instanceof Node\Stmt\ClassLike) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof Return_) { + $this->returns[] = $node; + } + + return null; + } + }; + + (new NodeTraverser($visitor))->traverse($stmts); + + if (count($visitor->returns) !== 1) { + return null; + } + + $return = $visitor->returns[0]; + $namespace = null; + $statements = []; + + foreach ($stmts as $stmt) { + if (! $stmt instanceof Node\Stmt\Namespace_) { + $statements[] = $stmt; + continue; + } + + if ($namespace !== null) { + return null; + } + + $namespace = $stmt->name?->toString() ?? ''; + + foreach ($stmt->stmts as $namespacedStmt) { + $statements[] = $namespacedStmt; + } + } + + $returnIndex = array_search($return, $statements, true); + + if ($returnIndex === false) { + return null; + } + + return [$return, array_slice($statements, 0, $returnIndex), $namespace === '' ? null : $namespace]; + } +} diff --git a/src/phpstan/src/Support/ConfigSourceResolver.php b/src/phpstan/src/Support/ConfigSourceResolver.php new file mode 100644 index 000000000..792563d98 --- /dev/null +++ b/src/phpstan/src/Support/ConfigSourceResolver.php @@ -0,0 +1,1141 @@ + false, 'replaceconfigrecursivelyfrom' => true]; + + /** + * The repository methods that write a key, keyed by lowercase name, with the name of the + * parameter holding the key. + */ + private const array WRITE_METHODS = ['set' => 'key', 'push' => 'key', 'prepend' => 'key', 'offsetset' => 'offset', 'offsetunset' => 'offset']; + + /** + * The repository methods that only read. + */ + private const array READ_METHODS = [ + 'all', 'array', 'boolean', 'collection', 'float', 'get', 'getmany', + 'has', 'integer', 'offsetexists', 'offsetget', 'string', + ]; + + /** @var null|list */ + private ?array $configDirectories = null; + + /** + * The sources discovered from the loaded providers, or null before discovery. + * + * @var null|array{ + * merges: list}>, + * writtenKeys: list, + * unknownWrites: bool, + * packageFiles: array, + * packageDirectories: list, + * } + */ + private ?array $providerSources = null; + + /** + * Provider files parsed during discovery, keyed by path. + * + * @var array> + */ + private array $parsedFiles = []; + + /** + * Create a new resolver instance. + * + * @param list $configuredDirectories + */ + public function __construct( + private FileHelper $fileHelper, + #[AutowiredParameter(ref: '@currentPhpVersionSimpleDirectParser')] + private Parser $parser, + private ReflectionProvider $reflectionProvider, + #[AutowiredParameter(ref: '%configDirectories%')] + private array $configuredDirectories, + ) { + } + + /** + * Get the application's config directories, highest priority first. + * + * Configured directories replace the booted config path. Testbench's workbench + * config directory overlays the booted path when its config discovery is on. + * + * @return list + */ + public function getConfigDirectories(): array + { + if ($this->configDirectories !== null) { + return $this->configDirectories; + } + + $directories = []; + + foreach ($this->configuredDirectories as $configuredDirectory) { + $configuredDirectory = $this->fileHelper->absolutizePath($configuredDirectory); + + // An existing directory is taken literally, so glob characters in its path are not expanded + foreach ((is_dir($configuredDirectory) ? [$configuredDirectory] : (glob($configuredDirectory) ?: [])) as $directory) { + $directories[] = $directory; + } + } + + if ($this->configuredDirectories === []) { + $bootstrapper = $this->getApplication()->make(LoadConfiguration::class); + + if ($bootstrapper instanceof LoadConfigurationWithWorkbench + && (fn (): bool => $this->usesWorkbenchConfigFile)->call($bootstrapper)) { + $directories[] = workbench_path('config'); + } + + $directories[] = $this->getApplication()->configPath(); + } + + return $this->configDirectories = array_map($this->fileHelper->normalizePath(...), $directories); + } + + /** + * Determine whether the framework's config files are merged beneath the application's. + */ + public function shouldMergeFrameworkConfiguration(): bool + { + return $this->getApplication()->shouldMergeFrameworkConfiguration(); + } + + /** + * Get the named-entry options LoadConfiguration merges for a framework config file. + * + * @return list + */ + public function getMergeableOptions(string $name): array + { + $bootstrapper = $this->getApplication()->make(LoadConfiguration::class); + + return array_values((fn (): array => $this->mergeableOptions($name))->call($bootstrapper)); + } + + /** + * Get the package config files providers merge, in registration order. + * + * A merge is definite when every boot runs it; other merges may or may not run. + * The options are the provider's named-entry options for the key. + * + * @return list}> + */ + public function getMerges(): array + { + return $this->getProviderSources()['merges']; + } + + /** + * Get the config keys that providers and bootstrappers write. + * + * @return list + */ + public function getWrittenKeys(): array + { + return $this->getProviderSources()['writtenKeys']; + } + + /** + * Determine whether a provider writes config keys that cannot be found statically. + */ + public function hasUnknownWrites(): bool + { + return $this->getProviderSources()['unknownWrites']; + } + + /** + * Determine whether the file is a package config file: one that a provider merges, or + * one that a provider publishes into the config directory. + */ + public function isPackageConfigFile(string $path): bool + { + $sources = $this->getProviderSources(); + $path = $this->fileHelper->normalizePath($path); + + if (isset($sources['packageFiles'][$path])) { + return true; + } + + foreach ($sources['packageDirectories'] as $directory) { + if (str_starts_with($path, $directory . DIRECTORY_SEPARATOR)) { + return true; + } + } + + return false; + } + + /** + * Discover the configuration sources of the loaded providers. + * + * @return array{ + * merges: list}>, + * writtenKeys: list, + * unknownWrites: bool, + * packageFiles: array, + * packageDirectories: list, + * } + */ + private function getProviderSources(): array + { + if ($this->providerSources !== null) { + return $this->providerSources; + } + + $app = $this->getApplication(); + $merges = []; + $writtenKeys = []; + $mergedFiles = []; + $unknownWrites = false; + + foreach ($app->getProviders(ServiceProvider::class) as $provider) { + $unknownWrites = $this->discoverProvider($provider, $merges, $writtenKeys, $mergedFiles) || $unknownWrites; + } + + // Providers run in an order this analysis does not follow, so merges of the same + // or nested keys by different providers are not definite. + foreach ($merges as $index => $merge) { + foreach ($merges as $other) { + if ($merge['definite'] && $other['provider'] !== $merge['provider'] && $this->keysOverlap($merge['key'], $other['key'])) { + $merges[$index]['definite'] = false; + } + } + } + + // Direct writes after LoadConfiguration are recorded, such as Testbench's testing + // connection and provider set() calls. Only their keys are read. + $mutations = (fn (): array => $this->mutations)->call($app->make(ConfigMutationTracker::class)); + + foreach ($mutations as $mutation) { + if (! $mutation instanceof Closure) { + foreach (array_keys($mutation) as $key) { + $writtenKeys[] = (string) $key; + } + } + } + + $packageFiles = array_fill_keys($mergedFiles, true); + $packageDirectories = []; + + $configDirectory = $this->fileHelper->normalizePath($app->configPath()); + + foreach (ServiceProvider::pathsToPublish() as $source => $target) { + $target = $this->fileHelper->normalizePath($target); + + if ($target !== $configDirectory && ! str_starts_with($target, $configDirectory . DIRECTORY_SEPARATOR)) { + continue; + } + + if (is_dir($source)) { + $packageDirectories[] = $this->fileHelper->normalizePath($source); + } else { + $packageFiles[$this->fileHelper->normalizePath($source)] = true; + } + } + + $this->parsedFiles = []; + + return $this->providerSources = [ + 'merges' => array_map(static function (array $merge): array { + unset($merge['provider']); + + return $merge; + }, $merges), + // The direct pass also scans configureUsing() callbacks, so keys repeat. + 'writtenKeys' => array_values(array_unique($writtenKeys)), + 'unknownWrites' => $unknownWrites, + 'packageFiles' => $packageFiles, + 'packageDirectories' => $packageDirectories, + ]; + } + + /** + * Collect a provider's merges and the keys it writes, in configureUsing() callbacks + * and directly. + * + * Returns whether the provider writes keys that cannot be found statically. + * + * @param list, provider: string}> $merges + * @param list $writtenKeys + * @param list $mergedFiles the files merged, including those whose key is unknown + */ + private function discoverProvider(ServiceProvider $provider, array &$merges, array &$writtenKeys, array &$mergedFiles): bool + { + $providerClass = $this->reflectionProvider->getClass($provider::class); + $classLikes = $this->getClassLikes($providerClass); + + if ($classLikes === null) { + return true; + } + + /** @var list $definiteCalls */ + $definiteCalls = []; + $this->collectDefiniteMerges($providerClass, $providerClass, 'register', $definiteCalls); + + $calls = []; + + foreach ($definiteCalls as [$call, $file, $selfClass, $recursive]) { + $calls[] = [$call, $file, $selfClass, $recursive, true]; + } + + $reached = []; + + foreach ($definiteCalls as [$call]) { + $reached[spl_object_id($call)] = true; + } + + $unknownWrites = false; + $nodeFinder = new NodeFinder; + + foreach ($classLikes as [$classLike, $file, $selfClass]) { + $context = [$file, $selfClass, $provider]; + + foreach ($nodeFinder->findInstanceOf($classLike, MethodCall::class) as $call) { + if (! $this->isThisCall($call) || ! $call->name instanceof Identifier) { + continue; + } + + $name = $call->name->toLowerString(); + + // A callable created from a merge method has no arguments, so its merge stays unknown. + if (isset(self::MERGE_METHODS[$name]) && ! isset($reached[spl_object_id($call)])) { + $calls[] = [$call, $file, $selfClass, self::MERGE_METHODS[$name], false]; + } + + if ($name === 'configureusing') { + $unknownWrites = ! $this->collectCallbackWrites($call, $context, $writtenKeys) || $unknownWrites; + } + } + + foreach ($nodeFinder->findInstanceOf($classLike, FunctionLike::class) as $function) { + $unknownWrites = ! $this->collectDirectWrites($function, $context, $writtenKeys) || $unknownWrites; + } + } + + foreach ($calls as [$call, $file, $selfClass, $recursive, $definite]) { + $key = $this->evaluateArgument($call, 'key', 1, $file, $selfClass, $provider); + $path = $this->evaluateArgument($call, 'path', 0, $file, $selfClass, $provider); + $path = is_string($path) ? $this->fileHelper->normalizePath($path) : null; + + // A known file is a package config file even when the key it merges under is not known. + if ($path !== null) { + $mergedFiles[] = $path; + } + + if (! is_string($key)) { + $unknownWrites = true; + + continue; + } + + $merges[] = [ + 'key' => $key, + 'path' => $path, + 'recursive' => $recursive, + 'definite' => $definite && $path !== null, + 'options' => $recursive ? [] : array_values((fn (): array => $this->mergeableOptions($key))->call($provider)), + 'provider' => $provider::class, + ]; + } + + return $unknownWrites; + } + + /** + * Collect the merge calls on the straight line through a provider method: its top-level + * statements, following parent::register() and $this->helper() statements, until a + * statement that can return. A helper called twice runs twice. + * + * @param list $definiteCalls the merge calls, files, self classes and whether each merge is recursive + */ + private function collectDefiniteMerges(ClassReflection $providerClass, ClassReflection $startClass, string $methodName, array &$definiteCalls): void + { + $method = $this->findMethod($startClass, $methodName); + + if ($method === null) { + return; + } + + [$methodNode, $file, $selfClass, $declaringClass] = $method; + + RecursionGuard::run('hypervel.configProvider.' . $declaringClass->getName() . '::' . $methodNode->name->toLowerString(), function () use ($providerClass, $methodNode, $file, $selfClass, $declaringClass, &$definiteCalls): bool { + foreach ($methodNode->stmts ?? [] as $stmt) { + // Creating a callable runs nothing, so it is neither a merge nor a call to follow. + $call = $stmt instanceof Expression && $stmt->expr instanceof CallLike && ! $stmt->expr->isPartialFunctionApplication() + ? $stmt->expr + : null; + + if ($call instanceof MethodCall && $this->isThisCall($call) && $call->name instanceof Identifier) { + if (isset(self::MERGE_METHODS[$call->name->toLowerString()])) { + $definiteCalls[] = [$call, $file, $selfClass, self::MERGE_METHODS[$call->name->toLowerString()]]; + } else { + $this->collectDefiniteMerges($providerClass, $providerClass, $call->name->toString(), $definiteCalls); + } + } + + if ($call instanceof StaticCall + && $call->class instanceof Name + && $call->class->toLowerString() === 'parent' + && $call->name instanceof Identifier + && $call->name->toLowerString() === 'register' + && $declaringClass->getParentClass() !== null) { + $this->collectDefiniteMerges($providerClass, $declaringClass->getParentClass(), 'register', $definiteCalls); + } + + if ($this->canReturn($stmt)) { + break; + } + } + + return true; + }); + } + + /** + * Collect the keys a configureUsing() callback writes. + * + * The tracker records only the callback, so every write it can make must be found here. + * Returns false when one cannot be: the callback is not a closure, as when + * configureUsing(...) creates a callable, or its repository escapes or receives an + * unknown call. + * + * @param array{string, string, ServiceProvider} $context the file, self class and provider + * @param list $writtenKeys + */ + private function collectCallbackWrites(MethodCall $call, array $context, array &$writtenKeys): bool + { + $callback = $call->getArg('callback', 0)?->value; + + if (! $callback instanceof ClosureExpression && ! $callback instanceof ArrowFunction) { + return false; + } + + $receivers = []; + + if ($callback->params !== []) { + $variable = $callback->params[0]->var; + + if ($callback->params[0]->byRef || $callback->params[0]->variadic || ! $variable instanceof Variable || ! is_string($variable->name)) { + return false; + } + + $receivers[$variable->name] = true; + } + + foreach ($callback->getStmts() as $stmt) { + if (! $this->collectWrites($stmt, $receivers, true, $context, $writtenKeys)) { + return false; + } + } + + return true; + } + + /** + * Collect the keys a provider function writes directly through a recognized repository, + * including in branches the analysis boot did not run. + * + * The tracker already records every write the boot ran, whatever its receiver, so a + * repository flow this cannot follow, such as a local variable, is left to it. Returns + * false for a recognized write whose key cannot be bounded. + * + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectDirectWrites(FunctionLike $function, array $context, array &$writtenKeys): bool + { + $receivers = []; + + foreach ($function->getParams() as $parameter) { + if ($parameter->var instanceof Variable && is_string($parameter->var->name) && $this->isRepositoryType($parameter->type)) { + $receivers[$parameter->var->name] = true; + } + } + + foreach ($function->getStmts() ?? [] as $stmt) { + if (! $this->collectWrites($stmt, $receivers, false, $context, $writtenKeys)) { + return false; + } + } + + return true; + } + + /** + * Collect the config writes in a node. + * + * Writes are calls and offset writes on a recognized repository, the config([...]) + * helper and the Config facade. Returns false when a write's key cannot be bounded. When + * strict, as for callbacks, it also returns false when the repository escapes or receives + * an unknown call, including a bare config() used as a value. Otherwise those flows are + * left to the tracker, and nested functions are skipped, being collected on their own. + * + * @param array $receivers the variables holding the repository + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectWrites(Node $node, array $receivers, bool $strict, array $context, array &$writtenKeys): bool + { + $children = $node->getSubNodeNames(); + + if (! $strict && $node instanceof FunctionLike) { + return true; + } + + if (($node instanceof MethodCall && $this->isRepository($node->var, $receivers, $context)) + || ($node instanceof StaticCall && $node->class instanceof Name && $this->isConfigFacade($node->class))) { + if (! $this->collectMethodWrites($node, $strict, $context, $writtenKeys)) { + return false; + } + + $children = ['args']; + } elseif ($node instanceof FuncCall && $node->name instanceof Name && $this->isGlobalFunction($node->name, 'config') && $node->getRawArgs() !== []) { + // config(...) creates a callable that can later write any key. + if ($node->isPartialFunctionApplication()) { + return ! $strict; + } + + $key = $node->getArg('key', 0)?->value; + + if ($key instanceof Expr\Array_) { + if (! $this->collectArrayKeys($key, $context, $writtenKeys)) { + return false; + } + } elseif ($strict && ! $key instanceof Scalar && ! $key instanceof Concat) { + // Another key, including an unpacked one, may be an array of values to set. + return false; + } + } elseif (($node instanceof Assign || $node instanceof AssignRef || $node instanceof AssignOp) + && $node->var instanceof ArrayDimFetch + && $this->getRootDimension($node->var, $receivers, $context) !== null) { + if (! $this->collectDimensionWrite($node->var, $receivers, $context, $writtenKeys)) { + return false; + } + + $children = ['expr']; + } elseif ($node instanceof Unset_) { + foreach ($node->vars as $variable) { + if ($variable instanceof ArrayDimFetch && $this->getRootDimension($variable, $receivers, $context) !== null) { + if (! $this->collectDimensionWrite($variable, $receivers, $context, $writtenKeys)) { + return false; + } + } elseif (! $this->collectWrites($variable, $receivers, $strict, $context, $writtenKeys)) { + return false; + } + } + + $children = []; + } elseif ($node instanceof ArrayDimFetch && $this->getRootDimension($node, $receivers, $context) !== null) { + // An offset read; only its dimensions can hold writes. + for ($fetch = $node; $fetch instanceof ArrayDimFetch; $fetch = $fetch->var) { + if ($fetch->dim !== null && ! $this->collectWrites($fetch->dim, $receivers, $strict, $context, $writtenKeys)) { + return false; + } + } + + $children = []; + } elseif ($node instanceof ClosureExpression) { + foreach ($node->uses as $use) { + if (is_string($use->var->name) && isset($receivers[$use->var->name])) { + return false; + } + } + } elseif ($node instanceof Expr && $this->isRepository($node, $receivers, $context)) { + // The repository is passed, stored or returned. + return ! $strict; + } + + foreach ($children as $child) { + $subNodes = $node->{$child}; + + foreach (is_array($subNodes) ? $subNodes : [$subNodes] as $subNode) { + if ($subNode instanceof Node && ! $this->collectWrites($subNode, $receivers, $strict, $context, $writtenKeys)) { + return false; + } + } + } + + return true; + } + + /** + * Collect the key a repository or Config facade method call writes. + * + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectMethodWrites(MethodCall|StaticCall $call, bool $strict, array $context, array &$writtenKeys): bool + { + if (! $call->name instanceof Identifier) { + return ! $strict; + } + + $name = $call->name->toLowerString(); + + // A callable created from a read method can only read. + if (in_array($name, self::READ_METHODS, true)) { + return true; + } + + // An unknown method, or a callable created from a write method, may write any key. + if ($call->isPartialFunctionApplication() || ! isset(self::WRITE_METHODS[$name])) { + return ! $strict; + } + + // An unpacked key is not found, so it stays unknown. + $key = $call->getArg(self::WRITE_METHODS[$name], 0)?->value; + + if ($name === 'set' && $key instanceof Expr\Array_) { + return $this->collectArrayKeys($key, $context, $writtenKeys); + } + + return $key !== null && $this->collectKey($key, $context, $writtenKeys); + } + + /** + * Collect the keys of an array of config values to set. + * + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectArrayKeys(Expr\Array_ $array, array $context, array &$writtenKeys): bool + { + foreach ($array->items as $item) { + if ($item->key === null || ! $this->collectKey($item->key, $context, $writtenKeys)) { + return false; + } + } + + return true; + } + + /** + * Collect the key an offset write on a recognized repository writes. + * + * @param array $receivers + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectDimensionWrite(ArrayDimFetch $fetch, array $receivers, array $context, array &$writtenKeys): bool + { + /** @var ArrayDimFetch $root */ + $root = $this->getRootDimension($fetch, $receivers, $context); + + return $root->dim !== null && $this->collectKey($root->dim, $context, $writtenKeys); + } + + /** + * Collect a written key. A key that does not resolve is bounded by its constant leading + * segments: `'filesystems.disks.' . $name` writes below `filesystems.disks`, and + * `'filesystems.disks' . $suffix` below `filesystems`, since the suffix may extend `disks`. + * + * Returns false when not even the first segment is constant. + * + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectKey(Expr $key, array $context, array &$writtenKeys): bool + { + $value = $this->evaluate($key, ...$context); + + if (is_string($value) || is_int($value)) { + $writtenKeys[] = (string) $value; + + return true; + } + + $prefix = $this->getConstantPrefix($key, $context); + $boundary = $prefix === null ? false : strrpos($prefix, '.'); + + if ($boundary === false || $boundary === 0) { + return false; + } + + $writtenKeys[] = substr($prefix, 0, $boundary); + + return true; + } + + /** + * Get the constant leading part of a string expression built by concatenation or + * interpolation, or null when it has none. + * + * @param array{string, string, ServiceProvider} $context + */ + private function getConstantPrefix(Expr $expr, array $context): ?string + { + if ($expr instanceof Concat) { + $left = $this->evaluate($expr->left, ...$context); + + return is_string($left) + ? $left . ($this->getConstantPrefix($expr->right, $context) ?? '') + : $this->getConstantPrefix($expr->left, $context); + } + + if ($expr instanceof InterpolatedString) { + $prefix = ''; + + foreach ($expr->parts as $part) { + if (! $part instanceof InterpolatedStringPart) { + break; + } + + $prefix .= $part->value; + } + + return $prefix; + } + + $value = $this->evaluate($expr, ...$context); + + return is_string($value) ? $value : null; + } + + /** + * Get the offset fetch applied directly to a recognized repository beneath the fetch, if any. + * + * @param array $receivers + * @param array{string, string, ServiceProvider} $context + */ + private function getRootDimension(ArrayDimFetch $fetch, array $receivers, array $context): ?ArrayDimFetch + { + while ($fetch->var instanceof ArrayDimFetch) { + $fetch = $fetch->var; + } + + return $this->isRepository($fetch->var, $receivers, $context) ? $fetch : null; + } + + /** + * Determine whether the expression is the config repository: a variable holding it, a + * bare config(), app() or resolve() with its id, or the provider application's make() + * or get() with its id. The container has no ArrayAccess, so `$this->app['config']` is + * not a form. + * + * @param array $receivers + * @param array{string, string, ServiceProvider} $context + */ + private function isRepository(Expr $expr, array $receivers, array $context): bool + { + if ($expr instanceof Variable) { + return is_string($expr->name) && isset($receivers[$expr->name]); + } + + // A created callable is not the repository: getArg() finds no argument past a "..." placeholder. + if ($expr instanceof FuncCall && $expr->name instanceof Name) { + if ($this->isGlobalFunction($expr->name, 'config')) { + return $expr->getRawArgs() === []; + } + + $id = match (true) { + $this->isGlobalFunction($expr->name, 'app') => $expr->getArg('abstract', 0), + $this->isGlobalFunction($expr->name, 'resolve') => $expr->getArg('name', 0), + default => null, + }; + + return $id !== null && $this->isRepositoryId($id->value, $context); + } + + if (! $expr instanceof MethodCall + || ! $expr->var instanceof PropertyFetch + || ! $expr->var->var instanceof Variable + || $expr->var->var->name !== 'this' + || ! $expr->var->name instanceof Identifier + || $expr->var->name->toString() !== 'app' + || ! $expr->name instanceof Identifier) { + return false; + } + + $id = match ($expr->name->toLowerString()) { + 'make' => $expr->getArg('abstract', 0), + 'get' => $expr->getArg('id', 0), + default => null, + }; + + return $id !== null && $this->isRepositoryId($id->value, $context); + } + + /** + * Determine whether a container id resolves the config repository. + * + * @param array{string, string, ServiceProvider} $context + */ + private function isRepositoryId(Expr $id, array $context): bool + { + $id = $this->evaluate($id, ...$context); + + return is_string($id) && ($id === 'config' || $this->isRepositoryClass($id)); + } + + /** + * Determine whether a parameter type is the config repository's contract or a class implementing it. + */ + private function isRepositoryType(?Node $type): bool + { + if ($type instanceof NullableType) { + $type = $type->type; + } + + return $type instanceof Name && ! $type->isSpecialClassName() && $this->isRepositoryClass($type->toString()); + } + + /** + * Determine whether the class is the config repository's contract or implements it. + */ + private function isRepositoryClass(string $class): bool + { + return $this->reflectionProvider->hasClass($class) + && $this->reflectionProvider->getClass($class)->is(RepositoryContract::class); + } + + /** + * Determine whether the class name refers to the Config facade. + */ + private function isConfigFacade(Name $class): bool + { + $name = $class->toString(); + + return ! $class->isSpecialClassName() + && $this->reflectionProvider->hasClass($name) + && $this->reflectionProvider->getClass($name)->is(Config::class); + } + + /** + * Determine whether the call's name refers to the given global function. + */ + private function isGlobalFunction(Name $name, string $function): bool + { + $namespacedName = $name->getAttribute('namespacedName'); + + // An unqualified call resolves to a namespaced function of the same name when one exists. + if ($namespacedName instanceof FullyQualified && function_exists($namespacedName->toString())) { + return false; + } + + return $name->toLowerString() === $function; + } + + /** + * Determine whether the call is made on $this. + */ + private function isThisCall(MethodCall $call): bool + { + return $call->var instanceof Variable && $call->var->name === 'this'; + } + + /** + * Determine whether two config keys are the same or one is nested in the other. + */ + private function keysOverlap(string $key, string $other): bool + { + return $key === $other || str_starts_with($key, $other . '.') || str_starts_with($other, $key . '.'); + } + + /** + * Determine whether the statement can return from its function. + */ + private function canReturn(Stmt $stmt): bool + { + $visitor = new class extends NodeVisitorAbstract { + public bool $returns = false; + + public function enterNode(Node $node): ?int + { + if ($node instanceof Node\FunctionLike || $node instanceof ClassLike) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof Return_) { + $this->returns = true; + + return NodeVisitor::STOP_TRAVERSAL; + } + + return null; + } + }; + + (new NodeTraverser($visitor))->traverse([$stmt]); + + return $visitor->returns; + } + + /** + * Evaluate a call argument as a constant expression, or return null. + */ + private function evaluateArgument(MethodCall $call, string $name, int $position, string $file, string $selfClass, ServiceProvider $provider): mixed + { + $argument = $call->getArg($name, $position); + + return $argument === null ? null : $this->evaluate($argument->value, $file, $selfClass, $provider); + } + + /** + * Evaluate a constant expression in provider code, or return null when it is not constant. + * + * Besides literals, it resolves the file's __DIR__ and __FILE__, dirname() calls, class + * constants and the current value of a static string property, which providers such as + * Sentry's use as boot metadata. PHPStan does not fold path constants by default. + */ + private function evaluate(Expr $expr, string $file, string $selfClass, ServiceProvider $provider): mixed + { + $evaluator = new ConstExprEvaluator(function (Expr $expr) use (&$evaluator, $file, $selfClass, $provider): mixed { + // Errors such as an undefined constant surface as ConstExprEvaluationException through evaluateSilently(). + if ($expr instanceof Dir) { + return dirname($file); + } + + if ($expr instanceof File) { + return $file; + } + + if ($expr instanceof FuncCall && $expr->name instanceof Name && $this->isGlobalFunction($expr->name, 'dirname')) { + return dirname(...array_map(static fn (Node\Arg $argument): mixed => $evaluator->evaluateDirectly($argument->value), $expr->getArgs())); + } + + if ($expr instanceof ClassConstFetch && $expr->class instanceof Name && $expr->name instanceof Identifier) { + $class = $this->resolveClassName($expr->class, $selfClass, $provider); + + // Reflection reads protected and private constants, which providers often use. + return $expr->name->toLowerString() === 'class' + ? $class + : (new ReflectionClassConstant($class, $expr->name->toString()))->getValue(); + } + + if ($expr instanceof StaticPropertyFetch + && $expr->class instanceof Name + && in_array($expr->class->toLowerString(), ['self', 'static'], true) + && $expr->name instanceof VarLikeIdentifier) { + $value = (new ReflectionProperty($this->resolveClassName($expr->class, $selfClass, $provider), $expr->name->toString()))->getValue(); + + if (is_string($value)) { + return $value; + } + } + + throw new ConstExprEvaluationException('Expression is not constant.'); + }); + + try { + return $evaluator->evaluateSilently($expr); + } catch (ConstExprEvaluationException) { + return null; + } + } + + /** + * Resolve a class name in provider code, including self, static and parent. + * + * @throws ConstExprEvaluationException + */ + private function resolveClassName(Name $class, string $selfClass, ServiceProvider $provider): string + { + return match ($class->toLowerString()) { + 'self' => $selfClass, + 'static' => $provider::class, + 'parent' => $this->reflectionProvider->getClass($selfClass)->getParentClass()?->getName() + ?? throw new ConstExprEvaluationException('The class has no parent.'), + default => $class->toString(), + }; + } + + /** + * Find a method's declaration, searching the class, its traits and its parents below ServiceProvider. + * + * @return null|array{ClassMethod, string, string, ClassReflection} the method, its file, its self class and its declaring class + */ + private function findMethod(ClassReflection $class, string $methodName): ?array + { + for ($current = $class; $current !== null && $current->getName() !== ServiceProvider::class; $current = $current->getParentClass()) { + foreach ($this->getOwnClassLikes($current) ?? [] as [$classLike, $file]) { + $method = $classLike->getMethod($methodName); + + if ($method !== null) { + return [$method, $file, $current->getName(), $current]; + } + } + } + + return null; + } + + /** + * Get the class declarations of a provider's hierarchy below ServiceProvider, with their traits. + * + * Returns null when a declaration has no source file to read. + * + * @return null|list each declaration, its file and its self class + */ + private function getClassLikes(ClassReflection $class): ?array + { + $classLikes = []; + + for ($current = $class; $current !== null && $current->getName() !== ServiceProvider::class; $current = $current->getParentClass()) { + $ownClassLikes = $this->getOwnClassLikes($current); + + if ($ownClassLikes === null) { + return null; + } + + foreach ($ownClassLikes as [$classLike, $file]) { + $classLikes[] = [$classLike, $file, $current->getName()]; + } + } + + return $classLikes; + } + + /** + * Get a class's own declaration followed by those of its traits, depth first. + * + * @return null|list + */ + private function getOwnClassLikes(ClassReflection $class): ?array + { + $file = $class->getFileName(); + + if ($file === null) { + return null; + } + + $this->parsedFiles[$file] ??= $this->parser->parseFile($file); + + $classLike = (new NodeFinder)->findFirst( + $this->parsedFiles[$file], + static fn (Node $node): bool => $node instanceof ClassLike + && strtolower($node->namespacedName?->toString() ?? '') === strtolower($class->getName()), + ); + + if (! $classLike instanceof ClassLike) { + return null; + } + + $classLikes = [[$classLike, $file]]; + + foreach ($class->getTraits() as $trait) { + $traitClassLikes = $this->getOwnClassLikes($trait); + + if ($traitClassLikes === null) { + return null; + } + + foreach ($traitClassLikes as $traitClassLike) { + $classLikes[] = $traitClassLike; + } + } + + return $classLikes; + } + + /** + * Get the booted application. + */ + private function getApplication(): Application + { + return $this->getContainer()->make(Application::class); + } +} From 70d38814813cb8ff651b93b68bfc400e009f399e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:59:49 +0000 Subject: [PATCH 053/303] feat: infer configuration helper and repository return types Share presence-aware lookup across config(), Config facades and repository methods. Preserve default values, evaluate only defaults that are themselves closures, and respect custom methods and inherited getters that delegate to overrides. Add application/provider fixtures and regression coverage for load ordering, named writes, unknown targets, callable handling, open and numeric merges, config discovery and dependency reuse. Validation: the reviewed analyzer suite passes with 377 tests; main and source-type PHPStan analyses pass. --- src/phpstan/src/Internal/ConfigHelper.php | 237 ++++++++++++++++++ ...DynamicStaticMethodReturnTypeExtension.php | 60 +++++ ...sitoryDynamicMethodReturnTypeExtension.php | 56 +++++ ...tionDynamicFunctionReturnTypeExtension.php | 47 ++++ .../Config/ConfigFixtureServiceProvider.php | 108 ++++++++ .../OverlappingConfigServiceProvider.php | 18 ++ .../Writes/CallableCreationProvider.php | 21 ++ .../Writes/CallbackCallableProvider.php | 22 ++ .../Writes/DynamicCallbackWriteProvider.php | 21 ++ .../Writes/DynamicDirectWriteProvider.php | 18 ++ .../Config/Writes/DynamicMergeKeyProvider.php | 20 ++ .../Writes/EscapingCallbackProvider.php | 28 +++ .../Config/Writes/MergeCallableProvider.php | 32 +++ .../Writes/ReadCallableCallbackProvider.php | 22 ++ .../Writes/ReorderedArgumentsProvider.php | 32 +++ .../Writes/UnpackedCallbackWriteProvider.php | 21 ++ .../Writes/UnrecognizedFlowProvider.php | 29 +++ .../Fixtures/Config/application/listed.php | 5 + .../Fixtures/Config/application/merged.php | 8 + .../Config/application/open-recursive.php | 8 + .../Fixtures/Config/application/open.php | 8 + .../Fixtures/Config/application/recursive.php | 10 + .../Fixtures/Config/application/replaced.php | 5 + .../Fixtures/Config/config/conditional.php | 7 + .../PHPStan/Fixtures/Config/config/listed.php | 5 + .../PHPStan/Fixtures/Config/config/merged.php | 8 + .../Fixtures/Config/config/open-recursive.php | 8 + tests/PHPStan/Fixtures/Config/config/open.php | 8 + .../Fixtures/Config/config/overlap.php | 7 + .../Fixtures/Config/config/package.php | 12 + .../Fixtures/Config/config/partial.php | 7 + .../Fixtures/Config/config/property.php | 7 + .../Fixtures/Config/config/published.php | 7 + .../Fixtures/Config/config/recursive.php | 10 + .../Fixtures/Config/config/replaced.php | 5 + .../ConfigDynamicReturnTypeExtensionTest.php | 81 ++++++ .../PHPStan/Type/ConfigFileResolutionTest.php | 82 ++++++ .../data/config-facade-collection-method.php | 15 ++ .../Type/data/config-file-dependency.php | 11 + .../Type/data/config-file-resolution.neon | 8 + .../Type/data/config-file-resolution.php | 43 ++++ .../config-file-resolution/config/app.php | 5 + .../config-file-resolution/config/nested.php | 7 + .../config/nested/deep/leaf.php | 5 + .../config/nested/rcnest.php | 5 + .../config/nested/typed.php | 6 + .../config-file-resolution/config/rcdup.php | 5 + .../config/scopeclosure.php | 9 + .../config/scopeconditional.php | 7 + .../config/scopedoc.php | 8 + .../config/scopeforward.php | 5 + .../config/scopeinclude.php | 7 + .../config/scopeleaf.php | 5 + .../config/scopereader.php | 8 + .../config/scopeself.php | 10 + .../config/scopevars.php | 7 + .../config/services/stripe.php | 5 + .../config-file-resolution/config/webapp.php | 5 + .../config-file-resolution/config/xrcapp.php | 5 + .../config-file-resolution/config/xrcdup.php | 5 + .../literal[1]/config/rcliteral.php | 5 + .../modules/a/config/rcmoda.php | 5 + .../modules/b/config/rcmodb.php | 5 + .../data/config-file-scope-dependency.php | 9 + tests/PHPStan/Type/data/config-file-scope.php | 63 +++++ .../Type/data/config-helper-function.php | 27 ++ .../Type/data/config-provider-sources.php | 56 +++++ .../data/config-repository-method-l12-20.php | 15 ++ .../Type/data/config-repository-method.php | 58 +++++ .../PHPStan/Type/data/config-reproduction.php | 11 + .../Type/data/config-with-config-paths.neon | 4 + .../Unit/Support/ConfigSourceResolverTest.php | 135 ++++++++++ .../application/config/reproduction.php | 8 + tests/PHPStan/application/config/test.php | 7 + tests/PHPStan/phpunit-bootstrap.php | 8 + types/Foundation/Helpers.php | 5 +- 76 files changed, 1706 insertions(+), 1 deletion(-) create mode 100644 src/phpstan/src/Internal/ConfigHelper.php create mode 100644 src/phpstan/src/ReturnTypes/ConfigFacadeDynamicStaticMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/ConfigRepositoryDynamicMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/ConfigFunctionDynamicFunctionReturnTypeExtension.php create mode 100644 tests/PHPStan/Fixtures/Config/ConfigFixtureServiceProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/OverlappingConfigServiceProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/Writes/CallableCreationProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/Writes/CallbackCallableProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/Writes/DynamicCallbackWriteProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/Writes/DynamicDirectWriteProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/Writes/DynamicMergeKeyProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/Writes/EscapingCallbackProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/Writes/MergeCallableProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/Writes/ReadCallableCallbackProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/Writes/ReorderedArgumentsProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/Writes/UnpackedCallbackWriteProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/Writes/UnrecognizedFlowProvider.php create mode 100644 tests/PHPStan/Fixtures/Config/application/listed.php create mode 100644 tests/PHPStan/Fixtures/Config/application/merged.php create mode 100644 tests/PHPStan/Fixtures/Config/application/open-recursive.php create mode 100644 tests/PHPStan/Fixtures/Config/application/open.php create mode 100644 tests/PHPStan/Fixtures/Config/application/recursive.php create mode 100644 tests/PHPStan/Fixtures/Config/application/replaced.php create mode 100644 tests/PHPStan/Fixtures/Config/config/conditional.php create mode 100644 tests/PHPStan/Fixtures/Config/config/listed.php create mode 100644 tests/PHPStan/Fixtures/Config/config/merged.php create mode 100644 tests/PHPStan/Fixtures/Config/config/open-recursive.php create mode 100644 tests/PHPStan/Fixtures/Config/config/open.php create mode 100644 tests/PHPStan/Fixtures/Config/config/overlap.php create mode 100644 tests/PHPStan/Fixtures/Config/config/package.php create mode 100644 tests/PHPStan/Fixtures/Config/config/partial.php create mode 100644 tests/PHPStan/Fixtures/Config/config/property.php create mode 100644 tests/PHPStan/Fixtures/Config/config/published.php create mode 100644 tests/PHPStan/Fixtures/Config/config/recursive.php create mode 100644 tests/PHPStan/Fixtures/Config/config/replaced.php create mode 100644 tests/PHPStan/Type/ConfigDynamicReturnTypeExtensionTest.php create mode 100644 tests/PHPStan/Type/ConfigFileResolutionTest.php create mode 100644 tests/PHPStan/Type/data/config-facade-collection-method.php create mode 100644 tests/PHPStan/Type/data/config-file-dependency.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution.neon create mode 100644 tests/PHPStan/Type/data/config-file-resolution.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/app.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/nested.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/nested/deep/leaf.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/nested/rcnest.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/nested/typed.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/rcdup.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/scopeclosure.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/scopeconditional.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/scopedoc.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/scopeforward.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/scopeinclude.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/scopeleaf.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/scopereader.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/scopeself.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/scopevars.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/services/stripe.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/webapp.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/xrcapp.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/config/xrcdup.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/literal[1]/config/rcliteral.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/modules/a/config/rcmoda.php create mode 100644 tests/PHPStan/Type/data/config-file-resolution/modules/b/config/rcmodb.php create mode 100644 tests/PHPStan/Type/data/config-file-scope-dependency.php create mode 100644 tests/PHPStan/Type/data/config-file-scope.php create mode 100644 tests/PHPStan/Type/data/config-helper-function.php create mode 100644 tests/PHPStan/Type/data/config-provider-sources.php create mode 100644 tests/PHPStan/Type/data/config-repository-method-l12-20.php create mode 100644 tests/PHPStan/Type/data/config-repository-method.php create mode 100644 tests/PHPStan/Type/data/config-reproduction.php create mode 100644 tests/PHPStan/Type/data/config-with-config-paths.neon create mode 100644 tests/PHPStan/Unit/Support/ConfigSourceResolverTest.php create mode 100644 tests/PHPStan/application/config/reproduction.php create mode 100644 tests/PHPStan/application/config/test.php diff --git a/src/phpstan/src/Internal/ConfigHelper.php b/src/phpstan/src/Internal/ConfigHelper.php new file mode 100644 index 000000000..959c3603d --- /dev/null +++ b/src/phpstan/src/Internal/ConfigHelper.php @@ -0,0 +1,237 @@ +getArgs(), static fn (Arg $argument): bool => $argument->unpack)) { + return null; + } + + $key = $call->getArg('key', 0); + + if ($key === null) { + return null; + } + + $keyType = $scope->getType($key->value); + + if (! $keyType->isString()->yes() || ! $keyType->isConstantScalarValue()->yes()) { + return null; + } + + $default = $call->getArg('default', 1); + $defaultType = $default === null ? new NullType : TypeTraverser::map( + $scope->getType($default->value), + static function (Type $type, callable $traverse) use ($scope): Type { + // value() calls a default that is itself a Closure, not one the default holds. + if ($type instanceof UnionType) { + return $traverse($type); + } + + return (new ObjectType(Closure::class))->isSuperTypeOf($type)->yes() + ? $type->getCallableParametersAcceptors($scope)[0]->getReturnType() + : $type; + }, + )->generalize(GeneralizePrecision::lessSpecific()); + + $types = []; + + foreach ($keyType->getConstantStrings() as $constantString) { + $value = $this->configParser->getValue($constantString->getValue(), $scope); + + if ($value === null) { + return null; + } + + [$presence, $type] = $value; + + if (! $presence->no()) { + $types[] = $type; + } + + if (! $presence->yes()) { + $types[] = $defaultType; + } + } + + return TypeCombinator::union(...$types); + } + + /** + * Get the type a repository or Config facade method returns, or null when the + * declared type applies. + * + * The typed getters throw for a value of another type, so a value that never has + * the getter's type gives an error type, which ConfigTypeRule and ConfigCollectionRule + * report. + * + * @param DependencyTracker&Scope $scope + */ + public function getMethodReturnType(CallLike $call, Scope $scope): ?Type + { + $methodName = $this->getMethodName($call, $scope); + + if ($methodName === null) { + return null; + } + + $valueType = $this->getValueType($call, $scope); + + if ($valueType === null || $methodName === 'get') { + return $valueType; + } + + $type = TypeCombinator::intersect($valueType, $this->getGetterType($methodName)); + + if ($type instanceof NeverType) { + return new ErrorType; + } + + if ($methodName === 'collection') { + return new GenericObjectType(Collection::class, [$type->getIterableKeyType(), $type->getIterableValueType()]); + } + + return $type; + } + + /** + * Get the name of the standard repository or Config facade value method the call makes, + * or null when it calls another method or a custom implementation. + */ + public function getMethodName(CallLike $call, Scope $scope): ?string + { + if ((! $call instanceof MethodCall && ! $call instanceof StaticCall) || ! $call->name instanceof Identifier) { + return null; + } + + $methodName = $call->name->toLowerString(); + + if (! in_array($methodName, self::METHODS, true)) { + return null; + } + + if ($call instanceof MethodCall) { + [$calledOnType, $receiverClass] = [$scope->getType($call->var), Repository::class]; + } elseif ($call->class instanceof Name) { + [$calledOnType, $receiverClass] = [new ObjectType($scope->resolveName($call->class)), Config::class]; + } else { + return null; + } + + if (! (new ObjectType($receiverClass))->isSuperTypeOf($calledOnType)->yes()) { + return null; + } + + // The repository's typed getters read through get(), and collection() through array(), + // so a receiver overriding either changes what they return. Facade calls forward to the + // bound repository, so a facade subclass's own methods do not change them. + $methodNames = match (true) { + $call instanceof StaticCall, $methodName === 'get' => [$methodName], + $methodName === 'collection' => [$methodName, 'array', 'get'], + default => [$methodName, 'get'], + }; + + foreach ($methodNames as $name) { + $method = $scope->getMethodReflection($calledOnType, $name); + + if ($method === null || ! $this->isConfigMethod($method)) { + return null; + } + } + + return $methodName; + } + + /** + * Determine whether the method is one of the standard repository's, its contract's or the + * Config facade's value methods. A custom implementation keeps its declared return type. + */ + public function isConfigMethod(MethodReflection $method): bool + { + return in_array(strtolower($method->getName()), self::METHODS, true) + && in_array($method->getDeclaringClass()->getName(), [Repository::class, ConfigRepository::class, Config::class], true); + } + + /** + * Get the type a typed getter requires its value to have. + */ + public function getGetterType(string $methodName): Type + { + return match ($methodName) { + 'string' => new StringType, + 'integer' => new IntegerType, + 'float' => new FloatType, + 'boolean' => new BooleanType, + 'array', 'collection' => new ArrayType(new MixedType, new MixedType), + default => throw new InvalidArgumentException(sprintf('[%s] is not a typed config getter.', $methodName)), + }; + } +} diff --git a/src/phpstan/src/ReturnTypes/ConfigFacadeDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConfigFacadeDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 000000000..372871e6e --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConfigFacadeDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,60 @@ +configHelper->isConfigMethod($methodReflection); + } + + /** + * Get the type of the configuration value the method returns. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + return $this->configHelper->getMethodReturnType($methodCall, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/ConfigRepositoryDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConfigRepositoryDynamicMethodReturnTypeExtension.php new file mode 100644 index 000000000..6f96d2a8e --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConfigRepositoryDynamicMethodReturnTypeExtension.php @@ -0,0 +1,56 @@ +configHelper->isConfigMethod($methodReflection); + } + + /** + * Get the type of the configuration value the method returns. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + return $this->configHelper->getMethodReturnType($methodCall, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ConfigFunctionDynamicFunctionReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ConfigFunctionDynamicFunctionReturnTypeExtension.php new file mode 100644 index 000000000..f3f92442e --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/ConfigFunctionDynamicFunctionReturnTypeExtension.php @@ -0,0 +1,47 @@ +getName() === 'config'; + } + + /** + * Get the type of the configuration value the helper returns for a key. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + return $this->configHelper->getValueType($functionCall, $scope); + } +} diff --git a/tests/PHPStan/Fixtures/Config/ConfigFixtureServiceProvider.php b/tests/PHPStan/Fixtures/Config/ConfigFixtureServiceProvider.php new file mode 100644 index 000000000..b73451898 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/ConfigFixtureServiceProvider.php @@ -0,0 +1,108 @@ +mergeConfigFrom(__DIR__ . '/config/package.php', self::PACKAGE_KEY); + $this->mergeConfigFrom(__DIR__ . '/config/merged.php', 'merged'); + $this->replaceConfigRecursivelyFrom(__DIR__ . '/config/recursive.php', 'recursive'); + $this->mergeConfigFrom(__DIR__ . '/config/overlap.php', 'overlap'); + $this->mergeConfigFrom(__DIR__ . '/config/partial.php', 'partial'); + $this->mergeConfigFrom(__DIR__ . '/config/open.php', 'open'); + $this->replaceConfigRecursivelyFrom(__DIR__ . '/config/open-recursive.php', 'open-recursive'); + $this->mergeConfigFrom(__DIR__ . '/config/listed.php', 'listed'); + $this->replaceConfigRecursivelyFrom(__DIR__ . '/config/replaced.php', 'replaced'); + $this->registerPropertyConfig(); + + // Only the tracker sees this write: the static pass does not follow locals. + $config = $this->app->make('config'); + $config->set('package.direct', 'value'); + + $this->configureUsing(static function (Repository $config): void { + $config->set('package.written', $config->string('package.key')); + }); + + $this->configureUsing(static function (): void { + app('config')->set('package.helper', 'value'); + }); + + $this->publishes([__DIR__ . '/config/published.php' => config_path('published.php')]); + + if ($this->app->runningInConsole()) { + $this->mergeConfigFrom(__DIR__ . '/config/conditional.php', 'conditional'); + } + + // The analysis boot runs in the console, so only the static pass sees this write. + if (! $this->app->runningInConsole()) { + $this->app->make('config')->set('package.web', 'value'); + + return; + } + + $this->mergeConfigFrom(__DIR__ . '/config/conditional.php', 'after-return'); + } + + /** + * Write through a repository parameter, as the gRPC provider appends its server. + */ + protected function writeThroughParameter(Repository $config): void + { + $config->set('package.parameter', 'value'); + } + + /** + * Write a named entry with a dynamic name, as packages register disks. + */ + protected function registerDisk(string $name): void + { + config(['merged.disks.' . $name => ['driver' => 'local']]); + } + + /** + * Write a key whose last segment is only partly constant. + */ + protected function registerSuffixed(string $suffix): void + { + config(["partial.disks{$suffix}" => true]); + } + + /** + * Merge the config file keyed by the static property. + */ + protected function registerPropertyConfig(): void + { + $this->mergeConfigFrom(dirname(__DIR__) . '/Config/config/property.php', static::$propertyKey); + } + + /** + * Get the package configuration's named-entry options. + */ + protected function mergeableOptions(string $name): array + { + return $name === self::PACKAGE_KEY ? ['stores'] : []; + } +} diff --git a/tests/PHPStan/Fixtures/Config/OverlappingConfigServiceProvider.php b/tests/PHPStan/Fixtures/Config/OverlappingConfigServiceProvider.php new file mode 100644 index 000000000..8d1e3f5bd --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/OverlappingConfigServiceProvider.php @@ -0,0 +1,18 @@ +mergeConfigFrom(__DIR__ . '/config/overlap.php', 'overlap'); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/CallableCreationProvider.php b/tests/PHPStan/Fixtures/Config/Writes/CallableCreationProvider.php new file mode 100644 index 000000000..7b8f34f06 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/CallableCreationProvider.php @@ -0,0 +1,21 @@ +set(...); + $make = $this->app->make(...); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/CallbackCallableProvider.php b/tests/PHPStan/Fixtures/Config/Writes/CallbackCallableProvider.php new file mode 100644 index 000000000..ccfad4d4c --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/CallbackCallableProvider.php @@ -0,0 +1,22 @@ +configureUsing(static function (Repository $config): void { + $set = $config->set(...); + $set('package.key', 'value'); + }); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/DynamicCallbackWriteProvider.php b/tests/PHPStan/Fixtures/Config/Writes/DynamicCallbackWriteProvider.php new file mode 100644 index 000000000..4fd409ac0 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/DynamicCallbackWriteProvider.php @@ -0,0 +1,21 @@ +configureUsing(static function (Repository $config): void { + $config->set($config->string('package.target'), true); + }); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/DynamicDirectWriteProvider.php b/tests/PHPStan/Fixtures/Config/Writes/DynamicDirectWriteProvider.php new file mode 100644 index 000000000..162e20bc5 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/DynamicDirectWriteProvider.php @@ -0,0 +1,18 @@ +app->environment() => true]); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/DynamicMergeKeyProvider.php b/tests/PHPStan/Fixtures/Config/Writes/DynamicMergeKeyProvider.php new file mode 100644 index 000000000..eb307acb7 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/DynamicMergeKeyProvider.php @@ -0,0 +1,20 @@ +mergeConfigFrom(dirname(__DIR__) . '/config/package.php', $this->app->environment()); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/EscapingCallbackProvider.php b/tests/PHPStan/Fixtures/Config/Writes/EscapingCallbackProvider.php new file mode 100644 index 000000000..79e1b9abc --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/EscapingCallbackProvider.php @@ -0,0 +1,28 @@ +configureUsing(static function (Repository $config): void { + static::configure($config); + }); + } + + /** + * Change the configuration. + */ + protected static function configure(object $config): void + { + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/MergeCallableProvider.php b/tests/PHPStan/Fixtures/Config/Writes/MergeCallableProvider.php new file mode 100644 index 000000000..899d3e7d0 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/MergeCallableProvider.php @@ -0,0 +1,32 @@ +mergeConfigFrom(...); + $merge(dirname(__DIR__) . '/config/package.php', 'package'); + + $this->mergePartialConfig(...); + } + + /** + * Merge the partial configuration. + */ + protected function mergePartialConfig(): void + { + $this->mergeConfigFrom(dirname(__DIR__) . '/config/partial.php', 'partial'); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/ReadCallableCallbackProvider.php b/tests/PHPStan/Fixtures/Config/Writes/ReadCallableCallbackProvider.php new file mode 100644 index 000000000..26f4bc84f --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/ReadCallableCallbackProvider.php @@ -0,0 +1,22 @@ +configureUsing(static function (Repository $config): void { + $get = $config->get(...); + $config->set('package.copy', $get('package.original')); + }); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/ReorderedArgumentsProvider.php b/tests/PHPStan/Fixtures/Config/Writes/ReorderedArgumentsProvider.php new file mode 100644 index 000000000..e7ec2a410 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/ReorderedArgumentsProvider.php @@ -0,0 +1,32 @@ +configureUsing(static function (Repository $config): void { + $config->set(value: 'text', key: 'package.callback'); + $config->offsetSet(value: 'text', offset: 'package.offset'); + }); + } + + /** + * Write through the helper and repositories resolved with reordered arguments. + */ + protected function writeDirectly(): void + { + config(default: null, key: ['package.helper' => 'text']); + app(parameters: [], abstract: 'config')->set('package.resolved', 'text'); + $this->app->make(parameters: [], abstract: 'config')->set('package.made', 'text'); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/UnpackedCallbackWriteProvider.php b/tests/PHPStan/Fixtures/Config/Writes/UnpackedCallbackWriteProvider.php new file mode 100644 index 000000000..71dfd7354 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/UnpackedCallbackWriteProvider.php @@ -0,0 +1,21 @@ +configureUsing(static function (Repository $config): void { + $config->set(...['package.key', 'value']); + }); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/UnrecognizedFlowProvider.php b/tests/PHPStan/Fixtures/Config/Writes/UnrecognizedFlowProvider.php new file mode 100644 index 000000000..6614b436e --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/UnrecognizedFlowProvider.php @@ -0,0 +1,29 @@ +configureUsing(static function (): void { + app(LoggerInterface::class)->info('configured'); + }); + + if ($this->app->runningInConsole()) { + $this->app->make('events')->dispatch('configured'); + } + + // The tracker records this write when it runs; the static pass does not follow locals. + $config = $this->app->make('config'); + $config->set($this->app->environment(), true); + } +} diff --git a/tests/PHPStan/Fixtures/Config/application/listed.php b/tests/PHPStan/Fixtures/Config/application/listed.php new file mode 100644 index 000000000..c119f393d --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/application/listed.php @@ -0,0 +1,5 @@ + 'app', + 'app' => 'app', +]; diff --git a/tests/PHPStan/Fixtures/Config/application/open-recursive.php b/tests/PHPStan/Fixtures/Config/application/open-recursive.php new file mode 100644 index 000000000..11d6f9702 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/application/open-recursive.php @@ -0,0 +1,8 @@ + 'app', +]; diff --git a/tests/PHPStan/Fixtures/Config/application/open.php b/tests/PHPStan/Fixtures/Config/application/open.php new file mode 100644 index 000000000..11d6f9702 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/application/open.php @@ -0,0 +1,8 @@ + 'app', +]; diff --git a/tests/PHPStan/Fixtures/Config/application/recursive.php b/tests/PHPStan/Fixtures/Config/application/recursive.php new file mode 100644 index 000000000..2b6487e3e --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/application/recursive.php @@ -0,0 +1,10 @@ + [ + 'shared' => 'app', + 'app' => 'app', + ], +]; diff --git a/tests/PHPStan/Fixtures/Config/application/replaced.php b/tests/PHPStan/Fixtures/Config/application/replaced.php new file mode 100644 index 000000000..c119f393d --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/application/replaced.php @@ -0,0 +1,5 @@ + 'conditional', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/listed.php b/tests/PHPStan/Fixtures/Config/config/listed.php new file mode 100644 index 000000000..36e900f4b --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/listed.php @@ -0,0 +1,5 @@ + 'package', + 'package' => 'package', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/open-recursive.php b/tests/PHPStan/Fixtures/Config/config/open-recursive.php new file mode 100644 index 000000000..6937423f9 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/open-recursive.php @@ -0,0 +1,8 @@ + 'package', + 'enabled' => false, +]; diff --git a/tests/PHPStan/Fixtures/Config/config/open.php b/tests/PHPStan/Fixtures/Config/config/open.php new file mode 100644 index 000000000..6937423f9 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/open.php @@ -0,0 +1,8 @@ + 'package', + 'enabled' => false, +]; diff --git a/tests/PHPStan/Fixtures/Config/config/overlap.php b/tests/PHPStan/Fixtures/Config/config/overlap.php new file mode 100644 index 000000000..3b750525f --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/overlap.php @@ -0,0 +1,7 @@ + 'package', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/package.php b/tests/PHPStan/Fixtures/Config/config/package.php new file mode 100644 index 000000000..8da54e9f4 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/package.php @@ -0,0 +1,12 @@ + 'package', + 'stores' => [ + 'declared' => ['driver' => 'array'], + ], + 'env' => env('PACKAGE_ENV'), + 'url' => config('app.url'), +]; diff --git a/tests/PHPStan/Fixtures/Config/config/partial.php b/tests/PHPStan/Fixtures/Config/config/partial.php new file mode 100644 index 000000000..b2f7f5674 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/partial.php @@ -0,0 +1,7 @@ + 'value', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/property.php b/tests/PHPStan/Fixtures/Config/config/property.php new file mode 100644 index 000000000..674447f76 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/property.php @@ -0,0 +1,7 @@ + 'property', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/published.php b/tests/PHPStan/Fixtures/Config/config/published.php new file mode 100644 index 000000000..146180553 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/published.php @@ -0,0 +1,7 @@ + env('PUBLISHED_ENV'), +]; diff --git a/tests/PHPStan/Fixtures/Config/config/recursive.php b/tests/PHPStan/Fixtures/Config/config/recursive.php new file mode 100644 index 000000000..f11c34c38 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/recursive.php @@ -0,0 +1,10 @@ + [ + 'shared' => 'package', + 'package' => 'package', + ], +]; diff --git a/tests/PHPStan/Fixtures/Config/config/replaced.php b/tests/PHPStan/Fixtures/Config/config/replaced.php new file mode 100644 index 000000000..36e900f4b --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/replaced.php @@ -0,0 +1,5 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/config-helper-function.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-repository-method.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-reproduction.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-facade-collection-method.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-repository-method-l12-20.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-provider-sources.php'); + } + + public function testConfigFilesAreFileDependencies(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $file = __DIR__ . '/data/config-file-dependency.php'; + + // The second run hits the parsed config cache and must still declare the files. + foreach ([1, 2] as $run) { + $collector->startFile($file); + self::processFile($file, static function (): void { + }); + $dependencies = $collector->finishFile(); + + $dependencyFiles = []; + + foreach ($dependencies['values'] as [$extensionClass, $key]) { + $this->assertSame(FileResultCacheValueExtension::class, $extensionClass); + $dependencyFiles[] = $key; + } + + // Every config directory's candidate files, and the framework file merged beneath auth.php. + $fileHelper = self::getContainer()->getByType(FileHelper::class); + $expected = [$fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath() . '/auth.php')]; + + foreach (self::getContainer()->getByType(ConfigSourceResolver::class)->getConfigDirectories() as $configPath) { + foreach (['auth', 'auth/defaults', 'test', 'test/foo', 'missing', 'missing/foo'] as $name) { + $expected[] = $fileHelper->normalizePath($configPath . '/' . $name . '.php'); + } + } + + sort($expected); + sort($dependencyFiles); + + $this->assertSame($expected, $dependencyFiles, sprintf('Run %d', $run)); + } + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/config-with-config-paths.neon']; + } +} diff --git a/tests/PHPStan/Type/ConfigFileResolutionTest.php b/tests/PHPStan/Type/ConfigFileResolutionTest.php new file mode 100644 index 000000000..c0f801e85 --- /dev/null +++ b/tests/PHPStan/Type/ConfigFileResolutionTest.php @@ -0,0 +1,82 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/config-file-resolution.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-file-scope.php'); + } + + public function testEveryFileThatCanHoldAKeyIsAFileDependency(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $file = __DIR__ . '/data/config-file-resolution.php'; + + $collector->startFile($file); + self::processFile($file, static function (): void { + }); + $dependencyFiles = array_column($collector->finishFile()['values'], 1); + + $fileHelper = self::getContainer()->getByType(FileHelper::class); + + // config('nested.rcnest.v') can come from any prefix of the key in any config directory + foreach (['config', 'modules/a/config', 'modules/b/config', 'literal[1]/config'] as $configDirectory) { + foreach (['nested.php', 'nested/rcnest.php', 'nested/rcnest/v.php'] as $configFile) { + $this->assertContains( + $fileHelper->normalizePath(__DIR__ . '/data/config-file-resolution/' . $configDirectory . '/' . $configFile), + $dependencyFiles, + ); + } + } + + foreach ($dependencyFiles as $dependencyFile) { + $this->assertStringNotContainsString('*', $dependencyFile); + } + } + + public function testConfigFilesReadByAConfigFileAreFileDependencies(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $file = __DIR__ . '/data/config-file-scope-dependency.php'; + $leaf = self::getContainer()->getByType(FileHelper::class) + ->normalizePath(__DIR__ . '/data/config-file-resolution/config/scopeleaf.php'); + + // The second run reads the parsed config cache and must still declare the file the config file reads. + foreach ([1, 2] as $run) { + $collector->startFile($file); + self::processFile($file, static function (): void { + }); + + $this->assertContains($leaf, array_column($collector->finishFile()['values'], 1), sprintf('Run %d', $run)); + } + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/config-file-resolution.neon']; + } +} diff --git a/tests/PHPStan/Type/data/config-facade-collection-method.php b/tests/PHPStan/Type/data/config-facade-collection-method.php new file mode 100644 index 000000000..0ce1a28c8 --- /dev/null +++ b/tests/PHPStan/Type/data/config-facade-collection-method.php @@ -0,0 +1,15 @@ +", Config::collection('auth.defaults')); + + assertType('Hypervel\Support\Collection<0|1|2, 1|2|3>', Config::collection('test.bar')); + assertType('*ERROR*', Config::collection('test.foo')); +} diff --git a/tests/PHPStan/Type/data/config-file-dependency.php b/tests/PHPStan/Type/data/config-file-dependency.php new file mode 100644 index 000000000..71305d6dd --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-dependency.php @@ -0,0 +1,11 @@ +get('test.foo'); + config('missing.foo'); + config($key); +} diff --git a/tests/PHPStan/Type/data/config-file-resolution.neon b/tests/PHPStan/Type/data/config-file-resolution.neon new file mode 100644 index 000000000..e99de7494 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution.neon @@ -0,0 +1,8 @@ +includes: + - ../../phpstan-tests.neon +parameters: + checkConfigTypes: true + configDirectories: + - %rootDir%/../../../tests/PHPStan/Type/data/config-file-resolution/config + - %rootDir%/../../../tests/PHPStan/Type/data/config-file-resolution/modules/*/config + - '%rootDir%/../../../tests/PHPStan/Type/data/config-file-resolution/literal[1]/config' diff --git a/tests/PHPStan/Type/data/config-file-resolution.php b/tests/PHPStan/Type/data/config-file-resolution.php new file mode 100644 index 000000000..0a69df97f --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution.php @@ -0,0 +1,43 @@ + 'from app.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/nested.php b/tests/PHPStan/Type/data/config-file-resolution/config/nested.php new file mode 100644 index 000000000..bf7fa2f17 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/nested.php @@ -0,0 +1,7 @@ + 'from nested.php', + 'rcnest' => ['v' => 'from nested.php, replaced by nested/rcnest.php'], + 'deep' => ['v' => 'from nested.php'], +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/nested/deep/leaf.php b/tests/PHPStan/Type/data/config-file-resolution/config/nested/deep/leaf.php new file mode 100644 index 000000000..ad0d38ada --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/nested/deep/leaf.php @@ -0,0 +1,5 @@ + 'from nested/deep/leaf.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/nested/rcnest.php b/tests/PHPStan/Type/data/config-file-resolution/config/nested/rcnest.php new file mode 100644 index 000000000..050b5955f --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/nested/rcnest.php @@ -0,0 +1,5 @@ + 'from nested/rcnest.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/nested/typed.php b/tests/PHPStan/Type/data/config-file-resolution/config/nested/typed.php new file mode 100644 index 000000000..389411fac --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/nested/typed.php @@ -0,0 +1,6 @@ + 1, +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/rcdup.php b/tests/PHPStan/Type/data/config-file-resolution/config/rcdup.php new file mode 100644 index 000000000..02ef0ced9 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/rcdup.php @@ -0,0 +1,5 @@ + 'from rcdup.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeclosure.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeclosure.php new file mode 100644 index 000000000..2ecc80f39 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeclosure.php @@ -0,0 +1,9 @@ + $normalize('A'), +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeconditional.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeconditional.php new file mode 100644 index 000000000..3d65dee21 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeconditional.php @@ -0,0 +1,7 @@ + 'cli']; +} + +return ['v' => 'web']; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopedoc.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopedoc.php new file mode 100644 index 000000000..8fbb92c1a --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopedoc.php @@ -0,0 +1,8 @@ +} */ +return [ + 'items' => new AliasedCollection(), +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeforward.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeforward.php new file mode 100644 index 000000000..16b9c1905 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeforward.php @@ -0,0 +1,5 @@ + config('scopeleaf.v'), +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeinclude.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeinclude.php new file mode 100644 index 000000000..44a80a8db --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeinclude.php @@ -0,0 +1,7 @@ + $fromInclude, +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeleaf.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeleaf.php new file mode 100644 index 000000000..acee91830 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeleaf.php @@ -0,0 +1,5 @@ + 'leaf', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopereader.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopereader.php new file mode 100644 index 000000000..ea8e64a2a --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopereader.php @@ -0,0 +1,8 @@ + $fromOther, + 'inline' => config('scopeleaf.v'), +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeself.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeself.php new file mode 100644 index 000000000..c10617730 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeself.php @@ -0,0 +1,10 @@ + 'larastan', + 'early' => $name, + 'late' => config('scopeself.name'), + 'callback' => fn () => config('scopeself.name'), +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopevars.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopevars.php new file mode 100644 index 000000000..31fb89dca --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopevars.php @@ -0,0 +1,7 @@ + $hosts, +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/services/stripe.php b/tests/PHPStan/Type/data/config-file-resolution/config/services/stripe.php new file mode 100644 index 000000000..66d2b659f --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/services/stripe.php @@ -0,0 +1,5 @@ + 'from services/stripe.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/webapp.php b/tests/PHPStan/Type/data/config-file-resolution/config/webapp.php new file mode 100644 index 000000000..9c40078b7 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/webapp.php @@ -0,0 +1,5 @@ + 'from webapp.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/xrcapp.php b/tests/PHPStan/Type/data/config-file-resolution/config/xrcapp.php new file mode 100644 index 000000000..0d1531e90 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/xrcapp.php @@ -0,0 +1,5 @@ + 'from xrcapp.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/xrcdup.php b/tests/PHPStan/Type/data/config-file-resolution/config/xrcdup.php new file mode 100644 index 000000000..0402904e5 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/xrcdup.php @@ -0,0 +1,5 @@ + 'from xrcdup.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/literal[1]/config/rcliteral.php b/tests/PHPStan/Type/data/config-file-resolution/literal[1]/config/rcliteral.php new file mode 100644 index 000000000..c32a760e2 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/literal[1]/config/rcliteral.php @@ -0,0 +1,5 @@ + 'from literal[1]/config/rcliteral.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/modules/a/config/rcmoda.php b/tests/PHPStan/Type/data/config-file-resolution/modules/a/config/rcmoda.php new file mode 100644 index 000000000..520e53976 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/modules/a/config/rcmoda.php @@ -0,0 +1,5 @@ + 'from modules/a/config/rcmoda.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/modules/b/config/rcmodb.php b/tests/PHPStan/Type/data/config-file-resolution/modules/b/config/rcmodb.php new file mode 100644 index 000000000..032cf58bd --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/modules/b/config/rcmodb.php @@ -0,0 +1,5 @@ + 'from modules/b/config/rcmodb.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-scope-dependency.php b/tests/PHPStan/Type/data/config-file-scope-dependency.php new file mode 100644 index 000000000..df625049d --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-scope-dependency.php @@ -0,0 +1,9 @@ +', config('scopevars.hosts')); +} + +function callerWithoutVariable(): void +{ + assertType('non-empty-list', config('scopevars.hosts')); +} + +function phpDocUsesConfigFileImports(): void +{ + assertType('Hypervel\Support\Collection', config('scopedoc.items')); +} + +function returnInsideClosureIsNotTheConfig(): void +{ + assertType('lowercase-string', config('scopeclosure.v')); +} + +function conditionalReturnHasNoSingleValue(): void +{ + assertType('mixed', config('scopeconditional.v')); +} + +function variableFromIncludeIsUnknown(): void +{ + assertType('mixed', config('scopeinclude.v')); +} + +// Unlike upstream, a config file reads the configuration as it stands while the file loads: +// a file's own value is set only after it returns, and later files are not loaded yet. +function selfReference(): void +{ + assertType("'larastan'", config('scopeself.name')); + assertType('null', config('scopeself.early')); + assertType('null', config('scopeself.late')); +} + +// A closure in a config file may run while the file loads or after, so its reads keep the declared type. +function closureInConfigFile(): void +{ + assertType('Closure(): mixed', config('scopeself.callback')); +} + +function readsOtherConfigFile(): void +{ + assertType("'leaf'", config('scopereader.v')); + assertType("'leaf'", config('scopereader.inline')); +} + +function readsLaterConfigFile(): void +{ + assertType('null', config('scopeforward.v')); +} diff --git a/tests/PHPStan/Type/data/config-helper-function.php b/tests/PHPStan/Type/data/config-helper-function.php new file mode 100644 index 000000000..863cd4de3 --- /dev/null +++ b/tests/PHPStan/Type/data/config-helper-function.php @@ -0,0 +1,27 @@ + 1)); + assertType('non-empty-array', config('foo', ['callback' => fn (): int => 1])); + assertType('string', config('foo', 'strlen')); +} diff --git a/tests/PHPStan/Type/data/config-provider-sources.php b/tests/PHPStan/Type/data/config-provider-sources.php new file mode 100644 index 000000000..8f3719abd --- /dev/null +++ b/tests/PHPStan/Type/data/config-provider-sources.php @@ -0,0 +1,56 @@ +string('package.key')); + assertType("'app'", Config::get('merged.shared')); +} diff --git a/tests/PHPStan/Type/data/config-repository-method-l12-20.php b/tests/PHPStan/Type/data/config-repository-method-l12-20.php new file mode 100644 index 000000000..c236504df --- /dev/null +++ b/tests/PHPStan/Type/data/config-repository-method-l12-20.php @@ -0,0 +1,15 @@ +collection('foo')); + assertType("Hypervel\Support\Collection<'guard'|'passwords', 'users'|'web'>", $config->collection('auth.defaults')); + + assertType('Hypervel\Support\Collection<0|1|2, 1|2|3>', $config->collection('test.bar')); + assertType('*ERROR*', $config->collection('test.foo')); +} diff --git a/tests/PHPStan/Type/data/config-repository-method.php b/tests/PHPStan/Type/data/config-repository-method.php new file mode 100644 index 000000000..f5f6808db --- /dev/null +++ b/tests/PHPStan/Type/data/config-repository-method.php @@ -0,0 +1,58 @@ +get('foo')); + assertType('array{guard: \'web\', passwords: \'users\'}', $config->get('auth.defaults')); + + assertType('array{1, 2, 3}', $config->get('test.bar')); + assertType("'bar'", $config->get('test.foo')); + assertType("'bar'", $config->get('test.foo', 5)); +} + +class CustomValue +{ +} + +class CustomRepository extends Repository +{ + public function get(array|string $key, mixed $default = null): CustomValue + { + return new CustomValue; + } +} + +class CustomArrayRepository extends Repository +{ + public function array(string $key, mixed $default = null): array + { + return []; + } +} + +class CustomConfig extends Config +{ + public static function get(array|string $key, mixed $default = null): CustomValue + { + return new CustomValue; + } +} + +// A custom implementation keeps its declared type, and so do the inherited getters that +// read through it. Facade calls forward to the bound repository, not the facade's methods. +function customImplementations(CustomRepository $config, CustomArrayRepository $arrayConfig): void +{ + assertType('ConfigRepositoryMethod\CustomValue', $config->get('test.foo')); + assertType('string', $config->string('test.foo')); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $arrayConfig->collection('test.bar')); + assertType("'bar'", $arrayConfig->string('test.foo')); + assertType('ConfigRepositoryMethod\CustomValue', CustomConfig::get('test.foo')); + assertType("'bar'", CustomConfig::string('test.foo')); +} diff --git a/tests/PHPStan/Type/data/config-reproduction.php b/tests/PHPStan/Type/data/config-reproduction.php new file mode 100644 index 000000000..7e2a4418c --- /dev/null +++ b/tests/PHPStan/Type/data/config-reproduction.php @@ -0,0 +1,11 @@ + $providerClass + */ + #[DataProvider('providerWrites')] + public function testUnknownWrites(string $providerClass, bool $unknownWrites): void + { + $this->assertSame($unknownWrites, $this->createResolver($providerClass)->hasUnknownWrites()); + } + + /** + * @return array, bool}> + */ + public static function providerWrites(): array + { + return [ + 'dynamic key in a callback' => [DynamicCallbackWriteProvider::class, true], + 'dynamic key written directly' => [DynamicDirectWriteProvider::class, true], + 'callback repository passed on' => [EscapingCallbackProvider::class, true], + 'unpacked arguments in a callback' => [UnpackedCallbackWriteProvider::class, true], + 'callable created in a callback' => [CallbackCallableProvider::class, true], + 'callable created from a merge method' => [MergeCallableProvider::class, true], + 'dynamic merge key' => [DynamicMergeKeyProvider::class, true], + 'unrelated container use and a local repository' => [UnrecognizedFlowProvider::class, false], + ]; + } + + /** + * Named arguments bind by name, creating a callable outside a callback writes nothing, + * and a callable created from a read method only reads. + * + * @param class-string $providerClass + * @param list $writtenKeys + */ + #[DataProvider('providerWrittenKeys')] + public function testWrittenKeys(string $providerClass, array $writtenKeys): void + { + $resolver = $this->createResolver($providerClass); + + $this->assertFalse($resolver->hasUnknownWrites()); + $this->assertSame($writtenKeys, $resolver->getWrittenKeys()); + } + + /** + * @return array, list}> + */ + public static function providerWrittenKeys(): array + { + return [ + 'reordered arguments' => [ReorderedArgumentsProvider::class, ['package.callback', 'package.offset', 'package.helper', 'package.resolved', 'package.made']], + 'created callables' => [CallableCreationProvider::class, []], + 'read callable in a callback' => [ReadCallableCallbackProvider::class, ['package.copy']], + ]; + } + + public function testCreatingCallableFromHelperDoesNotMakeItsMergeDefinite(): void + { + $merges = $this->createResolver(MergeCallableProvider::class)->getMerges(); + + $this->assertCount(1, $merges); + $this->assertSame('partial', $merges[0]['key']); + $this->assertFalse($merges[0]['definite']); + } + + public function testDynamicMergeKeyKeepsItsPackageConfigFile(): void + { + $resolver = $this->createResolver(DynamicMergeKeyProvider::class); + + $this->assertSame([], $resolver->getMerges()); + $this->assertTrue($resolver->isPackageConfigFile(dirname(__DIR__, 2) . '/Fixtures/Config/config/package.php')); + } + + /** + * Create a resolver whose application has loaded only the given provider. + * + * @param class-string $providerClass + */ + private function createResolver(string $providerClass): ConfigSourceResolver + { + $app = m::mock(Application::class); + $provider = new $providerClass($app); + + $app->shouldReceive('getProviders')->with(ServiceProvider::class)->andReturn([$providerClass => $provider]); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn(new ConfigMutationTracker); + $app->shouldReceive('configPath')->andReturn(__DIR__ . '/missing-config'); + + $container = new Container; + $container->instance(Application::class, $app); + + $phpstan = PHPStanTestCase::getContainer(); + $resolver = new ConfigSourceResolver( + $phpstan->getByType(FileHelper::class), + $phpstan->getService('currentPhpVersionSimpleDirectParser'), + PHPStanTestCase::createReflectionProvider(), + [], + ); + $resolver->setContainer($container); + + return $resolver; + } +} diff --git a/tests/PHPStan/application/config/reproduction.php b/tests/PHPStan/application/config/reproduction.php new file mode 100644 index 000000000..53bff99c3 --- /dev/null +++ b/tests/PHPStan/application/config/reproduction.php @@ -0,0 +1,8 @@ + [ + 'key' => env('SOME_ENV'), + ], +]; diff --git a/tests/PHPStan/application/config/test.php b/tests/PHPStan/application/config/test.php new file mode 100644 index 000000000..e1acc6222 --- /dev/null +++ b/tests/PHPStan/application/config/test.php @@ -0,0 +1,7 @@ + 'bar', + 'bar' => [1, 2, 3], + 'default' => env('CACHE_DRIVER', 'file'), +]; diff --git a/tests/PHPStan/phpunit-bootstrap.php b/tests/PHPStan/phpunit-bootstrap.php index 68f949978..7bf90e11d 100644 --- a/tests/PHPStan/phpunit-bootstrap.php +++ b/tests/PHPStan/phpunit-bootstrap.php @@ -4,9 +4,12 @@ use Composer\Autoload\ClassLoader; use Hypervel\Console\Application; +use Hypervel\Container\Container; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Console\ClosureCommand; use Hypervel\Testbench\Bootstrapper; +use Hypervel\Tests\PHPStan\Fixtures\Config\ConfigFixtureServiceProvider; +use Hypervel\Tests\PHPStan\Fixtures\Config\OverlappingConfigServiceProvider; use Hypervel\Tests\PHPStan\Fixtures\Console\WithArgumentCommand; use Hypervel\Tests\PHPStan\Fixtures\Console\WithoutArgumentCommand; @@ -32,6 +35,7 @@ // Fixture config files replace the skeleton's files of the same name; the rest stay. $filesystem->copyDirectory(__DIR__ . '/application/config', BASE_PATH . '/config'); +$filesystem->copyDirectory(__DIR__ . '/Fixtures/Config/application', BASE_PATH . '/config'); // Registered only as a provider would, in this order, which the command lookup tests rely on. Application::starting(static function (Application $artisan): void { @@ -46,3 +50,7 @@ // Boot the analyzed application before any test, so its error and exception handlers // stay out of PHPUnit's per-test handler checks. require_once __DIR__ . '/../../src/phpstan/bootstrap.php'; + +// Package configuration sources, which the config analysis reads from the loaded providers. +Container::getInstance()->register(ConfigFixtureServiceProvider::class); +Container::getInstance()->register(OverlappingConfigServiceProvider::class); diff --git a/types/Foundation/Helpers.php b/types/Foundation/Helpers.php index 7ec49c969..bf30a02c5 100644 --- a/types/Foundation/Helpers.php +++ b/types/Foundation/Helpers.php @@ -25,7 +25,10 @@ assertType('Hypervel\Config\Repository', config()); assertType('null', config(['foo' => 'bar'])); -assertType('mixed', config('foo')); +// Unlike upstream, the PHPStan extension types an absent key as its null default. +assertType('null', config('foo')); +/** @var string $key */ +assertType('mixed', config($key)); assertType('Hypervel\Log\Context\Repository', context()); assertType('Hypervel\Log\Context\Repository', context(['foo' => 'bar'])); From 1211cccf05308730e73ddd126d457d912f378b01 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:59:49 +0000 Subject: [PATCH 054/303] feat: diagnose incompatible typed configuration getters Report configuration values that cannot satisfy a typed getter or collection lookup. Share method eligibility with return-type inference so custom implementations retain their own contracts. Keep unknown values and unpacked calls outside definitive diagnostics, and cover facade, repository, helper, named-argument and nullable-default behavior. Validation: targeted rule fixtures and the complete 377-test analyzer suite pass; main and type-fixture analyses pass. --- .../src/Rules/ConfigCollectionRule.php | 72 +++++++++++++++ src/phpstan/src/Rules/ConfigTypeRule.php | 89 +++++++++++++++++++ .../Rules/ConfigCollectionRuleTest.php | 48 ++++++++++ tests/PHPStan/Rules/ConfigTypeRuleTest.php | 37 ++++++++ .../Rules/data/config-collection-rule.php | 32 +++++++ tests/PHPStan/Rules/data/config-type-rule.php | 59 ++++++++++++ 6 files changed, 337 insertions(+) create mode 100644 src/phpstan/src/Rules/ConfigCollectionRule.php create mode 100644 src/phpstan/src/Rules/ConfigTypeRule.php create mode 100644 tests/PHPStan/Rules/ConfigCollectionRuleTest.php create mode 100644 tests/PHPStan/Rules/ConfigTypeRuleTest.php create mode 100644 tests/PHPStan/Rules/data/config-collection-rule.php create mode 100644 tests/PHPStan/Rules/data/config-type-rule.php diff --git a/src/phpstan/src/Rules/ConfigCollectionRule.php b/src/phpstan/src/Rules/ConfigCollectionRule.php new file mode 100644 index 000000000..8591a97f5 --- /dev/null +++ b/src/phpstan/src/Rules/ConfigCollectionRule.php @@ -0,0 +1,72 @@ +collection(), + * are checked too. + * + * @implements Rule + */ +#[Conditional(attribute: new AutowiredService, if: '%checkConfigTypes%')] +final class ConfigCollectionRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct(private ConfigHelper $configHelper) + { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report collection() calls for config values that are never arrays. + * + * @param CallLike $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if ($this->configHelper->getMethodName($node, $scope) !== 'collection') { + return []; + } + + $key = $node->getArg('key', 0); + $valueType = $this->configHelper->getValueType($node, $scope); + + if ($key === null || $valueType === null || ! $valueType->isArray()->no()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Config key %s is not an array.', $scope->getType($key->value)->describe(VerbosityLevel::value()))) + ->identifier('hypervel.configCollection') + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/ConfigTypeRule.php b/src/phpstan/src/Rules/ConfigTypeRule.php new file mode 100644 index 000000000..129698440 --- /dev/null +++ b/src/phpstan/src/Rules/ConfigTypeRule.php @@ -0,0 +1,89 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkConfigTypes%')] +final class ConfigTypeRule implements Rule +{ + /** + * The value types the typed getters require, as the error message names them. + */ + private const array GETTER_TYPES = [ + 'string' => 'a string', + 'integer' => 'an integer', + 'float' => 'a float', + 'boolean' => 'a boolean', + 'array' => 'an array', + ]; + + /** + * Create a new rule instance. + */ + public function __construct(private ConfigHelper $configHelper) + { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report typed getter calls for config values that never have the getter's type. + * + * @param CallLike $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $methodName = $this->configHelper->getMethodName($node, $scope); + + if (! isset(self::GETTER_TYPES[$methodName])) { + return []; + } + + $key = $node->getArg('key', 0); + $valueType = $this->configHelper->getValueType($node, $scope); + + if ($key === null || $valueType === null || ! $this->configHelper->getGetterType($methodName)->isSuperTypeOf($valueType)->no()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Config key %s is not %s.', + $scope->getType($key->value)->describe(VerbosityLevel::value()), + self::GETTER_TYPES[$methodName], + )) + ->identifier('hypervel.configType') + ->build(), + ]; + } +} diff --git a/tests/PHPStan/Rules/ConfigCollectionRuleTest.php b/tests/PHPStan/Rules/ConfigCollectionRuleTest.php new file mode 100644 index 000000000..6834770f3 --- /dev/null +++ b/tests/PHPStan/Rules/ConfigCollectionRuleTest.php @@ -0,0 +1,48 @@ + */ +class ConfigCollectionRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(ConfigCollectionRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/config-collection-rule.php'], [ + [ + "Config key 'test.foo' is not an array.", + 12, + ], + [ + "Config key 'test.foo' is not an array.", + 19, + ], + [ + "Config key 'test.foo' is not an array.", + 20, + ], + [ + "Config key 'test.missing' is not an array.", + 26, + ], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../Type/data/config-with-config-paths.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/ConfigTypeRuleTest.php b/tests/PHPStan/Rules/ConfigTypeRuleTest.php new file mode 100644 index 000000000..7757b51ac --- /dev/null +++ b/tests/PHPStan/Rules/ConfigTypeRuleTest.php @@ -0,0 +1,37 @@ + */ +class ConfigTypeRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(ConfigTypeRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/config-type-rule.php'], [ + ["Config key 'test.foo' is not an integer.", 14], + ["Config key 'test.foo' is not an array.", 16], + ["Config key 'auth.defaults.guard' is not a float.", 17], + ["Config key 'test.missing' is not a boolean.", 31], + ["Config key 'test.foo' is not an integer.", 40], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../Type/data/config-with-config-paths.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/data/config-collection-rule.php b/tests/PHPStan/Rules/data/config-collection-rule.php new file mode 100644 index 000000000..5d9f97121 --- /dev/null +++ b/tests/PHPStan/Rules/data/config-collection-rule.php @@ -0,0 +1,32 @@ +collection('test.foo'); + config()->collection('test.foo'); + } + + public function presence(): void + { + // An absent key gives the default, which array() rejects + Config::collection('test.missing'); + Config::collection('test.missing', []); + + // Framework code adds log channels, so an undeclared one is unknown + Config::collection('logging.channels.undeclared'); + } +} diff --git a/tests/PHPStan/Rules/data/config-type-rule.php b/tests/PHPStan/Rules/data/config-type-rule.php new file mode 100644 index 000000000..451033fba --- /dev/null +++ b/tests/PHPStan/Rules/data/config-type-rule.php @@ -0,0 +1,59 @@ +array('test.foo'); + config()->float('auth.defaults.guard'); + } + + public function uncertainValues(): void + { + // An env() value may be a string + Config::string('test.default'); + + // Framework code adds log channels, so an undeclared one is unknown + Config::string('logging.channels.undeclared'); + } + + public function absentKeys(): void + { + Config::boolean('test.missing'); + Config::boolean('test.missing', false); + } + + /** + * @param array{string} $arguments + */ + public function argumentForms(array $arguments): void + { + Config::integer(default: 1, key: 'test.foo'); + + // Unpacked arguments are not resolved + Config::integer(...$arguments); + } + + public function customGetter(LenientRepository $config): void + { + // A custom getter keeps its own semantics + $config->integer('test.foo'); + } +} + +class LenientRepository extends Repository +{ + public function integer(string $key, mixed $default = null): int + { + return (int) $this->get($key, $default); + } +} From af50ef310a4f64145115884edc64ad42b26a14e8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:59:49 +0000 Subject: [PATCH 055/303] feat: enforce configuration boundaries for environment readers Check the env helpers, app_id and Env static readers by resolved identity. Permit actual application, framework, package and publishable config sources while rejecting sibling directories that only share a prefix. Preserve trait diagnostics and editor-file mapping, and keep the rule enabled for framework analysis. Disable authoring policy only in the dedicated source-type assertion harness. Validation: environment-policy rule fixtures, main and type-fixture PHPStan analyses, and the complete analyzer suite pass. --- phpstan.types.neon.dist | 1 + src/phpstan/extension.neon | 1 + .../Rules/NoEnvCallsOutsideOfConfigRule.php | 180 ++++++++++++++++++ .../NoEnvCallsOutsideOfConfigRuleTest.php | 176 +++++++++++++++++ tests/PHPStan/Rules/data/EnvUsageClass.php | 15 ++ .../Rules/data/EnvUsageClassOverride.php | 20 ++ tests/PHPStan/Rules/data/EnvUsageTrait.php | 20 ++ .../Rules/data/config-sibling/env-calls.php | 6 + tests/PHPStan/Rules/data/config/env-calls.php | 6 + tests/PHPStan/Rules/data/env-calls.neon | 6 + tests/PHPStan/Rules/data/env-calls.php | 12 ++ tests/PHPStan/Rules/data/env-readers.php | 21 ++ .../data/module/bar/config/env-calls.php | 12 ++ .../data/module/foo/config/env-calls.php | 12 ++ 14 files changed, 488 insertions(+) create mode 100644 src/phpstan/src/Rules/NoEnvCallsOutsideOfConfigRule.php create mode 100644 tests/PHPStan/Rules/NoEnvCallsOutsideOfConfigRuleTest.php create mode 100644 tests/PHPStan/Rules/data/EnvUsageClass.php create mode 100644 tests/PHPStan/Rules/data/EnvUsageClassOverride.php create mode 100644 tests/PHPStan/Rules/data/EnvUsageTrait.php create mode 100644 tests/PHPStan/Rules/data/config-sibling/env-calls.php create mode 100644 tests/PHPStan/Rules/data/config/env-calls.php create mode 100644 tests/PHPStan/Rules/data/env-calls.neon create mode 100644 tests/PHPStan/Rules/data/env-calls.php create mode 100644 tests/PHPStan/Rules/data/env-readers.php create mode 100644 tests/PHPStan/Rules/data/module/bar/config/env-calls.php create mode 100644 tests/PHPStan/Rules/data/module/foo/config/env-calls.php diff --git a/phpstan.types.neon.dist b/phpstan.types.neon.dist index 43ec38305..feba6c70b 100644 --- a/phpstan.types.neon.dist +++ b/phpstan.types.neon.dist @@ -15,3 +15,4 @@ parameters: noPipeSeparatedValidationRules: false checkModelMethodVisibility: false noUnnecessaryEnumerableToArrayCalls: false + noEnvCallsOutsideOfConfig: false diff --git a/src/phpstan/extension.neon b/src/phpstan/extension.neon index 33296648d..2d659e4ef 100644 --- a/src/phpstan/extension.neon +++ b/src/phpstan/extension.neon @@ -29,6 +29,7 @@ parameters: checkMissingTranslations: true checkModelAppends: true checkModelMethodVisibility: true + # generalizeEnvReturnType is omitted: env() declares its return type, which a default does not decide. checkConfigTypes: true checkAuthCallsWhenInRequestScope: false parseModelCastsMethod: true diff --git a/src/phpstan/src/Rules/NoEnvCallsOutsideOfConfigRule.php b/src/phpstan/src/Rules/NoEnvCallsOutsideOfConfigRule.php new file mode 100644 index 000000000..cd4e2838d --- /dev/null +++ b/src/phpstan/src/Rules/NoEnvCallsOutsideOfConfigRule.php @@ -0,0 +1,180 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noEnvCallsOutsideOfConfig%')] +class NoEnvCallsOutsideOfConfigRule implements Rule +{ + /** + * The global helpers that read the environment, including app_id(), which supplies config defaults. + */ + private const array FUNCTIONS = ['env', 'env_array', 'env_or_fail', 'app_id']; + + /** + * The Env methods that read a variable, lowercased. + */ + private const array METHODS = ['get', 'getarray', 'getorfail']; + + /** + * The single reflection files are PHPStan's normalized `--tmp-file` and + * `--instead-of` paths, which are set in editor mode. + */ + public function __construct( + private ConfigSourceResolver $configSourceResolver, + private FileHelper $fileHelper, + private ReflectionProvider $reflectionProvider, + #[AutowiredParameter] + private ?string $singleReflectionFile = null, + #[AutowiredParameter] + private ?string $singleReflectionInsteadOfFile = null, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report environment reads outside of config files. + * + * @param CallLike $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $reader = $this->getReaderName($node, $scope); + + if ($reader === null || ! $this->isCalledOutsideOfConfig($node, $scope)) { + return []; + } + + // Upstream says env() returns null once the config is cached; it still reads the + // process environment, but the .env file is no longer loaded. + return [ + RuleErrorBuilder::message(sprintf("Called '%s' outside of the config directory. The .env file is not loaded when the configuration is cached, so use 'config' instead.", $reader)) + ->identifier('hypervel.noEnvCallsOutsideOfConfig') + ->line($node->getStartLine()) + ->file($scope->getFile(), $scope->getFileDescription()) + ->build(), + ]; + } + + /** + * Get the name of the env helper or Env reader the call makes, or null when it makes neither. + */ + private function getReaderName(CallLike $call, Scope $scope): ?string + { + if ($call instanceof FuncCall && $call->name instanceof Name) { + $function = strtolower($this->reflectionProvider->resolveFunctionName($call->name, $scope) ?? ''); + + return in_array($function, self::FUNCTIONS, true) ? $function : null; + } + + if (! $call instanceof StaticCall + || ! $call->class instanceof Name + || ! $call->name instanceof Identifier + || ! in_array($call->name->toLowerString(), self::METHODS, true)) { + return null; + } + + $className = $scope->resolveName($call->class); + + if (! $this->reflectionProvider->hasClass($className)) { + return null; + } + + $class = $this->reflectionProvider->getClass($className); + + if (! $class->is(Env::class)) { + return null; + } + + return $class->getName() . '::' . $class->getNativeMethod($call->name->toString())->getName(); + } + + /** + * Determine whether the call is outside of every config directory and package config file. + */ + protected function isCalledOutsideOfConfig(CallLike $call, Scope $scope): bool + { + $file = $this->fileHelper->normalizePath($this->resolveAnalysedFile($scope->getFile())); + + $configDirectories = [ + ...$this->configSourceResolver->getConfigDirectories(), + $this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath()), + $this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigStubPath()), + ]; + + foreach ($configDirectories as $configDirectory) { + // Upstream's bare prefix match lets /app/config permit /app/config-sibling. + if (str_starts_with($file, $configDirectory . DIRECTORY_SEPARATOR)) { + return false; + } + } + + return ! $this->configSourceResolver->isPackageConfigFile($file); + } + + /** + * In editor mode PHPStan analyses the temporary file, so map it back to + * the file it stands in for. + */ + private function resolveAnalysedFile(string $scopeFile): string + { + if ($this->singleReflectionFile === null) { + return $scopeFile; + } + + if ($this->fileHelper->normalizePath($scopeFile) !== $this->singleReflectionFile) { + return $scopeFile; + } + + return $this->singleReflectionInsteadOfFile ?? $scopeFile; + } +} diff --git a/tests/PHPStan/Rules/NoEnvCallsOutsideOfConfigRuleTest.php b/tests/PHPStan/Rules/NoEnvCallsOutsideOfConfigRuleTest.php new file mode 100644 index 000000000..84fe9236b --- /dev/null +++ b/tests/PHPStan/Rules/NoEnvCallsOutsideOfConfigRuleTest.php @@ -0,0 +1,176 @@ + */ +class NoEnvCallsOutsideOfConfigRuleTest extends RuleTestCase +{ + private const string MESSAGE = "Called 'env' outside of the config directory. The .env file is not loaded when the configuration is cached, so use 'config' instead."; + + private const string READER_MESSAGE = "Called '%s' outside of the config directory. The .env file is not loaded when the configuration is cached, so use 'config' instead."; + + private ?string $singleReflectionFile = null; + + private ?string $singleReflectionInsteadOfFile = null; + + // Upstream overrides the config path in setUp(); env-calls.neon sets configDirectories instead. + + protected function getRule(): Rule + { + return new NoEnvCallsOutsideOfConfigRule( + self::getContainer()->getByType(ConfigSourceResolver::class), + $this->getFileHelper(), + $this->createReflectionProvider(), + $this->singleReflectionFile, + $this->singleReflectionInsteadOfFile, + ); + } + + private function setSingleReflectionFiles(?string $file, ?string $insteadOfFile): void + { + $this->singleReflectionFile = $file; + $this->singleReflectionInsteadOfFile = $insteadOfFile; + } + + #[Test] + public function itDoesNotFailForEnvCallsInsideConfigDirectory(): void + { + $this->analyse([__DIR__ . '/data/config/env-calls.php'], []); + } + + #[Test] + public function itDoesNotFailForEnvCallsInsideGlobConfigDirectory(): void + { + $this->analyse([__DIR__ . '/data/module/foo/config/env-calls.php', __DIR__ . '/data/module/bar/config/env-calls.php'], []); + } + + #[Test] + public function itReportsEnvCallsOutsideOfConfigDirectory(): void + { + $this->analyse([__DIR__ . '/data/env-calls.php'], [ + [self::MESSAGE, 7], + [self::MESSAGE, 8], + ]); + } + + #[Test] + public function itReportsEveryEnvReaderOutsideOfConfigDirectory(): void + { + $this->analyse([__DIR__ . '/data/env-readers.php'], [ + [sprintf(self::READER_MESSAGE, 'env_array'), 9], + [sprintf(self::READER_MESSAGE, 'env_or_fail'), 10], + [sprintf(self::READER_MESSAGE, 'app_id'), 11], + [sprintf(self::READER_MESSAGE, 'Hypervel\Support\Env::get'), 12], + [sprintf(self::READER_MESSAGE, 'Hypervel\Support\Env::getArray'), 13], + [sprintf(self::READER_MESSAGE, 'Hypervel\Support\Env::getOrFail'), 14], + [sprintf(self::READER_MESSAGE, 'Hypervel\Support\Env::get'), 17], + [sprintf(self::READER_MESSAGE, 'Hypervel\Testbench\Foundation\Env::get'), 18], + ]); + } + + #[Test] + public function itDoesNotFailForEnvCallsInConfigPublishStubs(): void + { + $this->analyse([__DIR__ . '/../../../src/foundation/config-stubs/app.php'], []); + } + + #[Test] + public function itReportsEnvCallsInDirectoryNamedLikeConfigDirectory(): void + { + $this->analyse([__DIR__ . '/data/config-sibling/env-calls.php'], [ + [self::MESSAGE, 6], + ]); + } + + #[Test] + public function itDoesNotFailForEnvCallsInPackageConfigFiles(): void + { + $this->analyse([ + __DIR__ . '/../Fixtures/Config/config/package.php', + __DIR__ . '/../Fixtures/Config/config/published.php', + ], []); + } + + #[Test] + public function itDoesNotReportTraitFunctionsThatHaveBeenOverridden(): void + { + $this->analyse([ + __DIR__ . '/data/EnvUsageClassOverride.php', + __DIR__ . '/data/EnvUsageTrait.php', + ], []); + } + + #[Test] + public function itReportsEnvCallsInTraitRatherThanClass(): void + { + $actualErrors = $this->gatherAnalyserErrors([ + __DIR__ . '/data/EnvUsageClass.php', + __DIR__ . '/data/EnvUsageTrait.php', + ]); + + $this->assertCount(2, $actualErrors); + $this->assertSame(self::MESSAGE, $actualErrors[0]->getMessage()); + $this->assertSame( + __DIR__ . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'EnvUsageTrait.php (in context of class Hypervel\Tests\PHPStan\Rules\Data\EnvUsageClass)', + $actualErrors[0]->getFile(), + ); + $this->assertSame(17, $actualErrors[0]->getLine()); + + $this->assertSame(self::MESSAGE, $actualErrors[1]->getMessage()); + $this->assertSame( + __DIR__ . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'EnvUsageTrait.php (in context of class Hypervel\Tests\PHPStan\Rules\Data\EnvUsageClass)', + $actualErrors[1]->getFile(), + ); + $this->assertSame(18, $actualErrors[1]->getLine()); + } + + #[Test] + public function itDoesNotReportInEditorModeWhenInsteadOfPointsAtConfigFile(): void + { + $this->setSingleReflectionFiles(__DIR__ . '/data/env-calls.php', __DIR__ . '/data/config/env-calls.php'); + + $this->analyse([__DIR__ . '/data/env-calls.php'], []); + } + + #[Test] + public function itReportsInEditorModeWhenInsteadOfIsOutsideConfig(): void + { + $this->setSingleReflectionFiles(__DIR__ . '/data/env-calls.php', __DIR__ . '/data/some-non-config-file.php'); + + $this->analyse([__DIR__ . '/data/env-calls.php'], [ + [self::MESSAGE, 7], + [self::MESSAGE, 8], + ]); + } + + #[Test] + public function itUsesNormalLogicWhenAnalysedFileDiffersFromTmpFile(): void + { + $this->setSingleReflectionFiles('/some/other/buffer.php', __DIR__ . '/data/config/env-calls.php'); + + $this->analyse([__DIR__ . '/data/env-calls.php'], [ + [self::MESSAGE, 7], + [self::MESSAGE, 8], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/data/env-calls.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/data/EnvUsageClass.php b/tests/PHPStan/Rules/data/EnvUsageClass.php new file mode 100644 index 000000000..24d2dc68c --- /dev/null +++ b/tests/PHPStan/Rules/data/EnvUsageClass.php @@ -0,0 +1,15 @@ +callEnv(); + } +} diff --git a/tests/PHPStan/Rules/data/EnvUsageClassOverride.php b/tests/PHPStan/Rules/data/EnvUsageClassOverride.php new file mode 100644 index 000000000..682182409 --- /dev/null +++ b/tests/PHPStan/Rules/data/EnvUsageClassOverride.php @@ -0,0 +1,20 @@ +callEnv(); + } + + protected function callEnv(): void + { + // + } +} diff --git a/tests/PHPStan/Rules/data/EnvUsageTrait.php b/tests/PHPStan/Rules/data/EnvUsageTrait.php new file mode 100644 index 000000000..b8f5aac85 --- /dev/null +++ b/tests/PHPStan/Rules/data/EnvUsageTrait.php @@ -0,0 +1,20 @@ + Date: Thu, 5 Nov 2026 06:59:50 +0000 Subject: [PATCH 056/303] docs: record configuration analysis and the independent audit boundary Record the settled configuration typing, environment policy, loader behavior and regression requirements. Remove completed work from the remaining-file checklist. Make independent audits of the completed code, remaining design and authoring guidance the next step before further porting or audit-driven implementation. Retain the outstanding shared result-cache and consumer integration work. Validation: reviewed documentation against the completed checkpoint and checked whitespace. --- .../2026-11-04-hypervel-phpstan-checklist.md | 16 +++++----- docs/plans/2026-11-04-hypervel-phpstan.md | 31 +++++++++++++++---- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index 392d444f5..87b1c743c 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -26,7 +26,6 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `ClosureTypes/RelationshipQueryCallbackExtension.php` - copy with the queue rules: `Concerns/InspectsQueuedJobs.php` -- copy: `Internal/{ConfigHelper,RecursionGuard}.php` - omit: `Internal/ComposerHelper.php` (`Support\Composer::vendorPath()` resolves the actual install) - copy: `Methods/{BuilderHelper,FacadesMethodsExtension,HigherOrderCollectionProxyExtension,HigherOrderTapProxyExtension,Macro,MacroMethodsClassReflectionExtension,ManagersMethodsExtension,ModelFactoryMethodsClassReflectionExtension,StorageMethodsClassReflectionExtension}.php` - merge into moved forwarding extensions: `Methods/{EloquentBuilderForwardsCallsExtension,ModelForwardsCallsExtension,RelationForwardsCallsExtension}.php` @@ -34,11 +33,11 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `Properties/*` (17 files, including `Schema/MySqlDataTypeToPhpTypeConverter.php`) - copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection,ReflectionHelper,StaticMethodReflection}.php` - merge into moved scope reflections: `Reflection/ModelScopeMethodReflection.php` -- copy: `ReturnTypes/*` except the ported auth, console, container typing and translation files and the omitted `ContainerArrayAccessDynamicMethodReturnTypeExtension`/`ApplicationMakeDynamicReturnTypeExtension` (32 files, including `Helpers/`) -- copy: `Rules/*` except below and the ported auth request-scope, console, `NoMissingTranslationsRule` and `UnusedViewsRule` rules (23 files, including `Queue/` and `UselessConstructs/`) +- copy: `ReturnTypes/*` except the ported auth, config, console, container typing and translation files and the omitted `ContainerArrayAccessDynamicMethodReturnTypeExtension`/`ApplicationMakeDynamicReturnTypeExtension` (including `Helpers/`) +- copy: `Rules/*` except below and the ported auth request-scope, config, console, `NoEnvCallsOutsideOfConfigRule`, `NoMissingTranslationsRule` and `UnusedViewsRule` rules (including `Queue/` and `UselessConstructs/`) - omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` - copy: `SQL/*` (7 files) -- copy: `Support/{ArrKeyLookup,CollectionHelper,ConfigParser,HigherOrderCollectionProxyHelper}.php` +- copy: `Support/{ArrKeyLookup,CollectionHelper,HigherOrderCollectionProxyHelper}.php` - copy: `Types/*` except the ported `ViewString*` files (13 files) ## New Hypervel source @@ -51,7 +50,8 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## stubs - copy: `common/*` except below, the ported view stubs and `Foundation/Console/Kernel.stub` (its `command()` closure type moved to source; the stub keeps only the empty declaration the `Artisan` mixin needs for stub validation) (75 files) -- merge into `common/Facades.stub` and `common/Helpers.stub` with their topics: the remaining facade and helper entries (only Artisan, View, Route and `view()` are ported; `trans()`/`__()` are omitted, as the stub explains) +- merge into `common/Facades.stub` and `common/Helpers.stub` with their topics: the remaining facade and helper entries (only Artisan, View, Route and `view()` are ported; `trans()`/`__()` are omitted, as the stub explains, and `config()`/`env()` are omitted because their source declares the types) +- omit: `common/Config/Repository.stub` (only upstream's omitted `config()` helper stub references it) - add to source with the routing stubs: upstream `common/Route.stub`'s `methods()` and `middleware()` element types as `Route` PHPDoc in `src/routing`, with a `types/` fixture (the stub keeps only the empty declaration stub validation needs) - omit: `common/Redis/{PredisConnection,PredisFacade}.stub`, `common/Gate.stub` (Hypervel's `Gate::forUser()` and its contract already return `static`) - add to source: upstream `common/MailMessage.stub`'s property shapes (`$view`, `$from`, `$replyTo`, `$attachments`, `$rawAttachments`) as `MailMessage` PHPDoc in `src/notifications`, with a `types/` fixture @@ -61,12 +61,12 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## tests - copy: `bootstrap.php`, `enum-definition.php`, then add the test bootstrap to `tests/PHPStan/phpstan-tests.neon`; omit `laravel-test.sh` -- copy: the remaining `application/` fixture app (69 files: app, config, factories, migrations, schema dump) +- copy: the remaining `application/` fixture app (app, config, factories, migrations, schema dump) - merge into `Integration/IntegrationTest.php`: the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties; copy `Integration/BleedingEdgeIntegrationTest.php` - copy: `Reflection/MacroMethodsClassReflectionExtensionTest.php` -- copy: `Rules/*Test.php` except below and the ported auth, console, view and translation rule tests (21 classes), the remaining `Rules/data/*`, `Rules/Queue/data/*`, `Rules/UselessConstructs/data/*` +- copy: `Rules/*Test.php` except below and the ported auth, config, console, environment, view and translation rule tests, the remaining `Rules/data/*`, `Rules/Queue/data/*`, `Rules/UselessConstructs/data/*` - omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` -- copy: `Type/*Test.php` except below, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php` (10 classes) with the remaining `Type/data/*` (143 files) +- copy: `Type/*Test.php` except below, the ported config tests, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php`, with the remaining `Type/data/*` - merge into `Type/MethodsClassReflectionExtensionTest.php` with the macro port: `macros.php` and `macros-php-81.php` (unconditional; Hypervel requires PHP 8.4) - merge: the remaining `Type/GeneralTypeTest.php` data entries, and its `data/config-with-migrations.neon` configuration once migrations are ported - merge: `Type/{RedisDriverNullTypeTest,RedisDriverPhpRedisTypeTest}.php` and their data into one phpredis test; omit `Type/RedisDriverPredisTypeTest.php` with `redis-driver-predis.*` diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index c523d3c56..48adc52d9 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -15,7 +15,9 @@ Remaining work: - Also completed: view and translation analysis (collectors, the missing-translation and unused-view rules, `view-string` types and stubs, with-method reflection, translation return types) with their tests. Their registration-provider dependencies remain with the shared runtime-input tracking under Result-cache correctness and analysis cost. - Also completed: the authentication flow (guard and user return types, the request-scope rules, the fixture app's auth models and config) with `AuthGuardTypeTest` for configuration-dependent guard typing. - Also completed: console command analysis (argument/option return types, the undefined-input rule, the fixture commands and the `Artisan` facade stub). -- Next: port the checklist's remaining `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. +- Also completed: configuration inference and diagnostics, environment-reader policy and source types, consumer config contracts, and the Foundation/Testbench nested-config loader fix, with their regression coverage. Shared result-cache tracking remains below. +- Next, before further porting: claude-stan and codex-stan each independently re-audit this plan, all checkpointed code and the remaining work for ported behavior that is too loose, incomplete or ineffective: checks with easy bypasses, changes that only satisfy a check, suppressions broader than the intended exception, best-effort inference boundaries presented as guarantees, and choices justified by unverified upstream or codebase precedent. Also review AGENTS.md's static-analysis guidance and propose a canonical package documentation section on intended coding patterns, rules, proper fixes and intentional exceptions, linked from AGENTS.md (instruction-file edits need owner approval). Then hold a consensus round and bring the owner a concise bullet proposal to approve before any audit-driven change or further porting. +- After the audit and owner direction, port the checklist's remaining `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -85,6 +87,8 @@ Omit `checkOctaneCompatibility`, `generalizeEnvReturnType` and `redisDriver` wit | `noLegacyQueueProperties` | `true` | `hypervel.legacyQueueProperty` | | `noPipeSeparatedValidationRules` | `true` | `hypervel.pipeSeparatedValidationRules` | +`ConfigTypeRule` adds `hypervel.configType` under the retained `checkConfigTypes` switch (see Upstream rule disposition). + The behavior and soundness limits of each new check are specified under Hypervel rules; a public name does not authorize speculative detection. Report one actionable diagnostic for the same underlying write rather than duplicating worker/static/config errors. Map upstream-owned identifiers exactly as follows. Core PHPStan offset/argument/type identifiers are unchanged, including when emitted by a ported rule. The two omitted diagnostics have no new identifier. @@ -221,7 +225,7 @@ Port every applicable upstream inference feature, not just the rules in the next | Console commands | Argument/option types and the undefined-input rule read the booted kernel's commands (`$kernel->all()`: `withCommands()` discovery, `command()` closures and providers' `starting()` registrations; a standalone application is the fallback when the kernel cannot list them). Look up the call's receiver, not the enclosing class; exactly `Command` and `ClosureCommand` receivers keep the declaration, because they name every command or unrelated per-instance signatures. Merge each matched command's application definition once at lookup, as `run()` does, so bulk shapes include `command` and the global options regardless of analysis order. A name lookup is the union over matching commands that define it (missing ones throw), else the declaration; option names follow `Input`, so negations resolve to their option and shortcuts are not names. Aliases list a command once. A definitely-null key is a bulk lookup; overridden methods keep their declarations for typing and diagnostics. The rule skips a call whose same-receiver `hasArgument()`/`hasOption()` guard is definitely true or unreachable in scope, because the call never reaches the commands without the name; Components' `GeneratorCommand` and `CreatesMatchingTest` guard `--force` this way. Scope keys the guard as written, so check its positional and `name:` forms built from the getter's value, never the getter's own argument nodes. A receiver union with an alternative that has no known definition (e.g. `FooCommand|ClosureCommand`) keeps the declaration. `@param-closure-this` for `command()` lives on the Kernel contract and Foundation Kernel. | | Views and translations | Retain `view-string` and live view-finder namespaces. Keep translation typing and the adapted missing-key rule. The optional unused-view rule reports absent statically discovered usage, not proof that a view can be deleted. | | Custom PHPDoc types | Port `model-property`, `relation-of`, `builder-of`, `collection-of`, `view-string`, their parsers, templates and applicable tests. Keep these analyzer-only annotations in stubs where adding them to framework source would require this dev package. | -| Dates and environment | Keep native/source `CarbonInterface` boundaries for configurable dates and exact immutable types for framework-owned values. Do not infer configurable dates from `get_class(now())`, or assume an `env()` default dictates the supplied environment value's type. | +| Dates and environment | Keep native/source `CarbonInterface` boundaries for configurable dates and exact immutable types for framework-owned values. Do not infer configurable dates from `get_class(now())`, or assume an `env()` default dictates the supplied environment value's type. `env()` and `Env::get()` declare `bool|string|TDefault|null` in source PHPDoc (`TDefault` from a value or `Closure` default; native signatures unchanged), matching `Env::getOption()` and `value()`; no stub or return-type extension. | Do not copy `ContractsMethodsExtension`, container ArrayAccess/dynamic-property support, Lumen paths, deferred-provider machinery, or unsupported Laravel integrations. Remove compatibility branches for old Laravel releases, but first merge applicable newer-version stub refinements into the single supported Hypervel surface. Deleting version directories without examining their overrides would lose features. @@ -233,7 +237,7 @@ Preserve upstream's Request and Optional `universalObjectCratesClasses` entries: ### Static configuration -Port `ConfigParser` in full, then adapt its discovery and merging. Boot supplies loaded provider identities and application paths, not a registry of package config files. `mergeConfigFrom()` records an opaque replay closure in `ConfigMutationTracker`, so inspect the loaded providers' source and statically resolve `mergeConfigFrom($path, $key)` arguments with PHPStan scope evaluation, including constant `__DIR__` expressions, inherited methods and supported helper calls. Preserve uncertainty for unresolved paths or dynamic control flow; do not invent a runtime path registry, inspect captured closure variables, or use post-analysis collectors to feed inference. Static expression analysis supplies config types, never the concrete values from the developer's `.env`. +Port `ConfigParser` in full, then adapt its discovery and merging. Static expression analysis supplies config types, never the concrete values from the developer's `.env`. Do not execute provider or config code to manufacture facts, invent a runtime path registry, inspect captured closure variables, or use post-analysis collectors to feed inference. ```php // config/feature.php @@ -243,7 +247,20 @@ return ['enabled' => (bool) env('FEATURE_ENABLED', false)]; $enabled = config('feature.enabled'); ``` -Cover helpers, facade and repository methods, typed getters and collection access. Respect explicit casts, imports, preceding assignments, nullable/union settings, defaults, package defaults and application overrides. Hypervel uses shallow merging; named groups declared by `mergeableOptions()` get one additional entry-merge level, with an application entry replacing its matching default entry whole. Honor registration order and supported custom providers. Reuse existing parser helpers rather than executing arbitrary config files a second time to get values. When a provider mutates config dynamically beyond the parser's knowledge, preserve uncertainty rather than inventing a fixed shape. +Upstream types only the application's files, claims an unresolved key holds its default (`config('logging.channels.sentry', [])` types as `array{}` while Sentry sets it), seals arrays that other sources extend, and reports every unknown `collection()` key. Replace that with one PHPStan type per config file key, composed from the sources Hypervel loads: + +- **Sources, highest priority first.** Application files: `configDirectories` when set (replacing only this discovery), else the booted `config_path()` overlaid by the Testbench workbench config directory when its config discovery is on (a workbench file replaces the same key). Then framework defaults from `LoadConfiguration::frameworkConfigPath()` when `shouldMergeFrameworkConfiguration()` is true. Then definite package merges. Keep upstream's per-file return-scope evaluation (imports, preceding statements, casts, `@return` docblocks) and its nested-file handling (a key with config files below it stays unknown). Unlike upstream, which reads the final composition, a config file's own `config()` reads see the configuration as it stands while that file loads: an application file sees the framework entries without an application file of their own and the application files before it in natural key order, with no merges or writes. A file's own key is unset until it returns. Reads inside a function or closure body keep the declared type, since the body may run during loading or long after. Framework and package files' reads keep the declared type: framework files load into an empty repository and providers merge package files in an order the analysis does not follow. Package code is analysed against the booted application's config, including its own defaults. Code that accepts wider consumer config declares that contract at its consuming boundary with a local PHPDoc, as Telescope's `registerWatchers()` does for keyed and listed watchers; code that validates every entry declares the getter's own type. A typed getter reached only after a null check of a key whose default is null takes an inline `hypervel.configType` ignore with its reason, since `ConfigTypeRule` reads the key, not the local value. Upstream's config-file `@return` docblock is not the consumer contract: it must describe the whole file, nothing checks it against the values, and it is ignored when `treatPhpDocTypesAsCertain` is off, as in Components. Never infer a source as unknown from which paths a run analyses. + - Loader fix: both `LoadConfiguration::loadConfigurationFiles()` copies (Foundation, Testbench) re-set every residual base entry after the app files load, discarding nested files below a framework-only name (`config/services/stripe.php` without `services.php`). Skip a residual entry that is base-only and identical to its initial value; added or changed residual values still apply. This preserves `loadConfigurationFile()`'s returned residual-value contract, not an override's direct repository write to a base-only key, which the old loop reset. Test the nested case and one override adding and changing residual values, in both copies. Then remove `ConfigParser::composeConfig()`'s special case that models the overwrite. +- **Merging.** Follow the runtime: `array_merge` at the top level and at each mergeable option (string keys: the higher source wins; integer keys: appended and renumbered), and `replaceConfigRecursivelyFrom` replacing by key at every depth. A higher array with unknown keys (an unsealed `@return` shape) may replace any lower entry it does not list, so in both merges those entries become unknown. Read mergeable options once per instance and key from the real `mergeableOptions()` of a `LoadConfiguration` and of the booted provider; do not copy their tables. +- **Provider merges.** Find `mergeConfigFrom()`/`replaceConfigRecursivelyFrom()` calls from the cached AST of loaded providers' class hierarchies and traits, excluding `ServiceProvider`. PHPStan scopes do not fold `__DIR__` (`usePathConstantsAsConstantString` is off, and must stay a consumer setting), so resolve arguments with php-parser's `ConstExprEvaluator`: the source file's `__DIR__`/`__FILE__`, global `dirname()` with constant arguments, class constants, and the current string value of a `self::`/`static::` property that is provider boot metadata (Sentry's documented boot-only `$abstract`). This is metadata argument resolution, not expression execution or runtime value typing. A merge is definite when its arguments resolve and it lies on the straight-line path from the effective (most-derived) `register()`: top-level `parent::register()` and `$this->helper()` statements followed under an active recursion guard (a helper called twice runs twice), with no earlier `return` outside nested closures. An earlier `throw` cannot have run in a booted application. Boot-time merges, merges of overlapping keys by different providers and every other resolved call are candidates; resolving arguments does not prove execution. Enumerate the registered providers once (`getProviders(ServiceProvider::class)`). Creating a callable runs nothing: `$this->helper(...)` is not followed, and a callable created from a merge method is an unresolvable merge. A resolved file is a package config file even when its key is not. +- **Mutations widen the composed type.** Direct writes come from the keys of the array entries `ConfigMutationTracker` records after LoadConfiguration attaches it, read once through the analysis-only `(fn () => ...)->call()` pattern. These cover Testbench's testing connection, user model and database/queue rewrites and provider `set()`/`push()`/`prepend()` calls. Never read the recorded values or run or inspect the recorded closures. `configureUsing()` writes are not recorded, so scan closure callbacks in provider hierarchies, from the cached AST, for writes through a recognized repository receiver (`set()`, `push()`, `prepend()`, offset assignment or unset). Recognized receivers, by resolved function and class identity: the callback's parameter, a method parameter typed as the repository contract or concrete, `config()`, global `app()`/`resolve()` with `'config'` or the repository class, `$this->app->make()`/`get()` with the same ids, plus `config([...])` and Config facade writes. The container has no `ArrayAccess`, so there is no `$this->app['config']` form. Known reads (`get()`, `has()`, the typed getters, `collection()`, `all()`, `getMany()`, offset reads) are not effects. Passing, reassigning, capturing or returning the callback parameter, calling any other method on it, or a non-closure callback is an unlocalizable write; an unrelated `app()`/`$this->app` use is not a write. Bind arguments by their real parameter names (`key`, `offset`, `abstract`, `name`, `id`) with `getArg()`, never by position, since the provider AST is not PHPStan's reordered call; an unpacked key is unlocalizable. Creating a callable from `config(...)` or a repository method writes nothing; inside a callback it is an unlocalizable write, since its later calls cannot be followed, unless it comes from a read-only method. + - The same pass records constant keys of direct writes through recognized receivers anywhere in the provider class-likes, covering branches the CLI boot did not run. The tracker stays authoritative for executed writes, so unrecognized flows (locals, helpers, other services) are not treated as writes. This is a documented best-effort boundary: an unexecuted write through an unrecognized flow is not found. + - Resolve a write key fully first. Otherwise bound it by its constant leading part (concatenation or interpolation) up to the last proven dot boundary: `'filesystems.disks.disk_' . $name` writes `filesystems.disks`, `'filesystems.disks' . $suffix` writes `filesystems`. Without a complete segment it is an unlocalizable write. One helper serves callbacks and direct writes. + - A replacement at P makes lookups at or below P unknown and widens P to optional `mixed` in its ancestors. + - A candidate merge at K unseals K and its string-keyed mergeable options, keeping their known fields. Numeric merge levels and non-array ancestors, which `Arr::set()` replaces, are replacements. + - An unresolvable merge target or an unlocalizable write makes every lookup unknown. + - Mergeable options are named-entry registries that framework code extends at runtime, so they stay unsealed. An unsealed tail covers additions, not replacements: HandleExceptions fills a falsy `deprecations` or `null` log channel inside a recorded closure, so a registry entry whose known value may be falsy is unknown. Other known entries keep their exact types. +- **Lookup.** Walk offsets with PHPStan presence (`hasOffsetValueType()`). Present gives the value alone, so a default cannot hide an incompatible value. Maybe gives the value or the default. Absent gives the default, or null without one. Unknown keeps the declaration. Evaluate only `Closure` defaults to their return, as `value()` does: the default itself or its union alternatives, never a Closure it holds. Cover `config()`, the Config facade's `get()`, typed getters and `collection()`, and the repository contract (which also covers the concrete repository), only for methods the contract, the concrete repository or the facade declares. A custom implementation or override keeps its declared type, and the rules skip it too. On a repository receiver this includes what a getter delegates to: the typed getters read through `get()` and `collection()` through `array()`. Facade calls forward to the bound repository, so only the called facade method is checked. Typed getters intersect their native type with the result, and an unknown `collection()` keeps its declaration. During recursive lookups an in-progress discovery is unknown and is not cached; publish derived caches only after discovery completes. Retain `configDirectories`, `viewDirectories`, `translationDirectories`, migration/schema paths and associated upstream settings for retained features. Prefer boot-discovered paths by default; document exactly whether an explicit setting replaces or supplements discovery. Larastan's configured migration/schema paths replace its fallback paths; preserve that contract unless a separately justified adaptation changes it. A non-empty `viewDirectories` likewise replaces the booted finder's paths and namespace hints, for the unused-view rule only; the missing-translation rule always scans the finder's paths, as upstream does. By default the unused-view rule names root views from the finder's paths and scans namespace hint directories for usages only: framework and package views such as `pagination::tailwind` are rendered by names no literal call shows. @@ -261,6 +278,8 @@ Track connection/table identity where it changes the result, schema changes acro - Retain PHPStan's `DependencyTracker`/`DeclarationDependencyTracker` use for configuration, migrations, schema dumps, views and translations. Track both changed files and directory membership so newly added/deleted files invalidate results. Keep declaration-based dependencies for reflection extensions and scope-based dependencies for return types/rules. - Track runtime inputs explicitly; normal class dependencies alone do not capture registration code. For macros/mixins, track registered closure/method source files through reflection, plus registration providers and the mixin class itself. For container string keys, track loaded provider source, `bootstrap/app.php`, `bootstrap/providers.php`, Composer's installed metadata/root discovery configuration and package discovery cache, Testbench YAML, `.env` and the relevant pre-boot environment identity. Resolved concrete classes retain normal class dependencies. For console definitions, track command class files and `routes/console.php`. Auth configuration and view/translation namespace registrations inherit their config/provider/resource dependencies. Include files called by registration helpers where they supply a consumed fact; retain an explicit dependency at that boundary rather than assuming the analyzed caller references them. Compute shared inputs once per process, not per symbol. +- Package-mode boot paths point into Testbench's per-process runtime copy, so tracked config, view and translation dependencies vanish between runs. Resolve provenance once at this shared tracking boundary, for all three: track the actual source inputs of copied files and registrations, and keep conservative invalidation where provenance is unknown (the analyzer fixture overwriting runtime `config/auth.php` and `resources/views`, other Testbench file changes, borrowed runtimes, a predefined `BASE_PATH`). No per-parser remapping or blanket prefix substitution. Required before final integration signoff. +- Boot-derived config mutation targets (the recorded `ConfigMutationTracker` keys) depend on external conditions, such as Testbench rewriting `database.default` only when the SQLite database file is missing. A stale result can keep a wrong narrow type or an unnecessarily broad one, and provider hashes and environment identity do not notice the file appearing or disappearing. Include that condition (file presence) in the shared tracking, and cover a warm-cache presence transition in both directions against a cold run. No new tracking in the config parser, no boot during cache restoration and no database content hashing. - PHPStan restores its result cache in the main process before application bootstrap. A `ResultCacheMetaExtension` cannot assume a booted container. Use it only for global inputs available before boot, such as selected configuration/environment identity; use existing PHPStan facilities for per-file facts. Do not serialize the live container or eagerly boot it during cache restoration. - Test a changed boot-derived return type with unchanged analyzed source, on a warm cache. A real provider/macro fixture must change a formerly valid call into an error without manually clearing the cache, and recover when the provider is corrected. Include a `mixin()` class referenced only by provider registration. Verify the same result with a cold run. - Preserve the migration cache's locking, second read under lock and atomic replacement. Port upstream `MigrationCacheTest` and dependency tests, then add the changed-fingerprint regression: `getMTime()` has whole-second resolution, so ordinary edit-and-analyze loops can reuse a stale schema after two edits in one second. Keep mtime fixed explicitly in this regression instead of relying on test timing or sleeps. Use existing content fingerprints or a once-per-input `xxh128` digest and add a short WHY comment at the change so an upstream sync preserves it. Preserve upstream's timestamp-change invalidation alongside content changes so existing applicable tests remain meaningful. No new concurrency suite is needed for unchanged locking. Keep one parsed-schema cache, bounded in-process reflection caches and normal PHPStan temp storage; no extra persistent metadata service. @@ -274,11 +293,11 @@ Copy retained rule files first, then adapt them. Retain their relevant toggles a |---|---| | `ArrKeyExistenceRule` | Diagnose definitely absent `Arr::get`/`pull` keys; respect optional keys, defaults, dynamic shapes and actual argument binding. | | `CheckDispatchArgumentTypesCompatibleWithClassConstructorRule` | Preserve job/event constructor checking, conditional dispatch, named arguments, unpacking and callable dispatch variants actually supported by Hypervel. | -| `ConfigCollectionRule` | Port against the adapted config parser and typed getters; do not forbid legitimate nullable/union `get()` calls. | +| `ConfigCollectionRule` | Port against the adapted config parser for every receiver (Config facade, repository contract, `config()`); report only known values that are definitely not arrays, never unknown keys. A new `ConfigTypeRule` (`hypervel.configType`, under `checkConfigTypes`) reports typed getters whose known value can never match; both share lookups through `ConfigHelper`. Do not forbid legitimate nullable/union `get()` calls. | | `ConsoleCommand/UndefinedArgumentOrOptionRule` | Use booted command definitions, including signatures and inherited/configured definitions. | | `ModelAppendsRule` | Support `#[Appends]` and currently supported property declarations. A database-backed attribute can be appended with a getter/class cast. Distinguish readable accessors from setter-only `Attribute` definitions; preserve custom serialization. | | `ModelRelationDefaultsRule` | Validate `$with`/`$withCount` against real relations, nested paths, aliases and inherited definitions. | -| `NoEnvCallsOutsideOfConfigRule` | Retain the policy with actual package config paths and narrowly justified framework/bootstrap uses. Do not claim outside-config calls always return null. Upstream's bare `str_starts_with()` lets `/app/config` permit `/app/config-sibling/x.php`; match the normalized directory itself or a path beneath it, and cover the sibling-prefix case in the existing test. | +| `NoEnvCallsOutsideOfConfigRule` | Retain the policy, closing upstream's bypass: match every normal reader, not only `env()`. These are the global `env`, `env_array` and `env_or_fail` functions, `app_id()` (a config-default helper that reads `APP_ID`/`APP_NAME`, so a runtime call gets a fallback id once configuration is cached), and the static `Env::get`, `getArray` and `getOrFail` methods, by resolved identity (case-insensitive names; `is(Env::class)` covers aliases and subclasses). One identifier; the message names the call. Raw `Env::getRepository()`, `getenv()` and superglobal access are deliberate low-level access and out of scope, so do not claim complete environment-access detection. Permit the application and framework config directories, `LoadConfiguration::frameworkConfigStubPath()` (the `config:publish` templates), plus package config files: merge paths (even when the key or execution is uncertain) and PHP sources `ServiceProvider::pathsToPublish()` maps into the config directory, including directory mappings. A merge path does not permit its whole directory. Do not claim outside-config calls return null: cached configuration only stops `.env` loading. Upstream's bare `str_starts_with()` lets `/app/config` permit `/app/config-sibling/x.php`; match the normalized directory itself or a path beneath it, and cover the sibling-prefix case in the existing test. Fixtures: one case per entry point, an alias, a subclass, the stub directory and the existing namespaced-function negatives. Components keep the rule on with no directory or path-scoped ignores: such ignores would hide a future service setting read from the environment instead of config. Framework `src/` outside config uses `Env::get()` (the owner's convention); each verified, deliberate infrastructure read, including the `env*()` wrappers' delegation, gets an inline `@phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (reason)`. In `tests/` (not analysed), plain reads use `Env::get()` and tests of the helpers keep calling them. | | `NoMissingTranslationsRule` | Reuse the booted translator, existing usage collectors and resource dependencies; do not rebuild lookup in a file scanner. Restrict default diagnostics to literal structured keys with a known group source and known standard file-backed resolution. See the translation predicate below. | | `NoModelMakeRule` | Use the conservative declaration/runtime-metadata exclusions below, without interpreting arbitrary model boot code. Unknown or customized construction stays silent. | | `NoPublicModelScopeAndAccessorRule` | Apply to the real Hypervel attributed-scope/accessor surface. Coordinate with the legacy-scope rule to avoid duplicate errors. Preserve inherited visibility and valid native methods. | From 75e1a6bc297e580bf296cb052eb3ff525396ccc6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:55:59 +0000 Subject: [PATCH 057/303] fix: honor configuration PHPDoc and clarify getter diagnostics Honor configuration file return annotations independently of always-true PHPDoc checks. Make incompatible typed-getter diagnostics explain which call throws and how to correct the configuration or getter. Update the rule expectations and configuration-resolution fixture to verify the approved analysis contract. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- src/phpstan/src/Rules/ConfigCollectionRule.php | 5 ++++- src/phpstan/src/Rules/ConfigTypeRule.php | 3 ++- src/phpstan/src/Support/ConfigParser.php | 8 ++++---- tests/PHPStan/Rules/ConfigCollectionRuleTest.php | 8 ++++---- tests/PHPStan/Rules/ConfigTypeRuleTest.php | 10 +++++----- tests/PHPStan/Type/data/config-file-resolution.neon | 2 ++ 6 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/phpstan/src/Rules/ConfigCollectionRule.php b/src/phpstan/src/Rules/ConfigCollectionRule.php index 8591a97f5..33e7a6f1b 100644 --- a/src/phpstan/src/Rules/ConfigCollectionRule.php +++ b/src/phpstan/src/Rules/ConfigCollectionRule.php @@ -64,7 +64,10 @@ public function processNode(Node $node, Scope $scope): array } return [ - RuleErrorBuilder::message(sprintf('Config key %s is not an array.', $scope->getType($key->value)->describe(VerbosityLevel::value()))) + RuleErrorBuilder::message(sprintf( + 'Config key %s is not an array, so collection() throws. Correct the config value or use the getter that matches its type.', + $scope->getType($key->value)->describe(VerbosityLevel::value()), + )) ->identifier('hypervel.configCollection') ->build(), ]; diff --git a/src/phpstan/src/Rules/ConfigTypeRule.php b/src/phpstan/src/Rules/ConfigTypeRule.php index 129698440..c590918f6 100644 --- a/src/phpstan/src/Rules/ConfigTypeRule.php +++ b/src/phpstan/src/Rules/ConfigTypeRule.php @@ -78,9 +78,10 @@ public function processNode(Node $node, Scope $scope): array return [ RuleErrorBuilder::message(sprintf( - 'Config key %s is not %s.', + 'Config key %s is not %s, so %s() throws. Correct the config value or use the getter that matches its type.', $scope->getType($key->value)->describe(VerbosityLevel::value()), self::GETTER_TYPES[$methodName], + $methodName, )) ->identifier('hypervel.configType') ->build(), diff --git a/src/phpstan/src/Support/ConfigParser.php b/src/phpstan/src/Support/ConfigParser.php index 1354dd18c..10e1bd6b1 100644 --- a/src/phpstan/src/Support/ConfigParser.php +++ b/src/phpstan/src/Support/ConfigParser.php @@ -210,8 +210,6 @@ public function __construct( private ScopeFactory $scopeFactory, private NodeScopeResolver $nodeScopeResolver, private ConfigSourceResolver $configSourceResolver, - #[AutowiredParameter] - private bool $treatPhpDocTypesAsCertain, ) { } @@ -918,10 +916,12 @@ private function getFileValue(string $path): ConfigArray|Expr|Type|null [$return] = $parsedConfigFile; - // Check if we have a type from the docblock + // Check if we have a type from the docblock. Upstream reads it only when + // treatPhpDocTypesAsCertain is on, but that setting only relaxes always-true + // checks; PHPStan still infers from PHPDoc types when it is off. $docComment = $return->getDocComment(); - if ($docComment !== null && $this->treatPhpDocTypesAsCertain) { + if ($docComment !== null) { // Names in the docblock are resolved with the config file's imports $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( $path, diff --git a/tests/PHPStan/Rules/ConfigCollectionRuleTest.php b/tests/PHPStan/Rules/ConfigCollectionRuleTest.php index 6834770f3..9b8c27414 100644 --- a/tests/PHPStan/Rules/ConfigCollectionRuleTest.php +++ b/tests/PHPStan/Rules/ConfigCollectionRuleTest.php @@ -20,19 +20,19 @@ public function testRule(): void { $this->analyse([__DIR__ . '/data/config-collection-rule.php'], [ [ - "Config key 'test.foo' is not an array.", + "Config key 'test.foo' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", 12, ], [ - "Config key 'test.foo' is not an array.", + "Config key 'test.foo' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", 19, ], [ - "Config key 'test.foo' is not an array.", + "Config key 'test.foo' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", 20, ], [ - "Config key 'test.missing' is not an array.", + "Config key 'test.missing' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", 26, ], ]); diff --git a/tests/PHPStan/Rules/ConfigTypeRuleTest.php b/tests/PHPStan/Rules/ConfigTypeRuleTest.php index 7757b51ac..3b6fd8c36 100644 --- a/tests/PHPStan/Rules/ConfigTypeRuleTest.php +++ b/tests/PHPStan/Rules/ConfigTypeRuleTest.php @@ -19,11 +19,11 @@ protected function getRule(): Rule public function testRule(): void { $this->analyse([__DIR__ . '/data/config-type-rule.php'], [ - ["Config key 'test.foo' is not an integer.", 14], - ["Config key 'test.foo' is not an array.", 16], - ["Config key 'auth.defaults.guard' is not a float.", 17], - ["Config key 'test.missing' is not a boolean.", 31], - ["Config key 'test.foo' is not an integer.", 40], + ["Config key 'test.foo' is not an integer, so integer() throws. Correct the config value or use the getter that matches its type.", 14], + ["Config key 'test.foo' is not an array, so array() throws. Correct the config value or use the getter that matches its type.", 16], + ["Config key 'auth.defaults.guard' is not a float, so float() throws. Correct the config value or use the getter that matches its type.", 17], + ["Config key 'test.missing' is not a boolean, so boolean() throws. Correct the config value or use the getter that matches its type.", 31], + ["Config key 'test.foo' is not an integer, so integer() throws. Correct the config value or use the getter that matches its type.", 40], ]); } diff --git a/tests/PHPStan/Type/data/config-file-resolution.neon b/tests/PHPStan/Type/data/config-file-resolution.neon index e99de7494..73fd85d99 100644 --- a/tests/PHPStan/Type/data/config-file-resolution.neon +++ b/tests/PHPStan/Type/data/config-file-resolution.neon @@ -1,6 +1,8 @@ includes: - ../../phpstan-tests.neon parameters: + # Hypervel's own configurations turn this off, and config file @return types still apply. + treatPhpDocTypesAsCertain: false checkConfigTypes: true configDirectories: - %rootDir%/../../../tests/PHPStan/Type/data/config-file-resolution/config From b6f14b7148942dfcb884231a6243ad0018f897fd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:55:59 +0000 Subject: [PATCH 058/303] fix: align collection contracts with runtime keys and callbacks Correct collection key, callback, pluck and combination types across eager and lazy collections. Accept integer pluck paths, matching the supported data lookup behavior instead of throwing on Collection::pluck(0, 1). Keep runtime implementations and Laravel extension points intact. Extend the collection type fixtures and integer-key runtime regression coverage. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- src/collections/src/Arr.php | 22 +++++-- src/collections/src/Collection.php | 40 +++++++---- src/collections/src/Enumerable.php | 63 +++++++++--------- src/collections/src/LazyCollection.php | 25 ++++--- .../src/Traits/EnumeratesValues.php | 26 ++++---- tests/Support/SupportCollectionTest.php | 8 +++ types/Collections/Arr.php | 2 + types/Collections/Collection.php | 66 ++++++++++++++----- types/Collections/LazyCollection.php | 31 ++++++--- types/Collections/helpers.php | 2 +- 10 files changed, 184 insertions(+), 101 deletions(-) diff --git a/src/collections/src/Arr.php b/src/collections/src/Arr.php index b74262e8c..b92358567 100644 --- a/src/collections/src/Arr.php +++ b/src/collections/src/Arr.php @@ -737,7 +737,7 @@ public static function select(array $array, array|string|int|float|null $keys): /** * Pluck an array of values from an array. */ - public static function pluck(iterable $array, string|array|int|Closure|null $value, string|array|Closure|null $key = null): array + public static function pluck(iterable $array, string|array|int|Closure|null $value, string|array|int|Closure|null $key = null): array { $results = []; @@ -772,21 +772,27 @@ public static function pluck(iterable $array, string|array|int|Closure|null $val /** * Explode the "value" and "key" arguments passed to "pluck". * - * @param array|Closure|string $value - * @param null|array|Closure|string $key - * @return array + * @return array{null|array|Closure|int, null|array|Closure|int} */ - protected static function explodePluckParameters($value, $key) + protected static function explodePluckParameters(string|array|int|Closure|null $value, string|array|int|Closure|null $key): array { $value = is_string($value) ? explode('.', $value) : $value; - $key = is_null($key) || is_array($key) || $key instanceof Closure ? $key : explode('.', $key); + $key = is_null($key) || is_array($key) || is_int($key) || $key instanceof Closure ? $key : explode('.', $key); return [$value, $key]; } /** * Run a map over each of the items in the array. + * + * @template TKey of array-key + * @template TValue + * @template TMapValue + * + * @param array $array + * @param callable(TValue, TKey): TMapValue $callback + * @return array */ public static function map(array $array, callable $callback): array { @@ -795,6 +801,7 @@ public static function map(array $array, callable $callback): array try { $items = array_map($callback, $array, $keys); } catch (ArgumentCountError) { + // @phpstan-ignore argument.type (only reached by callbacks that reject the key argument, such as internal functions) $items = array_map($callback, $array); } @@ -806,13 +813,14 @@ public static function map(array $array, callable $callback): array * * The callback should return an associative array with a single key/value pair. * - * @template TKey + * @template TKey of array-key * @template TValue * @template TMapWithKeysKey of array-key * @template TMapWithKeysValue * * @param array $array * @param callable(TValue, TKey): array $callback + * @return array */ public static function mapWithKeys(array $array, callable $callback): array { diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index 11d90f13e..eb6685526 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -504,13 +504,14 @@ public function get(mixed $key, mixed $default = null): mixed /** * Get an item from the collection by key or add it to collection if it does not exist. * + * @template TGetOrPutKey of array-key * @template TGetOrPutValue * - * @param null|TKey $key + * @param null|TGetOrPutKey $key * @param (Closure(): TGetOrPutValue)|TGetOrPutValue $value * @return TGetOrPutValue|TValue * - * @phpstan-this-out ($key is null ? static : static) + * @phpstan-this-out ($key is null ? static : static) */ public function getOrPut(mixed $key, mixed $value): mixed { @@ -785,9 +786,12 @@ public function last(?callable $callback = null, mixed $default = null): mixed /** * Get the values of a given key. * - * @param null|array|Closure|int|string $value - * @param null|array|Closure|int|string $key - * @return static + * @template TPluckValue + * @template TPluckKey of array-key|BaseStringable + * + * @param null|array|(Closure(TValue): TPluckValue)|int|string $value + * @param null|array|(Closure(TValue): TPluckKey)|int|string $key + * @return static<($key is Closure ? (TPluckKey is int ? int : string) : array-key), ($value is Closure ? TPluckValue : mixed)> */ public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|null $key = null): Collection { @@ -861,7 +865,7 @@ public function mapWithKeys(callable $callback): Collection * @template TMergeValue * * @param Arrayable>|iterable $items - * @return static + * @return static<(array is array ? int : TKey), TMergeValue|TValue> */ public function merge(mixed $items): static { @@ -874,7 +878,7 @@ public function merge(mixed $items): static * @template TMergeRecursiveValue * * @param Arrayable>|iterable $items - * @return static|TMergeRecursiveValue|TValue)> + * @return static<(array is array ? int : TKey), (TKey is int ? TMergeRecursiveValue|TValue : array|TMergeRecursiveValue|TValue)> */ public function mergeRecursive(mixed $items): static { @@ -883,6 +887,8 @@ public function mergeRecursive(mixed $items): static /** * Multiply the items in the collection by the multiplier. + * + * @return static */ public function multiply(int $multiplier): static { @@ -901,8 +907,7 @@ public function multiply(int $multiplier): static * @template TCombineValue * * @param Arrayable>|iterable $values - * @return static - * @phpstan-ignore generics.notSubtype (TValue becomes key - only valid when TValue is array-key, but can't express this constraint) + * @return static<(TValue is array-key ? TValue : array-key), TCombineValue> */ public function combine(mixed $values): static { @@ -1068,11 +1073,10 @@ public function unshift(mixed ...$values): static /** * Push all of the given items onto the collection. * - * @template TConcatKey of array-key * @template TConcatValue * - * @param iterable $source - * @return static + * @param iterable $source + * @return static */ public function concat(iterable $source): static { @@ -1233,6 +1237,8 @@ public function after(mixed $value, bool $strict = false): mixed * @param int<0, max> $count * @return ($count is 1 ? null|TValue : static) * + * @phpstan-this-out static<(array is array ? int : TKey), TValue> + * * @throws InvalidArgumentException */ public function shift(int $count = 1): mixed @@ -1266,6 +1272,8 @@ public function shift(int $count = 1): mixed /** * Shuffle the items in the collection. + * + * @return static */ public function shuffle(): static { @@ -1334,7 +1342,7 @@ public function slice(int $offset, ?int $length = null): static /** * Split a collection into a certain number of groups. * - * @return ($numberOfGroups is positive-int ? static> : never) + * @return ($numberOfGroups is positive-int ? static is array ? int : TKey), TValue>> : never) * * @throws InvalidArgumentException */ @@ -1348,7 +1356,7 @@ public function split(int $numberOfGroups): static return $this->newInstance(); } - /** @var static> $groups */ + /** @var static is array ? int : TKey), TValue>> $groups */ $groups = $this->newInstance(); // @phpstan-ignore varTag.nativeType (@TODO: Remove once PHPStan stops applying receiver generics to the factory's native static return type.) $groupSize = (int) floor($this->count() / $numberOfGroups); @@ -1710,6 +1718,10 @@ public function sortKeysUsing(callable $callback): static /** * Splice a portion of the underlying collection array. + * + * @return static<(array is array ? int : TKey), TValue> + * + * @phpstan-this-out static<(array is array ? int : TKey), TValue> */ public function splice(int $offset, ?int $length = null, mixed $replacement = []): static { diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php index 07c1149aa..b33c301fe 100644 --- a/src/collections/src/Enumerable.php +++ b/src/collections/src/Enumerable.php @@ -272,10 +272,10 @@ public function filter(?callable $callback = null): static; /** * Apply the callback if the given "value" is (or resolves to) truthy. * - * @template TWhenReturnType as null + * @template TWhenReturnType = never * - * @param null|(callable($this): TWhenReturnType) $callback - * @param null|(callable($this): TWhenReturnType) $default + * @param null|(callable($this): (null|TWhenReturnType|void)) $callback + * @param null|(callable($this): (null|TWhenReturnType|void)) $default * @return $this|TWhenReturnType */ public function when(mixed $value, ?callable $callback = null, ?callable $default = null): mixed; @@ -283,10 +283,10 @@ public function when(mixed $value, ?callable $callback = null, ?callable $defaul /** * Apply the callback if the collection is empty. * - * @template TWhenEmptyReturnType + * @template TWhenEmptyReturnType = never * - * @param (callable($this): TWhenEmptyReturnType) $callback - * @param null|(callable($this): TWhenEmptyReturnType) $default + * @param (callable($this): (null|TWhenEmptyReturnType|void)) $callback + * @param null|(callable($this): (null|TWhenEmptyReturnType|void)) $default * @return $this|TWhenEmptyReturnType */ public function whenEmpty(callable $callback, ?callable $default = null): mixed; @@ -294,10 +294,10 @@ public function whenEmpty(callable $callback, ?callable $default = null): mixed; /** * Apply the callback if the collection is not empty. * - * @template TWhenNotEmptyReturnType + * @template TWhenNotEmptyReturnType = never * - * @param callable($this): TWhenNotEmptyReturnType $callback - * @param null|(callable($this): TWhenNotEmptyReturnType) $default + * @param callable($this): (null|TWhenNotEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TWhenNotEmptyReturnType|void)) $default * @return $this|TWhenNotEmptyReturnType */ public function whenNotEmpty(callable $callback, ?callable $default = null): mixed; @@ -305,10 +305,10 @@ public function whenNotEmpty(callable $callback, ?callable $default = null): mix /** * Apply the callback if the given "value" is (or resolves to) falsy. * - * @template TUnlessReturnType + * @template TUnlessReturnType = never * - * @param (callable($this): TUnlessReturnType) $callback - * @param null|(callable($this): TUnlessReturnType) $default + * @param (callable($this): (null|TUnlessReturnType|void)) $callback + * @param null|(callable($this): (null|TUnlessReturnType|void)) $default * @return $this|TUnlessReturnType */ public function unless(mixed $value, callable $callback, ?callable $default = null): mixed; @@ -316,10 +316,10 @@ public function unless(mixed $value, callable $callback, ?callable $default = nu /** * Apply the callback unless the collection is empty. * - * @template TUnlessEmptyReturnType + * @template TUnlessEmptyReturnType = never * - * @param callable($this): TUnlessEmptyReturnType $callback - * @param null|(callable($this): TUnlessEmptyReturnType) $default + * @param callable($this): (null|TUnlessEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TUnlessEmptyReturnType|void)) $default * @return $this|TUnlessEmptyReturnType */ public function unlessEmpty(callable $callback, ?callable $default = null): mixed; @@ -327,10 +327,10 @@ public function unlessEmpty(callable $callback, ?callable $default = null): mixe /** * Apply the callback unless the collection is not empty. * - * @template TUnlessNotEmptyReturnType + * @template TUnlessNotEmptyReturnType = never * - * @param callable($this): TUnlessNotEmptyReturnType $callback - * @param null|(callable($this): TUnlessNotEmptyReturnType) $default + * @param callable($this): (null|TUnlessNotEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TUnlessNotEmptyReturnType|void)) $default * @return $this|TUnlessNotEmptyReturnType */ public function unlessNotEmpty(callable $callback, ?callable $default = null): mixed; @@ -633,7 +633,7 @@ public function mapWithKeys(callable $callback): Collection|static; * @template TFlatMapValue * * @param callable(TValue, TKey): (array|Enumerable) $callback - * @return static + * @return static */ public function flatMap(callable $callback): Collection|static; @@ -653,7 +653,7 @@ public function mapInto(string $class): Collection|static; * @template TMergeValue * * @param Arrayable>|iterable $items - * @return static + * @return static<(array is array ? int : TKey), TMergeValue|TValue> */ public function merge(mixed $items): static; @@ -663,7 +663,7 @@ public function merge(mixed $items): static; * @template TMergeRecursiveValue * * @param Arrayable>|iterable $items - * @return static|TMergeRecursiveValue|TValue)> + * @return static<(array is array ? int : TKey), (TKey is int ? TMergeRecursiveValue|TValue : array|TMergeRecursiveValue|TValue)> */ public function mergeRecursive(mixed $items): static; @@ -673,8 +673,7 @@ public function mergeRecursive(mixed $items): static; * @template TCombineValue * * @param Arrayable>|iterable $values - * @return static - * @phpstan-ignore generics.notSubtype (TValue becomes key - only valid when TValue is array-key, but can't express this constraint) + * @return static<(TValue is array-key ? TValue : array-key), TCombineValue> */ public function combine(Arrayable|iterable $values): static; @@ -733,11 +732,10 @@ public function partition(mixed $key, mixed $operator = null, mixed $value = nul /** * Push all of the given items onto the collection. * - * @template TConcatKey of array-key * @template TConcatValue * - * @param iterable $source - * @return static + * @param iterable $source + * @return static */ public function concat(iterable $source): static; @@ -825,6 +823,8 @@ public function after(mixed $value, bool $strict = false): mixed; /** * Shuffle the items in the collection. + * + * @return static */ public function shuffle(): static; @@ -862,7 +862,7 @@ public function slice(int $offset, ?int $length = null): static; /** * Split a collection into a certain number of groups. * - * @return static> + * @return static is array ? int : TKey), TValue>> */ public function split(int $numberOfGroups): static; @@ -1034,9 +1034,12 @@ public function pipeThrough(array $pipes): mixed; /** * Get the values of a given key. * - * @param null|array|Closure|int|string $value - * @param null|array|Closure|int|string $key - * @return static + * @template TPluckValue + * @template TPluckKey of array-key|BaseStringable + * + * @param null|array|(Closure(TValue): TPluckValue)|int|string $value + * @param null|array|(Closure(TValue): TPluckKey)|int|string $key + * @return static<($key is Closure ? (TPluckKey is int ? int : string) : array-key), ($value is Closure ? TPluckValue : mixed)> */ public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|null $key = null): Collection|static; diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index 14d7bcc66..f5ed0753a 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -739,9 +739,12 @@ public function last(?callable $callback = null, mixed $default = null): mixed /** * Get the values of a given key. * - * @param null|array|Closure|int|string $value - * @param null|array|Closure|int|string $key - * @return static + * @template TPluckValue + * @template TPluckKey of array-key|BaseStringable + * + * @param null|array|(Closure(TValue): TPluckValue)|int|string $value + * @param null|array|(Closure(TValue): TPluckKey)|int|string $key + * @return static<($key is Closure ? (TPluckKey is int ? int : string) : array-key), ($value is Closure ? TPluckValue : mixed)> */ public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|null $key = null): static { @@ -851,7 +854,7 @@ public function mapWithKeys(callable $callback): static * @template TFlatMapValue * * @param callable(TValue, TKey): (array|Enumerable) $callback - * @return static + * @return static */ public function flatMap(callable $callback): static { @@ -891,9 +894,12 @@ public function mergeRecursive(mixed $items): static /** * Multiply the items in the collection by the multiplier. + * + * @return static */ public function multiply(int $multiplier): static { + // @phpstan-ignore return.type (passthru loses generic type info) return $this->passthru(__FUNCTION__, func_get_args()); } @@ -903,8 +909,7 @@ public function multiply(int $multiplier): static * @template TCombineValue = mixed * * @param Arrayable>|(callable(): iterable)|iterable $values - * @return static - * @phpstan-ignore generics.notSubtype (TValue becomes key - only valid when TValue is array-key, but can't express this constraint) + * @return static<(TValue is array-key ? TValue : array-key), TCombineValue> */ public function combine(Arrayable|iterable|callable $values): static { @@ -1058,11 +1063,10 @@ public function partition(mixed $key, mixed $operator = null, mixed $value = nul /** * Push all of the given items onto the collection. * - * @template TConcatKey of array-key * @template TConcatValue * - * @param iterable $source - * @return static + * @param iterable $source + * @return static */ public function concat(iterable $source): static { @@ -1209,6 +1213,7 @@ public function after(mixed $value, bool $strict = false): mixed #[Override] public function shuffle(): static { + // @phpstan-ignore return.type (passthru loses generic type info) return $this->passthru(__FUNCTION__, []); } @@ -1331,7 +1336,7 @@ public function slice(int $offset, ?int $length = null): static /** * Split a collection into a certain number of groups. * - * @return ($numberOfGroups is positive-int ? static> : never) + * @return ($numberOfGroups is positive-int ? static is array ? int : TKey), TValue>> : never) * * @throws InvalidArgumentException */ diff --git a/src/collections/src/Traits/EnumeratesValues.php b/src/collections/src/Traits/EnumeratesValues.php index 70debc67b..498e47d8d 100644 --- a/src/collections/src/Traits/EnumeratesValues.php +++ b/src/collections/src/Traits/EnumeratesValues.php @@ -430,7 +430,7 @@ public function mapToGroups(callable $callback): Collection|static * @template TFlatMapValue * * @param callable(TValue, TKey): (array|Enumerable) $callback - * @return Collection|static + * @return Collection<(array is array ? int : TFlatMapKey), TFlatMapValue>|static<(array is array ? int : TFlatMapKey), TFlatMapValue> */ public function flatMap(callable $callback): Collection|static { @@ -555,10 +555,10 @@ public function sum(callable|int|string|null $callback = null): mixed /** * Apply the callback if the collection is empty. * - * @template TWhenEmptyReturnType + * @template TWhenEmptyReturnType = never * - * @param (callable($this): TWhenEmptyReturnType) $callback - * @param null|(callable($this): TWhenEmptyReturnType) $default + * @param (callable($this): (null|TWhenEmptyReturnType|void)) $callback + * @param null|(callable($this): (null|TWhenEmptyReturnType|void)) $default * @return $this|TWhenEmptyReturnType */ public function whenEmpty(callable $callback, ?callable $default = null): mixed @@ -569,10 +569,10 @@ public function whenEmpty(callable $callback, ?callable $default = null): mixed /** * Apply the callback if the collection is not empty. * - * @template TWhenNotEmptyReturnType + * @template TWhenNotEmptyReturnType = never * - * @param callable($this): TWhenNotEmptyReturnType $callback - * @param null|(callable($this): TWhenNotEmptyReturnType) $default + * @param callable($this): (null|TWhenNotEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TWhenNotEmptyReturnType|void)) $default * @return $this|TWhenNotEmptyReturnType */ public function whenNotEmpty(callable $callback, ?callable $default = null): mixed @@ -583,10 +583,10 @@ public function whenNotEmpty(callable $callback, ?callable $default = null): mix /** * Apply the callback unless the collection is empty. * - * @template TUnlessEmptyReturnType + * @template TUnlessEmptyReturnType = never * - * @param callable($this): TUnlessEmptyReturnType $callback - * @param null|(callable($this): TUnlessEmptyReturnType) $default + * @param callable($this): (null|TUnlessEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TUnlessEmptyReturnType|void)) $default * @return $this|TUnlessEmptyReturnType */ public function unlessEmpty(callable $callback, ?callable $default = null): mixed @@ -597,10 +597,10 @@ public function unlessEmpty(callable $callback, ?callable $default = null): mixe /** * Apply the callback unless the collection is not empty. * - * @template TUnlessNotEmptyReturnType + * @template TUnlessNotEmptyReturnType = never * - * @param callable($this): TUnlessNotEmptyReturnType $callback - * @param null|(callable($this): TUnlessNotEmptyReturnType) $default + * @param callable($this): (null|TUnlessNotEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TUnlessNotEmptyReturnType|void)) $default * @return $this|TUnlessNotEmptyReturnType */ public function unlessNotEmpty(callable $callback, ?callable $default = null): mixed diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index 3f40eac12..49b876176 100644 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -2831,6 +2831,14 @@ public function testPluckWithDotNotation($collection): void $this->assertEquals([['php', 'python'], ['php', 'asp', 'java']], $data->pluck('skill.backend')->all()); } + #[DataProvider('collectionClassProvider')] + public function testPluckWithIntegerKeys($collection): void + { + $data = new $collection([['taylor', 'foo'], ['dayle', 'bar']]); + + $this->assertEquals(['foo' => 'taylor', 'bar' => 'dayle'], $data->pluck(0, 1)->all()); + } + #[DataProvider('collectionClassProvider')] public function testPluckWithClosure($collection): void { diff --git a/types/Collections/Arr.php b/types/Collections/Arr.php index 8432a9ac5..9666512f2 100644 --- a/types/Collections/Arr.php +++ b/types/Collections/Arr.php @@ -230,6 +230,8 @@ public function jsonSerialize(): mixed assertType('array', Arr::random($array, 2)); assertType('array<0|1, mixed>', Arr::mapSpread([[0, 1], [2, 3]], fn (int $even, int $odd): int => $even + $odd)); +assertType("array<'first'|'last', int<0, max>>", Arr::map(['first' => 'taylor', 'last' => 'otwell'], fn (string $value, string $key): int => strlen($value))); +assertType("array<'name', 0>", Arr::mapWithKeys([new User], fn (User $user, int $key): array => ['name' => $key])); // Numeric prefixes can produce integer array keys. assertType('array', Arr::prependKeysWith($array, 'user_')); diff --git a/types/Collections/Collection.php b/types/Collections/Collection.php index 0a2714ef4..1a7a6ae83 100644 --- a/types/Collections/Collection.php +++ b/types/Collections/Collection.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; +use Hypervel\Support\Stringable; use SortDirection; use Traversable; @@ -290,7 +291,7 @@ function assertUnwrapUnion(array|Collection $items): void return true; })); -assertType('Hypervel\Support\Collection|null', $collection->when(true, function ($collection) { +assertType('Hypervel\Support\Collection', $collection->when(true, function ($collection) { assertType('Hypervel\Support\Collection', $collection); })); assertType("'string'|Hypervel\\Support\\Collection", $collection->when(true, function ($collection) { @@ -298,12 +299,12 @@ function assertUnwrapUnion(array|Collection $items): void return 'string'; })); -assertType('Hypervel\Support\Collection|null', $collection->when('Taylor', function ($collection, $name) { +assertType('Hypervel\Support\Collection', $collection->when('Taylor', function ($collection, $name) { assertType('Hypervel\Support\Collection', $collection); assertType("'Taylor'", $name); })); assertType( - 'Hypervel\Support\Collection|null', + 'Hypervel\Support\Collection', $collection->when( 'Taylor', function ($collection, $name) { @@ -316,12 +317,12 @@ function ($collection, $name) { } ) ); -assertType('Hypervel\Support\Collection|null', $collection->when(fn () => 'Taylor', function ($collection, $name) { +assertType('Hypervel\Support\Collection', $collection->when(fn () => 'Taylor', function ($collection, $name) { assertType('Hypervel\Support\Collection', $collection); assertType("'Taylor'", $name); })); assertType( - 'Hypervel\Support\Collection|null', + 'Hypervel\Support\Collection', $collection->when( function ($collection) { assertType('Hypervel\Support\Collection', $collection); @@ -339,7 +340,7 @@ function ($collection, $count) { ) ); -assertType('Hypervel\Support\Collection|null', $collection->when($invokable, function ($collection, $param) { +assertType('Hypervel\Support\Collection', $collection->when($invokable, function ($collection, $param) { assertType('Hypervel\Support\Collection', $collection); assertType('Invokable', $param); })); @@ -349,7 +350,7 @@ function ($collection, $count) { return true; })); -assertType('Hypervel\Support\Collection|null', $collection->whenEmpty(function ($collection) { +assertType('Hypervel\Support\Collection', $collection->whenEmpty(function ($collection) { assertType('Hypervel\Support\Collection', $collection); })); assertType("'string'|Hypervel\\Support\\Collection", $collection->whenEmpty(function ($collection) { @@ -363,7 +364,7 @@ function ($collection, $count) { return true; })); -assertType('Hypervel\Support\Collection|null', $collection->whenNotEmpty(function ($collection) { +assertType('Hypervel\Support\Collection', $collection->whenNotEmpty(function ($collection) { assertType('Hypervel\Support\Collection', $collection); })); assertType("'string'|Hypervel\\Support\\Collection", $collection->whenNotEmpty(function ($collection) { @@ -377,7 +378,7 @@ function ($collection, $count) { return true; })); -assertType('Hypervel\Support\Collection|null', $collection->unless(true, function ($collection) { +assertType('Hypervel\Support\Collection', $collection->unless(true, function ($collection) { assertType('Hypervel\Support\Collection', $collection); })); assertType("'string'|Hypervel\\Support\\Collection", $collection->unless(true, function ($collection) { @@ -385,12 +386,12 @@ function ($collection, $count) { return 'string'; })); -assertType('Hypervel\Support\Collection|null', $collection->unless('Taylor', function ($collection, $name) { +assertType('Hypervel\Support\Collection', $collection->unless('Taylor', function ($collection, $name) { assertType('Hypervel\Support\Collection', $collection); assertType("'Taylor'", $name); })); assertType( - 'Hypervel\Support\Collection|null', + 'Hypervel\Support\Collection', $collection->unless( 'Taylor', function ($collection, $name) { @@ -403,12 +404,12 @@ function ($collection, $name) { } ) ); -assertType('Hypervel\Support\Collection|null', $collection->unless(fn () => 'Taylor', function ($collection, $name) { +assertType('Hypervel\Support\Collection', $collection->unless(fn () => 'Taylor', function ($collection, $name) { assertType('Hypervel\Support\Collection', $collection); assertType("'Taylor'", $name); })); assertType( - 'Hypervel\Support\Collection|null', + 'Hypervel\Support\Collection', $collection->unless( function ($collection) { assertType('Hypervel\Support\Collection', $collection); @@ -426,7 +427,7 @@ function ($collection, $count) { ) ); -assertType('Hypervel\Support\Collection|null', $collection->unless($invokable, function ($collection, $param) { +assertType('Hypervel\Support\Collection', $collection->unless($invokable, function ($collection, $param) { assertType('Hypervel\Support\Collection', $collection); assertType('Invokable', $param); })); @@ -436,7 +437,7 @@ function ($collection, $count) { return true; })); -assertType('Hypervel\Support\Collection|null', $collection->unlessEmpty(function ($collection) { +assertType('Hypervel\Support\Collection', $collection->unlessEmpty(function ($collection) { assertType('Hypervel\Support\Collection', $collection); })); assertType("'string'|Hypervel\\Support\\Collection", $collection->unlessEmpty(function ($collection) { @@ -450,7 +451,7 @@ function ($collection, $count) { return true; })); -assertType('Hypervel\Support\Collection|null', $collection->unlessNotEmpty(function ($collection) { +assertType('Hypervel\Support\Collection', $collection->unlessNotEmpty(function ($collection) { assertType('Hypervel\Support\Collection', $collection); })); assertType("'string'|Hypervel\\Support\\Collection", $collection->unlessNotEmpty(function ($collection) { @@ -679,6 +680,7 @@ function ($collection, $count) { assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->combine([2])); assertType('Hypervel\Support\Collection', $collection::make([1])->combine([1])); assertType('Hypervel\Support\Collection', $collection::make(['string'])->combine(['string'])); +assertType('Hypervel\Support\Collection<(int|string), int>', $collection->flatten()->combine([1])); assertType('Hypervel\Support\Collection', $collection::make([1])->union([1])); assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->union(['string' => 'string'])); @@ -734,6 +736,24 @@ function ($collection, $count) { assertType('Hypervel\Support\Collection', $collection::make([1])->concat([2])); assertType('Hypervel\Support\Collection', $collection::make(['string'])->concat(['string'])); assertType('Hypervel\Support\Collection', $collection::make([1])->concat(['string'])); +assertType('Hypervel\Support\Collection', $collection::make(['first' => 'string'])->concat(['key' => 'string'])); + +// Merging, multiplying, shuffling and splicing renumber integer keys. +assertType('Hypervel\Support\Collection', $collection::make([10 => 'string'])->merge([10 => 'string'])); +assertType('Hypervel\Support\Collection', $collection::make(['first' => 'string'])->merge(['first' => 'string'])); +assertType('Hypervel\Support\Collection', $collection::make([10 => 'string'])->mergeRecursive([10 => 'string'])); +assertType('Hypervel\Support\Collection', $collection::make(['first' => 'string'])->multiply(2)); +assertType('Hypervel\Support\Collection', $collection::make(['first' => 'string'])->shuffle()); +assertType('Hypervel\Support\Collection', $collection::make([1])->flatMap(fn (int $value): array => [10 => $value])); +assertType('Hypervel\Support\Collection', $collection::make([1])->flatMap(fn (int $value): array => ['key' => $value])); + +$splicedCollection = $collection::make([10 => 'string', 20 => 'string']); +assertType('Hypervel\Support\Collection', $splicedCollection->splice(1)); +assertType('Hypervel\Support\Collection', $splicedCollection); + +$shiftedCollection = $collection::make([10 => 'string', 20 => 'string']); +assertType('string|null', $shiftedCollection->shift()); +assertType('Hypervel\Support\Collection', $shiftedCollection); assertType('Hypervel\Support\Collection', $collection::make([1])->random(2)); assertType('string', $collection::make(['string'])->random()); @@ -974,8 +994,14 @@ function ($collection, $count) { assertType('Hypervel\Support\Collection<(int|string), mixed>', $collection::make(['string' => 'string'])->pluck('string')); assertType('Hypervel\Support\Collection<(int|string), mixed>', $collection::make(['string' => 'string'])->pluck('string', 'string')); - -assertType('Hypervel\Support\Collection', $collection->reject()); +assertType('Hypervel\Support\Collection<(int|string), int>', $collection->pluck(fn (User $user): int => 1)); +assertType('Hypervel\Support\Collection', $collection->pluck('string', fn (User $user): string => 'key')); +assertType('Hypervel\Support\Collection', $collection->pluck('string', fn (User $user) => 'key')); +assertType('Hypervel\Support\Collection', $collection->pluck(fn (User $user): string => 'value', fn (User $user): int => 1)); +assertType('Hypervel\Support\Collection', $collection->pluck(fn (User $user): int => 1, fn (User $user): Stringable => new Stringable('key'))); + +// Without a callback, reject() keeps only falsy items, and objects are never falsy. +assertType('Hypervel\Support\Collection', $collection->reject()); assertType('Hypervel\Support\Collection', $collection->reject(new User)); assertType('Hypervel\Support\Collection', $collection->reject(function ($user) { assertType('User', $user); @@ -1063,6 +1089,10 @@ function ($collection, $count) { assertType("'string'|User", $getOrPutCollection->getOrPut(null, 'string')); assertType("Hypervel\\Support\\Collection", $getOrPutCollection); +$getOrPutCollection = $collection::make([new User]); +assertType("'string'|User", $getOrPutCollection->getOrPut('name', 'string')); +assertType("Hypervel\\Support\\Collection<'name'|int, 'string'|User>", $getOrPutCollection); + assertType('Hypervel\Support\Collection', $collection->forget(1)); assertType('Hypervel\Support\Collection', $collection->forget([1, 2])); assertType('Hypervel\Support\Collection', $collection->forget(collect([1, 2]))); diff --git a/types/Collections/LazyCollection.php b/types/Collections/LazyCollection.php index e83451052..8778b6859 100644 --- a/types/Collections/LazyCollection.php +++ b/types/Collections/LazyCollection.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; +use Hypervel\Support\Stringable; use SortDirection; use Traversable; @@ -262,7 +263,7 @@ public function toArray(): array return true; })); -assertType('Hypervel\Support\LazyCollection|null', $collection->when(true, function ($collection) { +assertType('Hypervel\Support\LazyCollection', $collection->when(true, function ($collection) { assertType('Hypervel\Support\LazyCollection', $collection); })); assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->when(true, function ($collection) { @@ -276,7 +277,7 @@ public function toArray(): array return true; })); -assertType('Hypervel\Support\LazyCollection|null', $collection->whenEmpty(function ($collection) { +assertType('Hypervel\Support\LazyCollection', $collection->whenEmpty(function ($collection) { assertType('Hypervel\Support\LazyCollection', $collection); })); assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->whenEmpty(function ($collection) { @@ -290,7 +291,7 @@ public function toArray(): array return true; })); -assertType('Hypervel\Support\LazyCollection|null', $collection->whenNotEmpty(function ($collection) { +assertType('Hypervel\Support\LazyCollection', $collection->whenNotEmpty(function ($collection) { assertType('Hypervel\Support\LazyCollection', $collection); })); assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->whenNotEmpty(function ($collection) { @@ -304,7 +305,7 @@ public function toArray(): array return true; })); -assertType('Hypervel\Support\LazyCollection|null', $collection->unless(true, function ($collection) { +assertType('Hypervel\Support\LazyCollection', $collection->unless(true, function ($collection) { assertType('Hypervel\Support\LazyCollection', $collection); })); assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->unless(true, function ($collection) { @@ -318,7 +319,7 @@ public function toArray(): array return true; })); -assertType('Hypervel\Support\LazyCollection|null', $collection->unlessEmpty(function ($collection) { +assertType('Hypervel\Support\LazyCollection', $collection->unlessEmpty(function ($collection) { assertType('Hypervel\Support\LazyCollection', $collection); })); assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->unlessEmpty(function ($collection) { @@ -332,7 +333,7 @@ public function toArray(): array return true; })); -assertType('Hypervel\Support\LazyCollection|null', $collection->unlessNotEmpty(function ($collection) { +assertType('Hypervel\Support\LazyCollection', $collection->unlessNotEmpty(function ($collection) { assertType('Hypervel\Support\LazyCollection', $collection); })); assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->unlessNotEmpty(function ($collection) { @@ -611,6 +612,14 @@ public function toArray(): array assertType('Hypervel\Support\LazyCollection', $collection::make([1])->concat([2])); assertType('Hypervel\Support\LazyCollection', $collection::make(['string'])->concat(['string'])); assertType('Hypervel\Support\LazyCollection', $collection::make([1])->concat(['string'])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['first' => 'string'])->concat(['key' => 'string'])); + +// Merging, multiplying and shuffling renumber integer keys, and flatMap() drops every key. +assertType('Hypervel\Support\LazyCollection', $collection::make([10 => 'string'])->merge([10 => 'string'])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['first' => 'string'])->merge(['first' => 'string'])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['first' => 'string'])->multiply(2)); +assertType('Hypervel\Support\LazyCollection', $collection::make(['first' => 'string'])->shuffle()); +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->flatMap(fn (int $value): array => ['key' => $value])); assertType('Hypervel\Support\LazyCollection', $collection::make([1])->random(2)); assertType('string', $collection::make(['string'])->random()); @@ -853,8 +862,14 @@ public function toArray(): array assertType('Hypervel\Support\LazyCollection<(int|string), mixed>', $collection::make(['string' => 'string'])->pluck('string')); assertType('Hypervel\Support\LazyCollection<(int|string), mixed>', $collection::make(['string' => 'string'])->pluck('string', 'string')); - -assertType('Hypervel\Support\LazyCollection', $collection->reject()); +assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection->pluck(fn (User $user): int => 1)); +assertType('Hypervel\Support\LazyCollection', $collection->pluck('string', fn (User $user): string => 'key')); +assertType('Hypervel\Support\LazyCollection', $collection->pluck('string', fn (User $user) => 'key')); +assertType('Hypervel\Support\LazyCollection', $collection->pluck(fn (User $user): string => 'value', fn (User $user): int => 1)); +assertType('Hypervel\Support\LazyCollection', $collection->pluck(fn (User $user): int => 1, fn (User $user): Stringable => new Stringable('key'))); + +// Without a callback, reject() keeps only falsy items, and objects are never falsy. +assertType('Hypervel\Support\LazyCollection', $collection->reject()); assertType('Hypervel\Support\LazyCollection', $collection->reject(function ($user) { assertType('User', $user); diff --git a/types/Collections/helpers.php b/types/Collections/helpers.php index f83124d2e..f6a926dcc 100644 --- a/types/Collections/helpers.php +++ b/types/Collections/helpers.php @@ -5,7 +5,7 @@ use function Hypervel\Support\enum_value; use function PHPStan\Testing\assertType; -assertType("'foo'", value('foo', 42)); +assertType("'foo'", value('foo', 42)); // @phpstan-ignore hypervel.uselessConstructs.value (asserts the helper's own type) assertType('42', value(fn () => 42)); assertType('42', value(function ($foo) { assertType('true', $foo); From ee8cae5afdb7c8476721960e3b196a95650003d6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:55:59 +0000 Subject: [PATCH 059/303] fix: refine support utility return types Describe Benchmark results, Str replacement arrays and reflection class-name lists according to their actual runtime behavior. Remove the redundant reflection filter without changing the returned names. Update the existing utility type fixtures and add focused benchmark inference coverage. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- src/reflection/src/Reflector.php | 8 ++++++-- src/support/src/Benchmark.php | 6 ++++-- src/support/src/Str.php | 12 ++++++++---- tests/PHPStan/Type/data/benchmark.php | 16 ++++++++++++++++ types/Reflection/helpers.php | 1 + types/Support/Benchmark.php | 11 +++++++++++ types/Support/Str.php | 4 +++- 7 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Type/data/benchmark.php create mode 100644 types/Support/Benchmark.php diff --git a/src/reflection/src/Reflector.php b/src/reflection/src/Reflector.php index 9ac84382d..4b8092d6a 100644 --- a/src/reflection/src/Reflector.php +++ b/src/reflection/src/Reflector.php @@ -116,13 +116,17 @@ public static function getParameterClassName(ReflectionParameter $parameter): ?s /** * Get the class names of the given parameter's type, including union types. + * + * @return list */ public static function getParameterClassNames(ReflectionParameter $parameter): array { $type = $parameter->getType(); if (! $type instanceof ReflectionUnionType) { - return array_filter([static::getParameterClassName($parameter)]); + $className = static::getParameterClassName($parameter); + + return $className === null ? [] : [$className]; } $unionTypes = []; @@ -135,7 +139,7 @@ public static function getParameterClassNames(ReflectionParameter $parameter): a $unionTypes[] = static::getTypeName($parameter, $listedType); } - return array_filter($unionTypes); + return $unionTypes; } /** diff --git a/src/support/src/Benchmark.php b/src/support/src/Benchmark.php index 65f174145..2ce5c11f2 100644 --- a/src/support/src/Benchmark.php +++ b/src/support/src/Benchmark.php @@ -14,8 +14,10 @@ class Benchmark /** * Measure a callable or array of callables over the given number of iterations. * - * @param array|Closure $benchmarkables - * @return array|float + * @template TKey of array-key + * + * @param array|Closure $benchmarkables + * @return ($benchmarkables is Closure ? float : array) */ public static function measure(Closure|array $benchmarkables, int $iterations = 1): array|float { diff --git a/src/support/src/Str.php b/src/support/src/Str.php index ee83f46c6..ce246cde2 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -1114,10 +1114,12 @@ private static function toStringOr(mixed $value, string $fallback): string /** * Replace the given value in the given string. * + * @template TKey of array-key + * * @param iterable|string $search * @param iterable|string $replace - * @param iterable|string $subject - * @return ($subject is string ? string : string[]) + * @param iterable|string $subject + * @return ($subject is string ? string : array) */ public static function replace(string|iterable $search, string|iterable $replace, string|iterable $subject, bool $caseSensitive = true): string|array { @@ -1141,10 +1143,12 @@ public static function replace(string|iterable $search, string|iterable $replace /** * Replace the given value in the given string regardless of case. * + * @template TKey of array-key + * * @param string|string[] $search * @param string|string[] $replace - * @param string|string[] $subject - * @return ($subject is string ? string : string[]) + * @param array|string $subject + * @return ($subject is string ? string : array) */ protected static function replaceWhileIgnoringCase(string|array $search, string|array $replace, string|array $subject): string|array { diff --git a/tests/PHPStan/Type/data/benchmark.php b/tests/PHPStan/Type/data/benchmark.php new file mode 100644 index 000000000..9882f92e7 --- /dev/null +++ b/tests/PHPStan/Type/data/benchmark.php @@ -0,0 +1,16 @@ + 'Hello World')); + // Hypervel: an array's averages are not cast to float, so a whole-millisecond average stays an integer. + assertType('array<0|1, float|int>', Benchmark::measure([fn () => 'Hello World', fn () => 'Hello World'])); + assertType('array<\'test1\'|\'test2\', float|int>', Benchmark::measure(['test1' => fn () => 'Hello World', 'test2' => fn () => 'Hello World'])); + assertType('array<100|\'test\', float|int>', Benchmark::measure(['test' => fn () => 'Hello World', 100 => fn () => 'Hello World'])); +} diff --git a/types/Reflection/helpers.php b/types/Reflection/helpers.php index 5d90254a3..9276c9939 100644 --- a/types/Reflection/helpers.php +++ b/types/Reflection/helpers.php @@ -39,6 +39,7 @@ function (ReflectionTypeLazyClass $proxy, array $eager) { 'Hypervel\Support\Collection, Hypervel\Support\Collection>', Reflector::getClassAttributes(ReflectionTypeTarget::class, ReflectionTypeAttribute::class, true) ); +assertType('list', Reflector::getParameterClassNames(new ReflectionParameter([ReflectionTypeTarget::class, 'method'], 0))); assertType('ReflectionClass', ClassMetadataCache::reflectClass(ReflectionTypeTarget::class)); assertType('ReflectionClass', ClassMetadataCache::reflectClass(new ReflectionTypeTarget)); diff --git a/types/Support/Benchmark.php b/types/Support/Benchmark.php new file mode 100644 index 000000000..4e3c871de --- /dev/null +++ b/types/Support/Benchmark.php @@ -0,0 +1,11 @@ + 1)); +assertType("array<'first'|'second', float|int>", Benchmark::measure(['first' => fn () => 1, 'second' => fn () => 2])); +assertType('array<0|1, float|int>', Benchmark::measure([fn () => 1, fn () => 2])); diff --git a/types/Support/Str.php b/types/Support/Str.php index 2eaa558c7..b7137a9b4 100644 --- a/types/Support/Str.php +++ b/types/Support/Str.php @@ -64,7 +64,9 @@ * @var string $subject */ assertType('string', Str::replace($search, $replace, $subject)); -assertType('array', Str::replace($search, $replace, [$subject])); +assertType('array<0, string>', Str::replace($search, $replace, [$subject])); +assertType("array<'name', string>", Str::replace($search, $replace, ['name' => $subject])); +assertType('array', Str::replace($search, $replace, collect([$subject]))); assertType('\'\'', Str::camel('')); assertType('string', Str::camel('Taylor Otwell')); From 5eee15aa39ada92947da6c926e0cf533d4b45918 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:55:59 +0000 Subject: [PATCH 060/303] fix: describe optional and conditional helper contracts Preserve Optional's wrapped value type and exclude null from the optional callback input. Describe higher-order condition state and the nullable/void callback behavior of conditional helpers without changing their runtime APIs. Align request enum defaults and generated facade annotations with lazy default evaluation. Update the support and request type fixtures. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- src/conditionable/src/HigherOrderWhenProxy.php | 11 +++++++++++ src/conditionable/src/Traits/Conditionable.php | 16 ++++++++-------- src/support/src/Facades/Request.php | 2 +- src/support/src/Optional.php | 5 +++++ src/support/src/Traits/InteractsWithData.php | 18 +++++++++--------- src/support/src/helpers.php | 4 ++-- types/Http/Request.php | 1 + types/Support/Traits.php | 18 ++++++++++++++++++ types/Support/helpers.php | 9 +++++---- 9 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/conditionable/src/HigherOrderWhenProxy.php b/src/conditionable/src/HigherOrderWhenProxy.php index ba43176f6..a4616c0e9 100644 --- a/src/conditionable/src/HigherOrderWhenProxy.php +++ b/src/conditionable/src/HigherOrderWhenProxy.php @@ -4,6 +4,10 @@ namespace Hypervel\Support; +/** + * @template TTarget + * @template-covariant THasCondition of bool = false + */ class HigherOrderWhenProxy { /** @@ -23,6 +27,8 @@ class HigherOrderWhenProxy /** * Create a new proxy instance. + * + * @param TTarget $target */ public function __construct( protected mixed $target, @@ -31,11 +37,16 @@ public function __construct( /** * Set the condition on the proxy. + * + * @return static + * + * @phpstan-self-out static */ public function condition(mixed $condition): static { [$this->condition, $this->hasCondition] = [(bool) $condition, true]; + // @phpstan-ignore return.type (the proxy now has a condition) return $this; } diff --git a/src/conditionable/src/Traits/Conditionable.php b/src/conditionable/src/Traits/Conditionable.php index fca272289..5bd83a9ed 100644 --- a/src/conditionable/src/Traits/Conditionable.php +++ b/src/conditionable/src/Traits/Conditionable.php @@ -13,12 +13,12 @@ trait Conditionable * Apply the callback if the given "value" is (or resolves to) truthy. * * @template TWhenParameter - * @template TWhenReturnType + * @template TWhenReturnType = never * * @param null|(Closure($this): TWhenParameter)|TWhenParameter $value - * @param null|(callable($this, TWhenParameter): TWhenReturnType) $callback - * @param null|(callable($this, TWhenParameter): TWhenReturnType) $default - * @return $this|TWhenReturnType + * @param null|(callable($this, TWhenParameter): (null|TWhenReturnType|void)) $callback + * @param null|(callable($this, TWhenParameter): (null|TWhenReturnType|void)) $default + * @return ($callback is null ? $this|HigherOrderWhenProxy<$this, bool>|TWhenReturnType : $this|TWhenReturnType) */ public function when(mixed $value = null, ?callable $callback = null, ?callable $default = null): mixed { @@ -46,12 +46,12 @@ public function when(mixed $value = null, ?callable $callback = null, ?callable * Apply the callback if the given "value" is (or resolves to) falsy. * * @template TUnlessParameter - * @template TUnlessReturnType + * @template TUnlessReturnType = never * * @param null|(Closure($this): TUnlessParameter)|TUnlessParameter $value - * @param null|(callable($this, TUnlessParameter): TUnlessReturnType) $callback - * @param null|(callable($this, TUnlessParameter): TUnlessReturnType) $default - * @return $this|TUnlessReturnType + * @param null|(callable($this, TUnlessParameter): (null|TUnlessReturnType|void)) $callback + * @param null|(callable($this, TUnlessParameter): (null|TUnlessReturnType|void)) $default + * @return ($callback is null ? $this|HigherOrderWhenProxy<$this, bool>|TUnlessReturnType : $this|TUnlessReturnType) */ public function unless(mixed $value = null, ?callable $callback = null, ?callable $default = null): mixed { diff --git a/src/support/src/Facades/Request.php b/src/support/src/Facades/Request.php index 103ec1f78..1579adc0c 100644 --- a/src/support/src/Facades/Request.php +++ b/src/support/src/Facades/Request.php @@ -30,7 +30,7 @@ * @method static \Hypervel\Http\Request dump(mixed $keys = []) * @method static \Hypervel\Http\Request duplicate(array|null $query = null, array|null $request = null, array|null $attributes = null, array|null $cookies = null, array|null $files = null, array|null $server = null) * @method static void enableHttpMethodParameterOverride() - * @method static \BackedEnum|null enum(string $key, string $enumClass, \BackedEnum|null $default = null) + * @method static \BackedEnum|null enum(string $key, string $enumClass, \Closure|\BackedEnum|null $default = null) * @method static \BackedEnum[] enums(string $key, string $enumClass) * @method static array except(mixed $keys) * @method static bool exists(array|string $key) diff --git a/src/support/src/Optional.php b/src/support/src/Optional.php index f09f30afd..9cfda8cc6 100644 --- a/src/support/src/Optional.php +++ b/src/support/src/Optional.php @@ -7,6 +7,9 @@ use ArrayAccess; use Hypervel\Support\Traits\Macroable; +/** + * @template TValue + */ class Optional implements ArrayAccess { use Macroable { @@ -20,6 +23,8 @@ class Optional implements ArrayAccess /** * Create a new optional instance. + * + * @param TValue $value */ public function __construct(mixed $value) { diff --git a/src/support/src/Traits/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php index 9b32d2c8b..3dd2fe92e 100644 --- a/src/support/src/Traits/InteractsWithData.php +++ b/src/support/src/Traits/InteractsWithData.php @@ -76,10 +76,10 @@ public function hasAny(array|string $keys): bool /** * Apply the callback if the instance contains the given key. * - * @template TReturn + * @template TReturn = never * @template TReturnDefault = never * - * @param callable(mixed): TReturn $callback + * @param callable(mixed): (null|TReturn|void) $callback * @param null|(callable(): TReturnDefault) $default * @return $this|TReturn|TReturnDefault */ @@ -150,10 +150,10 @@ public function anyFilled(array|string $keys): bool /** * Apply the callback if the instance contains a non-empty value for the given key. * - * @template TReturn + * @template TReturn = never * @template TReturnDefault = never * - * @param callable(mixed): TReturn $callback + * @param callable(mixed): (null|TReturn|void) $callback * @param null|(callable(): TReturnDefault) $default * @return $this|TReturn|TReturnDefault */ @@ -174,11 +174,11 @@ public function whenFilled(string $key, callable $callback, ?callable $default = * Apply the callback if the instance contains a valid enum value for the given key. * * @template TEnum of BackedEnum - * @template TReturn + * @template TReturn = never * @template TReturnDefault = never * * @param class-string $enumClass - * @param callable(TEnum): TReturn $callback + * @param callable(TEnum): (null|TReturn|void) $callback * @param null|(callable(): TReturnDefault) $default * @return $this|TReturn|TReturnDefault */ @@ -208,10 +208,10 @@ public function missing(array|string $key): bool /** * Apply the callback if the instance is missing the given key. * - * @template TReturn + * @template TReturn = never * @template TReturnDefault = never * - * @param callable(mixed): TReturn $callback + * @param callable(mixed): (null|TReturn|void) $callback * @param null|(callable(): TReturnDefault) $default * @return $this|TReturn|TReturnDefault */ @@ -404,7 +404,7 @@ public function interval(string $key, Unit|string|null $unit = null): ?CarbonInt * @template TDefault of TEnum|null * * @param class-string $enumClass - * @param TDefault $default + * @param (Closure(): TDefault)|TDefault $default * @return TDefault|TEnum */ public function enum(string $key, string $enumClass, mixed $default = null): mixed diff --git a/src/support/src/helpers.php b/src/support/src/helpers.php index b515e8f69..2ec0a3f20 100644 --- a/src/support/src/helpers.php +++ b/src/support/src/helpers.php @@ -284,8 +284,8 @@ function once(callable $callback) * @template TReturn * * @param TValue $value - * @param null|(callable(TValue): TReturn) $callback - * @return ($callback is null ? Optional : ($value is null ? null : TReturn)) + * @param null|(callable((TValue is null ? never : TValue)): TReturn) $callback + * @return ($callback is null ? Optional : ($value is null ? null : TReturn)) */ function optional($value = null, ?callable $callback = null) { diff --git a/types/Http/Request.php b/types/Http/Request.php index 3f1c4a9e0..e597acce5 100644 --- a/types/Http/Request.php +++ b/types/Http/Request.php @@ -17,6 +17,7 @@ enum TestEnum: string assertType('TestEnum|null', $request->enum('key', TestEnum::class)); assertType('TestEnum|TestEnum::Foo', $request->enum('key', TestEnum::class, TestEnum::Foo)); +assertType('TestEnum|TestEnum::Foo', $request->enum('key', TestEnum::class, fn () => TestEnum::Foo)); assertType('int', $request->integer('key')); assertType('int', $request->integer('key', 5, true)); diff --git a/types/Support/Traits.php b/types/Support/Traits.php index f26ad78ed..f61f8c01f 100644 --- a/types/Support/Traits.php +++ b/types/Support/Traits.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Localizable; use Hypervel\Support\UriQueryString; @@ -19,6 +20,18 @@ public function useWithLocale(): void } }; +$conditionable = function (ConditionableTarget $target, bool $condition): void { + // A callback without a result gives the instance back. + assertType('ConditionableTarget', $target->when($condition, function (): void { + })); + assertType('1|ConditionableTarget', $target->unless($condition, fn () => 1)); +}; + +final class ConditionableTarget +{ + use Conditionable; +} + $interactsWithData = function (UriQueryString $query): void { assertType('1|2|Hypervel\Support\UriQueryString', $query->whenEnum('foo', TestIntEnum::class, function ($enum) { assertType('TestIntEnum', $enum); @@ -67,6 +80,11 @@ public function useWithLocale(): void assertType('3|Hypervel\Support\UriQueryString', $query->whenMissing('foo', function ($value) { return 3; })); + + // A callback without a result gives the instance back. + assertType('Hypervel\Support\UriQueryString', $query->whenHas('foo', function ($value): void { + })); + assertType('Hypervel\Support\UriQueryString', $query->whenEnum('foo', TestIntEnum::class, fn ($enum) => null)); }; enum TestIntEnum: int diff --git a/types/Support/helpers.php b/types/Support/helpers.php index efcbebedd..467f10c1d 100644 --- a/types/Support/helpers.php +++ b/types/Support/helpers.php @@ -36,7 +36,7 @@ assertType('null', once(function () { /* @phpstan-ignore function.void (testing void) */ })); -assertType('Hypervel\Support\Optional', optional()); +assertType('Hypervel\Support\Optional', optional()); assertType('null', optional(null, fn () => 1)); assertType('1', optional('foo', function ($value) { assertType("'foo'", $value); @@ -47,7 +47,8 @@ assertType('1', retry(5, fn () => 1)); assertType('1', retry(2, fn () => 1, fn (int $attempt, Throwable $exception): int => 1000)); -assertType('object', str()); +// The analyzer does not model the anonymous class that passes calls to Str, so it types it as mixed. +assertType('mixed', str()); assertType('Hypervel\Support\Stringable', str('foo')); assertType('User', tap(new User, function ($user) { @@ -103,8 +104,8 @@ function testThrowUnless(float|int $foo, ?DateTimeImmutable $bar = null): void assertType('true', transform('', fn () => 1, true)); assertType('true', transform('', fn () => 1, fn () => true)); -assertType('User', with(new User)); -assertType('bool', with(new User)->save()); +assertType('User', with(new User)); // @phpstan-ignore hypervel.uselessConstructs.with (asserts the helper's own type) +assertType('bool', with(new User)->save()); // @phpstan-ignore hypervel.uselessConstructs.with (asserts the helper's own type) assertType('10', with(new User, function ($user) { assertType('User', $user); From 16c66e344bb58cae24bb8721f077f39fb3303066 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:55:59 +0000 Subject: [PATCH 061/303] feat: infer higher-order collection and optional proxy calls Resolve collection proxy results from the collection's own method signatures, preserving keys, lazy collections and callback return types. Add Optional and Conditionable forwarding with accurate argument-count behavior and preserved callback-owned static types. Share focused proxy reflections that respect member visibility, reference semantics and deprecation metadata. Cover proxy access failures, nullable Optional callbacks and supported fluent chains in the existing analyzer harness. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- .../HigherOrderCollectionProxyExtension.php | 38 ++++ .../Methods/HigherOrderTapProxyExtension.php | 61 ++++++ .../Methods/HigherOrderWhenProxyExtension.php | 38 ++++ src/phpstan/src/Methods/OptionalExtension.php | 89 +++++++++ ...rOrderCollectionProxyPropertyExtension.php | 38 ++++ .../HigherOrderWhenProxyPropertyExtension.php | 38 ++++ .../Reflection/ProxiedMethodReflection.php | 180 ++++++++++++++++++ .../Reflection/ProxiedParameterReflection.php | 72 +++++++ .../Reflection/ProxiedPropertyReflection.php | 139 ++++++++++++++ ...ionableExpressionTypeResolverExtension.php | 139 ++++++++++++++ .../HigherOrderTapProxyExtension.php | 48 +++++ .../HigherOrderCollectionProxyHelper.php | 161 ++++++++++++++++ .../Support/HigherOrderWhenProxyHelper.php | 115 +++++++++++ tests/PHPStan/Integration/IntegrationTest.php | 12 ++ .../Integration/data/optional-callbacks.php | 23 +++ .../Integration/data/proxied-members.php | 33 ++++ tests/PHPStan/Type/data/conditionable.php | 158 +++++++++++++++ .../data/higher-order-collection-proxies.php | 111 +++++++++++ tests/PHPStan/Type/data/optional-helper.php | 74 +++++++ tests/PHPStan/Type/data/tappable.php | 37 ++++ tests/PHPStan/application/app/Importer.php | 28 +++ 21 files changed, 1632 insertions(+) create mode 100644 src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php create mode 100644 src/phpstan/src/Methods/HigherOrderTapProxyExtension.php create mode 100644 src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php create mode 100644 src/phpstan/src/Methods/OptionalExtension.php create mode 100644 src/phpstan/src/Properties/HigherOrderCollectionProxyPropertyExtension.php create mode 100644 src/phpstan/src/Properties/HigherOrderWhenProxyPropertyExtension.php create mode 100644 src/phpstan/src/Reflection/ProxiedMethodReflection.php create mode 100644 src/phpstan/src/Reflection/ProxiedParameterReflection.php create mode 100644 src/phpstan/src/Reflection/ProxiedPropertyReflection.php create mode 100644 src/phpstan/src/ReturnTypes/ConditionableExpressionTypeResolverExtension.php create mode 100644 src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php create mode 100644 src/phpstan/src/Support/HigherOrderCollectionProxyHelper.php create mode 100644 src/phpstan/src/Support/HigherOrderWhenProxyHelper.php create mode 100644 tests/PHPStan/Integration/data/optional-callbacks.php create mode 100644 tests/PHPStan/Integration/data/proxied-members.php create mode 100644 tests/PHPStan/Type/data/conditionable.php create mode 100644 tests/PHPStan/Type/data/higher-order-collection-proxies.php create mode 100644 tests/PHPStan/Type/data/optional-helper.php create mode 100644 tests/PHPStan/Type/data/tappable.php create mode 100644 tests/PHPStan/application/app/Importer.php diff --git a/src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php b/src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php new file mode 100644 index 000000000..3fa271061 --- /dev/null +++ b/src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php @@ -0,0 +1,38 @@ +higherOrderCollectionProxyHelper->hasPropertyOrMethod($classReflection, $methodName, 'method'); + } + + /** + * Get the method, returning what the proxied collection method returns. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->higherOrderCollectionProxyHelper->getMethod($classReflection, $methodName); + } +} diff --git a/src/phpstan/src/Methods/HigherOrderTapProxyExtension.php b/src/phpstan/src/Methods/HigherOrderTapProxyExtension.php new file mode 100644 index 000000000..fe3f10578 --- /dev/null +++ b/src/phpstan/src/Methods/HigherOrderTapProxyExtension.php @@ -0,0 +1,61 @@ +getTargetType($classReflection); + + // The proxy calls the method from outside the tapped object's class. + return $targetType !== null + && $targetType->hasMethod($methodName)->yes() + && $targetType->getMethod($methodName, new OutOfClassScope)->isPublic(); + } + + /** + * Get the tapped object's method, returning the tapped object. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + // hasMethod() accepts only proxies with a tapped object. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + + return new ProxiedMethodReflection( + $classReflection, + $targetType->getMethod($methodName, new OutOfClassScope), + static fn (): Type => $targetType, + ); + } + + /** + * Get the tapped object's type. + */ + private function getTargetType(ClassReflection $classReflection): ?Type + { + if ($classReflection->getName() !== HigherOrderTapProxy::class) { + return null; + } + + $targetType = $classReflection->getActiveTemplateTypeMap()->getType('TTarget'); + + return $targetType !== null && $targetType->getObjectClassReflections() !== [] ? $targetType : null; + } +} diff --git a/src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php b/src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php new file mode 100644 index 000000000..0821c89bc --- /dev/null +++ b/src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php @@ -0,0 +1,38 @@ +higherOrderWhenProxyHelper->hasPropertyOrMethod($classReflection, $methodName, 'method'); + } + + /** + * Get the method, returning what the proxy returns for it. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->higherOrderWhenProxyHelper->getMethod($classReflection, $methodName); + } +} diff --git a/src/phpstan/src/Methods/OptionalExtension.php b/src/phpstan/src/Methods/OptionalExtension.php new file mode 100644 index 000000000..9f8fdf160 --- /dev/null +++ b/src/phpstan/src/Methods/OptionalExtension.php @@ -0,0 +1,89 @@ +getTargetType($classReflection); + + // The Optional calls the method from outside the object's class. + return $targetType !== null + && $targetType->hasMethod($methodName)->yes() + && $targetType->getMethod($methodName, new OutOfClassScope)->isPublic(); + } + + /** + * Get the held object's method, returning what the Optional returns for it. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + // hasMethod() accepts only Optionals with a value type. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + + /** @var Type $valueType */ + $valueType = $classReflection->getActiveTemplateTypeMap()->getType('TValue'); + $isObject = $valueType->isObject(); + + return new ProxiedMethodReflection( + $classReflection, + $targetType->getMethod($methodName, new OutOfClassScope), + static fn (Type $returnType): Type => match (true) { + $isObject->no() => new NullType, + $isObject->yes() => $returnType, + default => TypeCombinator::addNull($returnType), + }, + ); + } + + /** + * Get the type of the object the Optional may hold. + */ + private function getTargetType(ClassReflection $classReflection): ?Type + { + if ($classReflection->getName() !== Optional::class) { + return null; + } + + $valueType = $classReflection->getActiveTemplateTypeMap()->getType('TValue'); + + if ($valueType === null) { + return null; + } + + // A value that is never an object accepts any call, as the Optional skips it. + if ($valueType->isObject()->no()) { + return new MixedType; + } + + $objectType = TypeCombinator::intersect($valueType, new ObjectWithoutClassType); + + // An object of unknown class may have any method, like a mixed value. + return $objectType->getObjectClassNames() === [] ? new MixedType : $objectType; + } +} diff --git a/src/phpstan/src/Properties/HigherOrderCollectionProxyPropertyExtension.php b/src/phpstan/src/Properties/HigherOrderCollectionProxyPropertyExtension.php new file mode 100644 index 000000000..553e7db1c --- /dev/null +++ b/src/phpstan/src/Properties/HigherOrderCollectionProxyPropertyExtension.php @@ -0,0 +1,38 @@ +higherOrderCollectionProxyHelper->hasPropertyOrMethod($classReflection, $propertyName, 'property'); + } + + /** + * Get the property, typed as what the proxied collection method returns. + */ + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return $this->higherOrderCollectionProxyHelper->getProperty($classReflection, $propertyName); + } +} diff --git a/src/phpstan/src/Properties/HigherOrderWhenProxyPropertyExtension.php b/src/phpstan/src/Properties/HigherOrderWhenProxyPropertyExtension.php new file mode 100644 index 000000000..5ab1f3fc5 --- /dev/null +++ b/src/phpstan/src/Properties/HigherOrderWhenProxyPropertyExtension.php @@ -0,0 +1,38 @@ +higherOrderWhenProxyHelper->hasPropertyOrMethod($classReflection, $propertyName, 'property'); + } + + /** + * Get the property, typed as what the proxy returns for it. + */ + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return $this->higherOrderWhenProxyHelper->getProperty($classReflection, $propertyName); + } +} diff --git a/src/phpstan/src/Reflection/ProxiedMethodReflection.php b/src/phpstan/src/Reflection/ProxiedMethodReflection.php new file mode 100644 index 000000000..73be63502 --- /dev/null +++ b/src/phpstan/src/Reflection/ProxiedMethodReflection.php @@ -0,0 +1,180 @@ +targetMethod->getName(); + } + + /** + * Return the proxy class. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Return the method docblock. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Return the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Return the target method's variants with the proxy's parameters and return types. + * + * @return list + */ + public function getVariants(): array + { + return array_map( + fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + // Other parameters stay as they are, keeping PHPStan's extended metadata such as closure-this types. + array_map( + static fn (ParameterReflection $parameter): ParameterReflection => $parameter->passedByReference()->no() + ? $parameter + : new ProxiedParameterReflection($parameter), + $variant->getParameters(), + ), + $variant->isVariadic(), + self::resolveStaticTypes(($this->returnType)($variant->getReturnType())), + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + ), + $this->targetMethod->getVariants(), + ); + } + + /** + * Resolve the static types in a type the proxy returns to their classes. PHPStan binds the + * static types of a proxy's members to the proxy, while they describe the proxy's target. + */ + public static function resolveStaticTypes(Type $type): Type + { + return TypeTraverser::map( + $type, + static fn (Type $type, callable $traverse): Type => $type instanceof StaticType ? $type->getStaticObjectType() : $traverse($type), + ); + } + + /** + * Determine whether the target method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return $this->targetMethod->isDeprecated(); + } + + /** + * Return the target method's deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return $this->targetMethod->getDeprecatedDescription(); + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the target method is internal. + */ + public function isInternal(): TrinaryLogic + { + return $this->targetMethod->isInternal(); + } + + /** + * Return the target method's throw type. + */ + public function getThrowType(): ?Type + { + return $this->targetMethod->getThrowType(); + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } +} diff --git a/src/phpstan/src/Reflection/ProxiedParameterReflection.php b/src/phpstan/src/Reflection/ProxiedParameterReflection.php new file mode 100644 index 000000000..57432b22b --- /dev/null +++ b/src/phpstan/src/Reflection/ProxiedParameterReflection.php @@ -0,0 +1,72 @@ +parameter->getName(); + } + + /** + * Determine whether the parameter is optional. + */ + public function isOptional(): bool + { + return $this->parameter->isOptional(); + } + + /** + * Return the parameter type. + */ + public function getType(): Type + { + return $this->parameter->getType(); + } + + /** + * Return the parameter reference mode. + */ + public function passedByReference(): PassedByReference + { + return PassedByReference::createNo(); + } + + /** + * Determine whether the parameter is variadic. + */ + public function isVariadic(): bool + { + return $this->parameter->isVariadic(); + } + + /** + * Return the default value type. + */ + public function getDefaultValue(): ?Type + { + return $this->parameter->getDefaultValue(); + } +} diff --git a/src/phpstan/src/Reflection/ProxiedPropertyReflection.php b/src/phpstan/src/Reflection/ProxiedPropertyReflection.php new file mode 100644 index 000000000..04631921f --- /dev/null +++ b/src/phpstan/src/Reflection/ProxiedPropertyReflection.php @@ -0,0 +1,139 @@ +type = ProxiedMethodReflection::resolveStaticTypes($type); + } + + /** + * Return the proxy class. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + /** + * Determine whether the property is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the property is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the property is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Return the property docblock. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Get the type read from the property. + */ + public function getReadableType(): Type + { + return $this->type; + } + + /** + * Get the type written to the property. + */ + public function getWritableType(): Type + { + return $this->type; + } + + /** + * Determine whether an assignment can change the property's type. + */ + public function canChangeTypeAfterAssignment(): bool + { + return false; + } + + /** + * Determine whether the property is readable. + */ + public function isReadable(): bool + { + return true; + } + + /** + * Determine whether the property is writable. + */ + public function isWritable(): bool + { + return false; + } + + /** + * Determine whether the target property is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return $this->targetProperty?->isDeprecated() ?? TrinaryLogic::createNo(); + } + + /** + * Get the target property's deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return $this->targetProperty?->getDeprecatedDescription(); + } + + /** + * Determine whether the target property is internal. + */ + public function isInternal(): TrinaryLogic + { + return $this->targetProperty?->isInternal() ?? TrinaryLogic::createNo(); + } +} diff --git a/src/phpstan/src/ReturnTypes/ConditionableExpressionTypeResolverExtension.php b/src/phpstan/src/ReturnTypes/ConditionableExpressionTypeResolverExtension.php new file mode 100644 index 000000000..b44e80afd --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConditionableExpressionTypeResolverExtension.php @@ -0,0 +1,139 @@ +name instanceof Identifier + || ! in_array($expr->name->toLowerString(), ['when', 'unless'], true) + || $expr->isFirstClassCallable()) { + return null; + } + + $argumentCount = $this->countArguments($expr->getArgs()); + + if ($argumentCount === null) { + return null; + } + + $calledOnType = $scope->getType($expr->var); + $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); + + if ($methodReflection === null || ! $this->isConditionable($methodReflection)) { + return null; + } + + // Conditionable passes $this, so the proxy's target is the called object. + if ($argumentCount < 2) { + return new GenericObjectType(HigherOrderWhenProxy::class, [$calledOnType, new ConstantBooleanType($argumentCount === 1)]); + } + + $variant = ParametersAcceptorSelector::selectFromArgs($scope, $expr->getArgs(), $methodReflection->getVariants()); + + // Conditionable declares these templates, so isConditionable() guarantees them. + /** @var Type $callbackReturnType */ + $callbackReturnType = $variant->getResolvedTemplateTypeMap()->getType( + $expr->name->toLowerString() === 'when' ? 'TWhenReturnType' : 'TUnlessReturnType', + ); + + // The template holds what the callback or default returns other than null, and the call + // returns the instance otherwise. PHPStan leaves the template an error type when no + // callback or default returns a value, where its declared default is never. + return $callbackReturnType instanceof ErrorType + ? $calledOnType + : TypeCombinator::union($calledOnType, $callbackReturnType); + } + + /** + * Count the arguments as func_num_args() does: up to the last parameter given. + * + * @param list $args + */ + private function countArguments(array $args): ?int + { + $count = 0; + + foreach ($args as $position => $arg) { + if ($arg->unpack) { + return null; + } + + $index = $arg->name === null ? $position : array_search($arg->name->toString(), self::PARAMETERS, true); + + if ($index === false) { + return null; + } + + $count = max($count, $index + 1); + } + + return $count; + } + + /** + * Determine whether the method is Conditionable's own implementation. + */ + private function isConditionable(ExtendedMethodReflection $methodReflection): bool + { + $declaringClass = $methodReflection->getDeclaringClass(); + + // A trait method keeps the trait's file. + return $declaringClass->hasNativeMethod($methodReflection->getName()) + && $declaringClass->getNativeReflection()->getMethod($methodReflection->getName())->getFileName() + === $this->reflectionProvider->getClass(Conditionable::class)->getFileName(); + } +} diff --git a/src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php b/src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php new file mode 100644 index 000000000..f70727a07 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php @@ -0,0 +1,48 @@ +getType($methodCall->var)->getTemplateType(HigherOrderTapProxy::class, 'TTarget'); + + return $targetType->getObjectClassNames() === [] ? new MixedType : $targetType; + } +} diff --git a/src/phpstan/src/Support/HigherOrderCollectionProxyHelper.php b/src/phpstan/src/Support/HigherOrderCollectionProxyHelper.php new file mode 100644 index 000000000..ba0130931 --- /dev/null +++ b/src/phpstan/src/Support/HigherOrderCollectionProxyHelper.php @@ -0,0 +1,161 @@ +getTargetType($classReflection); + + if ($targetType === null) { + return false; + } + + // The proxy reaches only public members, since it runs outside the target's class. + if ($propertyOrMethod === 'property') { + // The proxy reads array items by key. + return $targetType->isArray()->yes() + ? $targetType->hasOffsetValueType(new ConstantStringType($name))->yes() + : $targetType->hasInstanceProperty($name)->yes() && $targetType->getInstanceProperty($name, new OutOfClassScope)->isPublic(); + } + + // The proxy calls the static method of class-string items. + if ($targetType->isClassString()->yes()) { + $classType = $targetType->getClassStringObjectType(); + + if (! $classType->hasMethod($name)->yes()) { + return false; + } + + $method = $classType->getMethod($name, new OutOfClassScope); + + return $method->isStatic() && $method->isPublic(); + } + + return $targetType->hasMethod($name)->yes() && $targetType->getMethod($name, new OutOfClassScope)->isPublic(); + } + + /** + * Get the property read through the proxy. + */ + public function getProperty(ClassReflection $classReflection, string $name): PropertyReflection + { + // hasProperty() accepts only proxies with a target. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + + if ($targetType->isArray()->yes()) { + return new ProxiedPropertyReflection( + $classReflection, + $this->getReturnType($classReflection, $targetType->getOffsetValueType(new ConstantStringType($name))), + null, + ); + } + + $targetProperty = $targetType->getInstanceProperty($name, new OutOfClassScope); + + return new ProxiedPropertyReflection($classReflection, $this->getReturnType($classReflection, $targetProperty->getReadableType()), $targetProperty); + } + + /** + * Get the method called through the proxy. + */ + public function getMethod(ClassReflection $classReflection, string $name): MethodReflection + { + // hasMethod() accepts only proxies with a target. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + + if ($targetType->isClassString()->yes()) { + $targetType = $targetType->getClassStringObjectType(); + } + + return new ProxiedMethodReflection( + $classReflection, + $targetType->getMethod($name, new OutOfClassScope), + fn (Type $returnType): Type => $this->getReturnType($classReflection, $returnType), + ); + } + + /** + * Get what the proxied collection method returns for a callback returning the given type. + */ + private function getReturnType(ClassReflection $classReflection, Type $callbackReturnType): Type + { + $templateTypeMap = $classReflection->getActiveTemplateTypeMap(); + + // getTargetType() accepts only proxies with these templates. + /** @var Type $methodType */ + $methodType = $templateTypeMap->getType('TMethod'); + + /** @var Type $collectionType */ + $collectionType = $templateTypeMap->getType('TCollection'); + + return ParametersAcceptorSelector::selectFromTypes( + [new ClosureType([], $callbackReturnType, false)], + $collectionType->getMethod($methodType->getConstantStrings()[0]->getValue(), new OutOfClassScope)->getVariants(), + false, + )->getReturnType(); + } + + /** + * Get the type the proxy calls members on: the collection items, or the collection itself + * for when() and unless(), which pass the collection to the callback. + */ + private function getTargetType(ClassReflection $classReflection): ?Type + { + if ($classReflection->getName() !== HigherOrderCollectionProxy::class) { + return null; + } + + $templateTypeMap = $classReflection->getActiveTemplateTypeMap(); + $methodType = $templateTypeMap->getType('TMethod'); + $valueType = $templateTypeMap->getType('TValue'); + $collectionType = $templateTypeMap->getType('TCollection'); + + if ($methodType === null || $valueType === null || $collectionType === null || count($methodType->getConstantStrings()) !== 1) { + return null; + } + + $method = $methodType->getConstantStrings()[0]->getValue(); + + if (! $collectionType->hasMethod($method)->yes()) { + return null; + } + + return in_array($method, ['when', 'unless'], true) ? $collectionType : $valueType; + } +} diff --git a/src/phpstan/src/Support/HigherOrderWhenProxyHelper.php b/src/phpstan/src/Support/HigherOrderWhenProxyHelper.php new file mode 100644 index 000000000..7580c3af9 --- /dev/null +++ b/src/phpstan/src/Support/HigherOrderWhenProxyHelper.php @@ -0,0 +1,115 @@ +getTargetType($classReflection); + + if ($targetType === null) { + return false; + } + + // The proxy reaches only public members, since it runs outside the target's class. + return $propertyOrMethod === 'method' + ? $targetType->hasMethod($name)->yes() && $targetType->getMethod($name, new OutOfClassScope)->isPublic() + : $targetType->hasInstanceProperty($name)->yes() && $targetType->getInstanceProperty($name, new OutOfClassScope)->isPublic(); + } + + /** + * Get the property read through the proxy. + */ + public function getProperty(ClassReflection $classReflection, string $name): PropertyReflection + { + // hasProperty() accepts only proxies with a target. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + $targetProperty = $targetType->getInstanceProperty($name, new OutOfClassScope); + + return new ProxiedPropertyReflection($classReflection, $this->getReturnType($classReflection, $targetProperty->getReadableType()), $targetProperty); + } + + /** + * Get the method called through the proxy. + */ + public function getMethod(ClassReflection $classReflection, string $name): MethodReflection + { + // hasMethod() accepts only proxies with a target. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + + return new ProxiedMethodReflection( + $classReflection, + $targetType->getMethod($name, new OutOfClassScope), + fn (Type $returnType): Type => $this->getReturnType($classReflection, $returnType), + ); + } + + /** + * Get what the proxy returns for a member of the given type. + */ + private function getReturnType(ClassReflection $classReflection, Type $memberType): Type + { + $templateTypeMap = $classReflection->getActiveTemplateTypeMap(); + + // getTargetType() accepts only proxies with a target. + /** @var Type $targetType */ + $targetType = $templateTypeMap->getType('TTarget'); + $hasCondition = $templateTypeMap->getType('THasCondition') ?? new BooleanType; + $types = []; + + if (! $hasCondition->isTrue()->yes()) { + $types[] = new GenericObjectType(HigherOrderWhenProxy::class, [$targetType, new ConstantBooleanType(true)]); + } + + if (! $hasCondition->isFalse()->yes()) { + $types[] = $memberType; + $types[] = $targetType; + } + + return TypeCombinator::union(...$types); + } + + /** + * Get the object the proxy passes members to. + */ + private function getTargetType(ClassReflection $classReflection): ?Type + { + if ($classReflection->getName() !== HigherOrderWhenProxy::class) { + return null; + } + + $targetType = $classReflection->getActiveTemplateTypeMap()->getType('TTarget'); + + return $targetType !== null && $targetType->isObject()->yes() ? $targetType : null; + } +} diff --git a/tests/PHPStan/Integration/IntegrationTest.php b/tests/PHPStan/Integration/IntegrationTest.php index 31e1246ba..41c5b73b5 100644 --- a/tests/PHPStan/Integration/IntegrationTest.php +++ b/tests/PHPStan/Integration/IntegrationTest.php @@ -89,6 +89,18 @@ public static function dataIntegrationTests(): iterable 13 => ['Parameter #1 $view of function view expects view-string|null, string given.'], ], ]; + + // Hypervel: optional() passes its callback only values that are not null. + yield [__DIR__ . '/data/optional-callbacks.php']; + + // Hypervel: a higher-order proxy runs outside the item's class, so it reaches only public members. + yield [ + __DIR__ . '/data/proxied-members.php', + [ + 30 => ['Call to private method secret() of class ProxiedMembers\Score.'], + 31 => ['Access to protected property Hypervel\Support\HigherOrderCollectionProxy>::$rank.'], + ], + ]; } /** diff --git a/tests/PHPStan/Integration/data/optional-callbacks.php b/tests/PHPStan/Integration/data/optional-callbacks.php new file mode 100644 index 000000000..ae2dc85e0 --- /dev/null +++ b/tests/PHPStan/Integration/data/optional-callbacks.php @@ -0,0 +1,23 @@ + + */ +function test(?Bar $maybeBar, ?int $maybeInt): array +{ + return [ + optional($maybeBar, fn (Bar $bar): string => $bar->name()), + optional($maybeInt, fn (int $value): int => $value + 1), + optional($maybeBar, fn ($bar) => $bar->name()), + ]; +} diff --git a/tests/PHPStan/Integration/data/proxied-members.php b/tests/PHPStan/Integration/data/proxied-members.php new file mode 100644 index 000000000..c3dbdc88f --- /dev/null +++ b/tests/PHPStan/Integration/data/proxied-members.php @@ -0,0 +1,33 @@ +secret(); + } + + private function secret(): int + { + return $this->rank; + } +} + +/** + * @param Collection $scores + * + * @return list + */ +function test(Collection $scores): array +{ + return [ + $scores->map->secret(), + $scores->map->rank, + ]; +} diff --git a/tests/PHPStan/Type/data/conditionable.php b/tests/PHPStan/Type/data/conditionable.php new file mode 100644 index 000000000..278b38486 --- /dev/null +++ b/tests/PHPStan/Type/data/conditionable.php @@ -0,0 +1,158 @@ + $query */ +function test(Builder $query): void +{ + assertType('ConditionableStubs\Foo', (new Foo())->when(true, function (Foo $foo) { + // do nothing + })); + + assertType('ConditionableStubs\Foo', (new Foo())->when(true, function (Foo $foo) { + return null; + })); + + // Hypervel: a false condition returns the instance, so it stays in the type. + assertType('ConditionableStubs\Foo|int<0, max>', (new Foo())->when(true, function (Foo $foo): int { + return rand(); + })); + + // Test to make sure the callback has a non-null value. + (new Foo())->when(User::first(), function (Foo $foo, $user): void { + assertType(User::class, $user); + }); + + assertType('ConditionableStubs\Foo', (new Foo())->unless(true, function (Foo $foo) { + // do nothing + })); + + assertType('ConditionableStubs\Foo', (new Foo())->unless(true, function (Foo $foo) { + return null; + })); + + // Hypervel: a true condition returns the instance, so it stays in the type. + assertType('ConditionableStubs\Foo|int<0, max>', (new Foo())->unless(true, function (Foo $foo): int { + return rand(); + })); + + assertType('Hypervel\Database\Eloquent\Builder', $query->when(true, static function (Builder $query): Builder { + /** @phpstan-var Builder $query */ + return $query->whereNull('name'); + })); +} + +class Logger +{ + use Conditionable; + + public bool $toggle = false; + + public function has(string $key): bool + { + return true; + } + + public function log(string $message): static + { + return $this; + } + + private function flush(): void + { + } +} + +/** + * Hypervel: without a callback, when() and unless() return a proxy. Given a value, it takes the + * condition from the value; otherwise it takes the condition from the next property or method. + * + * @param Collection $numbers + */ +function higherOrder(Logger $logger, Collection $numbers, ?string $maybe, callable $callback): void +{ + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->when()); + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->unless()); + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->when($maybe)); + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->unless(true)); + + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->when()->has('init')); + assertType('ConditionableStubs\Logger', $logger->when()->has('init')->log('three')); + assertType('ConditionableStubs\Logger', $logger->unless()->toggle->log('five')); + assertType('bool|ConditionableStubs\Logger', $logger->when(true)->toggle); + assertType('Hypervel\Support\Collection', $numbers->when()->isNotEmpty()->map(fn ($number) => $number + 1)); + + // The proxy runs outside the target's class, so it reaches only public members. + assertType('*ERROR*', $logger->when(true)->flush()); + + // A callback argument, even null, makes the call return the instance or the default's result. + assertType('ConditionableStubs\Logger', $logger->when(false, null)); + assertType("'fallback'|ConditionableStubs\Logger", $logger->when(false, null, fn () => 'fallback')); + assertType("'fallback'|ConditionableStubs\Logger", $logger->unless(true, null, fn () => 'fallback')); + assertType("'default'|ConditionableStubs\Logger", $logger->when(default: fn () => 'default')); + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->when(value: true)); + + // What the callback or default returns is kept, even a proxy or mixed. + $captured = $logger->when(); + assertType('ConditionableStubs\Logger|Hypervel\Support\HigherOrderWhenProxy', $logger->when(true, fn () => $captured)); + assertType('ConditionableStubs\Logger|Hypervel\Support\HigherOrderWhenProxy', $logger->when(false, null, fn () => $captured)); + assertType('mixed', $logger->when(true, $callback)); + + // Setting the condition changes the proxy itself. + $proxy = new HigherOrderWhenProxy($logger); + $proxy->condition(true); + assertType('Hypervel\Support\HigherOrderWhenProxy', $proxy); +} + +class CustomWhen +{ + /** @return HigherOrderWhenProxy */ + public function when(mixed $value = null): HigherOrderWhenProxy + { + return (new HigherOrderWhenProxy(new Logger))->condition(true); + } +} + +function customWhen(CustomWhen $custom): void +{ + // A method that is not Conditionable's keeps its own declaration. + assertType('Hypervel\Support\HigherOrderWhenProxy', $custom->when()); +} + +class Formatter +{ + public function format(Logger $logger): void + { + // A callback's own $this stays the callback's class. + assertType('$this(ConditionableStubs\Formatter)|ConditionableStubs\Logger', $logger->when(true, fn () => $this)); + } +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class Numbers extends Collection +{ + public static function strings(): void + { + // The proxy keeps the called object's types, and its members' static types mean that object. + assertType('Hypervel\Support\HigherOrderWhenProxy), true>', static::range(1, 3)->unless(false)); + assertType('ConditionableStubs\Numbers|ConditionableStubs\Numbers', static::range(1, 3)->unless(false)->map(fn ($number) => (string) $number)); + } +} diff --git a/tests/PHPStan/Type/data/higher-order-collection-proxies.php b/tests/PHPStan/Type/data/higher-order-collection-proxies.php new file mode 100644 index 000000000..0c08c5749 --- /dev/null +++ b/tests/PHPStan/Type/data/higher-order-collection-proxies.php @@ -0,0 +1,111 @@ +points; + } + + public function ratio(): float + { + return $this->ratio; + } +} + +/** + * @param Collection $importers + * @param Collection $scores + */ +function test(Collection $importers, Collection $scores): void +{ + // Averages may be integers, and an empty collection has no average, maximum or minimum. + assertType('float|int|null', $scores->avg->points()); + assertType('float|int|null', $scores->average->ratio); + assertType('int|null', $scores->max->points()); + assertType('float|null', $scores->min->ratio); + + // Proxies return what the collection method returns, and an integer sum can overflow to a float. + assertType('float|int', $scores->sum->points); + assertType('float|int', $scores->sum->ratio()); + + // Upstream has no proxies for these. + assertType('bool', $importers->doesntContain->isImported()); + assertType('bool', $importers->hasMany->isImported()); + assertType('bool', $importers->hasSole->isImported); + assertType('App\Importer|null', $importers->last->isImported()); + assertType('App\Importer', $importers->sole->isImported()); + assertType('float|null', $importers->percentage->isImported()); +} + +class Seeder +{ + public static function run(): bool + { + return true; + } + + public function name(): string + { + return ''; + } +} + +/** + * Hypervel: proxies keep the collection's keys and class where the collection method does. + * + * @param Collection $scores + * @param LazyCollection $lazyScores + * @param EloquentCollection $users + * @param Collection $rows + * @param Collection> $seeders + */ +function keysAndClasses(Collection $scores, LazyCollection $lazyScores, EloquentCollection $users, Collection $rows, Collection $seeders): void +{ + assertType('Hypervel\Support\Collection', $scores->map->points); + assertType('Hypervel\Support\Collection', $scores->filter->points()); + assertType('Hypervel\Support\Collection', $scores->sortBy->ratio); + assertType('Hypervel\Support\Collection, Hypervel\Support\Collection>', $scores->partition->points); + assertType('Hypervel\Support\Collection>', $scores->groupBy->points); + assertType('Hypervel\Support\Collection', $scores->keyBy->points); + + assertType('Hypervel\Support\LazyCollection', $lazyScores->map->ratio()); + assertType('Hypervel\Support\LazyCollection', $lazyScores->reject->points); + + assertType('Hypervel\Support\Collection', $users->map->getAllCapsName()); + assertType('Hypervel\Support\Collection, Hypervel\Database\Eloquent\Collection>', $users->partition->isActive()); + + // The proxy reads array items by key, and calls the static methods of class-string items. + assertType('Hypervel\Support\Collection', $rows->map->active); + assertType('Hypervel\Support\Collection', $rows->filter->active); + assertType('Hypervel\Support\Collection', $seeders->map->run()); +} + +/** + * @template TKey of array-key + * @template TValue of Score + * + * @extends Collection + */ +class Scores extends Collection +{ + public function points(): void + { + // A static collection type means the collection, not the proxy. + assertType('HigherOrderCollectionProxies\Scores', $this->map->points()); + } +} diff --git a/tests/PHPStan/Type/data/optional-helper.php b/tests/PHPStan/Type/data/optional-helper.php new file mode 100644 index 000000000..530ebf6ab --- /dev/null +++ b/tests/PHPStan/Type/data/optional-helper.php @@ -0,0 +1,74 @@ +', optional(['foo' => 'bar'])); + assertType('1', optional('1', function (string $value): int { + return 1; + })); + assertType('null', optional(null, function (string $value): int { + return 1; + })); + assertType('Hypervel\Support\Optional', optional(new StdClass())); + assertType('mixed', optional(new StdClass())->foo); + assertType('null', optional(new Foo)->doFoo()); + + // Hypervel: method calls go to the object the Optional holds, and return null when it may hold something else. + assertType('string', optional(new Bar)->name()); + assertType('string|null', optional($maybeBar)->name()); + assertType('string|null', optional($barOrString)->name()); + assertType('mixed', optional($mixed)->anything()); + assertType('null', optional(null)->anything()); + assertType('mixed', optional($maybeBar)['key']); + + // Hypervel: proxies reach only public methods, and pass argument values rather than references. + assertType('*ERROR*', optional(new Bar)->secret()); + $value = 1; + optional(new Bar)->fill($value); + assertType('1', $value); +} + +/** + * Hypervel: the callback only receives a value that is not null. + */ +function callbacks(?Bar $maybeBar, ?int $maybeInt): void +{ + optional($maybeBar, function ($bar) { + assertType('OptionalType\Bar', $bar); + }); + optional($maybeInt, function ($value) { + assertType('int', $value); + }); + assertType('string|null', optional($maybeBar, fn (Bar $bar): string => $bar->name())); + assertType('Hypervel\Support\Optional', optional($maybeBar)); +} diff --git a/tests/PHPStan/Type/data/tappable.php b/tests/PHPStan/Type/data/tappable.php new file mode 100644 index 000000000..977b58998 --- /dev/null +++ b/tests/PHPStan/Type/data/tappable.php @@ -0,0 +1,37 @@ +tap()->name()); + } +} + +function test(): void +{ + assertType( + 'Tappable\TappableClass', + (new TappableClass())->tap(function (TappableClass $tappable) { + }), + ); + + assertType( + 'Hypervel\Support\HigherOrderTapProxy', + (new TappableClass())->tap(), + ); +} diff --git a/tests/PHPStan/application/app/Importer.php b/tests/PHPStan/application/app/Importer.php new file mode 100644 index 000000000..c551e8bc8 --- /dev/null +++ b/tests/PHPStan/application/app/Importer.php @@ -0,0 +1,28 @@ + 0; + } + + public function import(): bool + { + return random_int(0, 1) > 0; + } + + public function getKey(): string + { + return Str::random(5); + } +} From 9acbd3472d688150f9fc5634349d4cfdb5920df1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:56:00 +0000 Subject: [PATCH 062/303] feat: analyze Arr lookups and pulled array shapes Infer Arr::get and Arr::pull values, defaults and supported array mutations through the real dot-key lookup semantics. Report definitely missing keys while respecting explicit defaults and custom delegated implementations. Add type and diagnostic fixtures for nested keys, optional shapes, numeric keys and overridden get or forget methods. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- ...DynamicStaticMethodReturnTypeExtension.php | 93 +++++++++ src/phpstan/src/Rules/ArrKeyExistenceRule.php | 85 ++++++++ src/phpstan/src/Support/ArrKeyLookup.php | 129 ++++++++++++ .../ArrPullParameterOutTypeExtension.php | 78 +++++++ .../PHPStan/Rules/ArrKeyExistenceRuleTest.php | 48 +++++ .../PHPStan/Rules/data/arr-key-existence.php | 83 ++++++++ tests/PHPStan/Type/data/arr-get-pull.php | 195 ++++++++++++++++++ 7 files changed, 711 insertions(+) create mode 100644 src/phpstan/src/ReturnTypes/ArrGetDynamicStaticMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/Rules/ArrKeyExistenceRule.php create mode 100644 src/phpstan/src/Support/ArrKeyLookup.php create mode 100644 src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php create mode 100644 tests/PHPStan/Rules/ArrKeyExistenceRuleTest.php create mode 100644 tests/PHPStan/Rules/data/arr-key-existence.php create mode 100644 tests/PHPStan/Type/data/arr-get-pull.php diff --git a/src/phpstan/src/ReturnTypes/ArrGetDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ArrGetDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 000000000..af64bdc25 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ArrGetDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,93 @@ +getName(), ['get', 'pull'], true); + } + + /** + * Get the type of the value the key can find, or of the default when it may be missing. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $arrayArg = $methodCall->getArg('array', 0); + $keyArg = $methodCall->getArg('key', 1); + + if ($arrayArg === null || $keyArg === null || ! ArrKeyLookup::appliesTo($scope, $methodCall, [$methodReflection->getName(), ...ArrKeyLookup::GET_METHODS])) { + return null; + } + + $lookups = ArrKeyLookup::find($scope, $arrayArg->value, $keyArg->value); + + if ($lookups === null) { + return null; + } + + $types = []; + $mayMiss = false; + + foreach ($lookups as $candidates) { + $exists = TrinaryLogic::createNo(); + + foreach ($candidates as [$found, $access]) { + if (! $found->no()) { + $types[] = $scope->getType($access); + } + + $exists = $exists->or($found); + } + + $mayMiss = $mayMiss || ! $exists->yes(); + } + + if ($mayMiss) { + $defaultArg = $methodCall->getArg('default', 2); + + // Arr::get() returns value($default), which calls a Closure default. + $types[] = $defaultArg === null + ? new NullType + : $scope->getType(new FuncCall(new FullyQualified('value'), [new Arg($defaultArg->value)])); + } + + return TypeCombinator::union(...$types); + } +} diff --git a/src/phpstan/src/Rules/ArrKeyExistenceRule.php b/src/phpstan/src/Rules/ArrKeyExistenceRule.php new file mode 100644 index 000000000..6f207569d --- /dev/null +++ b/src/phpstan/src/Rules/ArrKeyExistenceRule.php @@ -0,0 +1,85 @@ + + */ +#[AutowiredService] +final class ArrKeyExistenceRule implements Rule +{ + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Report the missing offset of an Arr::get() or Arr::pull() key that never exists. + * + * @param NodeCallbackInvoker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Identifier || ! $node->class instanceof Name) { + return []; + } + + $methodName = strtolower($node->name->name); + + if (! in_array($methodName, ['get', 'pull'], true) || ! ArrKeyLookup::appliesTo($scope, $node, [$methodName, ...ArrKeyLookup::GET_METHODS])) { + return []; + } + + $arrayArg = $node->getArg('array', 0); + $keyArg = $node->getArg('key', 1); + + // A default means the caller expects the key may be missing. + if ($arrayArg === null || $keyArg === null || $node->getArg('default', 2) !== null) { + return []; + } + + $lookups = ArrKeyLookup::find($scope, $arrayArg->value, $keyArg->value) ?? []; + + foreach ($lookups as $candidates) { + foreach ($candidates as [$found]) { + if (! $found->no()) { + return []; + } + } + } + + foreach ($lookups as $candidates) { + // The last candidate is the "dot" path when there is one. + [, $missing] = $candidates[count($candidates) - 1]; + $missing->setAttributes($node->getAttributes()); + $scope->invokeNodeCallback($missing); + } + + return []; + } +} diff --git a/src/phpstan/src/Support/ArrKeyLookup.php b/src/phpstan/src/Support/ArrKeyLookup.php new file mode 100644 index 000000000..b8bf2f618 --- /dev/null +++ b/src/phpstan/src/Support/ArrKeyLookup.php @@ -0,0 +1,129 @@ + $methods + */ + public static function appliesTo(Scope $scope, StaticCall $call, array $methods): bool + { + if (! $call->class instanceof Name) { + return false; + } + + $calledType = $scope->resolveTypeByName($call->class); + + foreach ($methods as $method) { + if ($scope->getMethodReflection($calledType, $method)?->getDeclaringClass()->getName() !== Arr::class) { + return false; + } + } + + return true; + } + + /** + * Get, for each possible key, the lookups Arr::get() may take: the literal key, then its + * "dot" path unless the literal key always exists. Each lookup is whether it finds a value and + * the access that reads it, or the first missing access. + * + * @return null|list> null when the key isn't + * an integer or constant string + */ + public static function find(Scope $scope, Expr $array, Expr $key): ?array + { + $keyType = $scope->getType($key); + + if ($keyType->isInteger()->yes()) { + return [[self::lookup($scope, $array, [$key])]]; + } + + $constantStrings = $keyType->getConstantStrings(); + + if ($constantStrings === []) { + return null; + } + + $results = []; + + foreach ($constantStrings as $constantString) { + $value = $constantString->getValue(); + $literal = self::lookup($scope, $array, [new String_($value)]); + + $results[] = $literal[0]->yes() || ! str_contains($value, '.') + ? [$literal] + : [$literal, self::lookup($scope, $array, array_map(static fn ($segment) => new String_($segment), explode('.', $value)))]; + } + + return $results; + } + + /** + * Follow the keys through the array, stopping at the first one that never exists. + * + * @param non-empty-list $keys + * + * @return array{TrinaryLogic, ArrayDimFetch} + */ + private static function lookup(Scope $scope, Expr $array, array $keys): array + { + $exists = TrinaryLogic::createYes(); + $value = $array; + + foreach ($keys as $key) { + $exists = $exists->and(self::keyExists($scope->getType($value), $scope->getType($key))); + $value = new ArrayDimFetch($value, $key); + + if ($exists->no()) { + break; + } + } + + return [$exists, $value]; + } + + /** + * Determine whether Arr::get() finds the key in a value of the given type. + * + * Arr::get() only reads arrays and ArrayAccess objects, never string offsets. Arrays cast + * numeric-string keys like '0' to integers; offsetExists() receives them unchanged. + */ + private static function keyExists(Type $type, Type $keyType): TrinaryLogic + { + return $type->isArray()->and($type->hasOffsetValueType($keyType->toArrayKey())) + ->or((new ObjectType(ArrayAccess::class))->isSuperTypeOf($type)->result->and($type->hasOffsetValueType($keyType))); + } +} diff --git a/src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php b/src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php new file mode 100644 index 000000000..e9ac88e9e --- /dev/null +++ b/src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php @@ -0,0 +1,78 @@ +getName() === 'pull' + && $parameter->getName() === 'array' + && $methodReflection->getDeclaringClass()->getName() === Arr::class; + } + + /** + * Get the array type without the pulled key. + */ + public function getParameterOutTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + ParameterReflection $parameter, + Scope $scope, + ): ?Type { + $arrayArg = $methodCall->getArg('array', 0); + $keyArg = $methodCall->getArg('key', 1); + + // pull() removes the key through forget(), which checks keys through accessible() and exists(). + if ($arrayArg === null || $keyArg === null || ! ArrKeyLookup::appliesTo($scope, $methodCall, ['pull', 'forget', 'accessible', 'exists'])) { + return null; + } + + $arrayType = $scope->getType($arrayArg->value); + + if (! $arrayType->isArray()->yes()) { + return null; + } + + $lookups = ArrKeyLookup::find($scope, $arrayArg->value, $keyArg->value); + + if ($lookups === null) { + return null; + } + + foreach ($lookups as $candidates) { + // Arr::forget() may remove a "dot" path instead of the literal key. + if (count($candidates) > 1) { + return null; + } + } + + // unset() on a list mishandles numeric strings like '0', so pass the integer key instead. + return $arrayType->unsetOffset($scope->getType($keyArg->value)->toArrayKey()); + } +} diff --git a/tests/PHPStan/Rules/ArrKeyExistenceRuleTest.php b/tests/PHPStan/Rules/ArrKeyExistenceRuleTest.php new file mode 100644 index 000000000..0c26ced92 --- /dev/null +++ b/tests/PHPStan/Rules/ArrKeyExistenceRuleTest.php @@ -0,0 +1,48 @@ + */ +class ArrKeyExistenceRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new CompositeRule([ + new ArrKeyExistenceRule, + self::getContainer()->getByType(NonexistentOffsetInArrayDimFetchRule::class), + ]); + } + + public function testRule(): void + { + $shape = 'array{foo: string, bar?: int, nested: array{deep: bool}, word: string}'; + + $this->analyse([__DIR__ . '/data/arr-key-existence.php'], [ + ["Offset 'bar' does not exist on array{foo: '123'}.", 15], + ["Offset 'missing' does not exist on " . $shape . '.', 45], + ["Offset 'missing' does not exist on array{deep: bool}.", 46], + ["Offset '0' does not exist on string.", 47], + ["Offset 'missing' does not exist on " . $shape . '.', 48], + ["Offset 'other' does not exist on " . $shape . '.', 48], + ["Offset 'missing' does not exist on array{foo: string}|null.", 49], + ["Offset 'missing' does not exist on " . $shape . '.', 50], + ["Offset 'missing' does not exist on " . $shape . '.', 51], + ["Offset 'missing' does not exist on " . $shape . '.', 52], + ["Offset 'missing' does not exist on " . $shape . '.', 53], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/data/arr-key-existence.php b/tests/PHPStan/Rules/data/arr-key-existence.php new file mode 100644 index 000000000..ca7bcd977 --- /dev/null +++ b/tests/PHPStan/Rules/data/arr-key-existence.php @@ -0,0 +1,83 @@ + '123', + ]; + + Arr::pull($array, 'bar'); +} + +class CustomArr extends Arr +{ +} + +/** + * @param array{foo: string, bar?: int, nested: array{deep: bool}, word: string} $shape + * @param array $map + * @param Collection $collection + * @param array{foo: string}|null $nullableShape + * @param 'foo'|'missing' $fooOrMissing + * @param 'missing'|'other' $missingKeys + */ +function test(array $shape, array $map, Collection $collection, ?array $nullableShape, string $fooOrMissing, string $missingKeys, string $anyKey, mixed $mixed): void +{ + Arr::get($shape, 'foo'); + Arr::get($shape, 'bar'); + Arr::get($shape, 'nested.deep'); + Arr::get($shape, null); + Arr::get($shape, $fooOrMissing); + Arr::get($shape, $anyKey); + Arr::get($map, 'anything'); + Arr::get($collection, 'anything'); + Arr::get($mixed, 'anything'); + Arr::get((new User())->toArray(), 'anything'); + Arr::get($shape, 'missing', 'default'); + Arr::pull($shape, 'missing', 'default'); + + Arr::get($shape, 'missing'); + Arr::get($shape, 'nested.missing'); + Arr::get($shape, 'word.0'); + Arr::get($shape, $missingKeys); + Arr::get($nullableShape, 'missing'); + Arr::get(key: 'missing', array: $shape); + Arr::GET($shape, 'missing'); + CustomArr::get($shape, 'missing'); + Arr::pull($shape, 'missing'); +} + +/** @param array{foo: string}|ArrKeyExistenceAccess $shapeOrAccess */ +function literalDotKeysAndArrayAccess(array|ArrKeyExistenceAccess $shapeOrAccess, ArrKeyExistenceAccess $access): void +{ + Arr::get(['a.b' => 1], 'a.b'); + Arr::get($shapeOrAccess, 'missing'); + Arr::get($access, 'missing'); + Arr::get($access, '0'); +} + +class OverridingArr extends Arr +{ + public static function get(mixed $array, string|int|float|null $key, mixed $default = null): mixed + { + return 'overridden'; + } +} + +/** @param array{foo: string} $shape */ +function overriddenGet(array $shape): void +{ + OverridingArr::get($shape, 'missing'); + OverridingArr::pull($shape, 'missing'); +} + +/** @implements \ArrayAccess */ +abstract class ArrKeyExistenceAccess implements \ArrayAccess +{ +} diff --git a/tests/PHPStan/Type/data/arr-get-pull.php b/tests/PHPStan/Type/data/arr-get-pull.php new file mode 100644 index 000000000..e2d2fa74e --- /dev/null +++ b/tests/PHPStan/Type/data/arr-get-pull.php @@ -0,0 +1,195 @@ + */ +class ArrGetPullAccess implements \ArrayAccess +{ + public function offsetExists(mixed $offset): bool + { + return true; + } + + public function offsetGet(mixed $offset): int + { + return 1; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + } + + public function offsetUnset(mixed $offset): void + { + } +} + +/** + * @param array{foo: string, bar?: int, nullable: string|null, nested: array{deep: bool, maybe?: int}, word: string} $shape + * @param list $rows + * @param array $map + * @param Collection $collection + * @param array{a: int}|null $nullableShape + * @param 'foo'|'bar' $fooOrBar + */ +function get( + array $shape, + array $rows, + array $map, + Collection $collection, + ?array $nullableShape, + ?string $nullableKey, + string $anyKey, + int $anyInt, + string $fooOrBar, +): void { + assertType('string', Arr::get($shape, 'foo')); + assertType('int|null', Arr::get($shape, 'bar')); + assertType('string|null', Arr::get($shape, 'nullable', 'default')); + assertType('null', Arr::get($shape, 'missing')); + assertType("'default'", Arr::get($shape, 'missing', 'default')); + assertType('string|null', Arr::get($shape, $fooOrBar === 'foo' ? 'foo' : 'missing')); + assertType('int|string|null', Arr::get($shape, $fooOrBar)); + assertType('string', Arr::get(key: 'foo', array: $shape)); + + // "dot" notation + assertType('bool', Arr::get($shape, 'nested.deep')); + assertType('int|null', Arr::get($shape, 'nested.maybe')); + assertType('null', Arr::get($shape, 'nested.missing')); + assertType('null', Arr::get($shape, 'word.0')); + assertType('string|null', Arr::get($rows, '0.name')); + + // Other keys keep the declared type + assertType('mixed', Arr::get($shape, null)); + assertType('mixed', Arr::get($shape, $nullableKey)); + assertType('mixed', Arr::get($map, $anyKey)); + + // Defaults go through value(), which only calls a Closure + assertType('1', Arr::get($shape, 'missing', static fn () => 1)); + assertType("'strtoupper'", Arr::get($shape, 'missing', 'strtoupper')); + + assertType('array{name: string}|null', Arr::get($rows, $anyInt)); + assertType('int|null', Arr::get($collection, 'key')); + assertType('int|null', Arr::get($nullableShape, 'a')); + + // Unsealed shapes may hold any other key + assertType('mixed', Arr::get((new User())->toArray(), 'anything')); +} + +/** + * @param array{'a.b': int, a: array{b: string}} $dotKey + * @param array{'a.b'?: int, a: array{b: string}} $maybeDotKey + * @param array{foo: string}|ArrGetPullAccess $shapeOrAccess + */ +function getLiteralDotKeysAndArrayAccess(array $dotKey, array $maybeDotKey, array|ArrGetPullAccess $shapeOrAccess, ArrGetPullAccess $access): void +{ + // A literal key wins over the "dot" path + assertType('int', Arr::get($dotKey, 'a.b')); + assertType('int|string', Arr::get($maybeDotKey, 'a.b')); + + assertType('int|null', Arr::get($access, 'key')); + assertType('int|null', Arr::get($access, '0')); + assertType('int|string|null', Arr::get($shapeOrAccess, 'foo')); +} + +/** + * @param array{foo: string, bar?: int, nested: array{deep: bool, keep: int}, optional?: array{deep: bool}, nullable: array{deep: bool}|null, 'dot.key': int} $shape + * @param list $list + * @param array $map + * @param 'foo'|'bar' $fooOrBar + */ +function pull(array $shape, array $list, array $map, string $anyKey, string $fooOrBar): void +{ + $array = $shape; + assertType('string', Arr::pull($array, 'foo')); + assertType("array{bar?: int, nested: array{deep: bool, keep: int}, optional?: array{deep: bool}, nullable: array{deep: bool}|null, 'dot.key': int}", $array); + + $array = $shape; + assertType('int|null', Arr::pull($array, 'bar')); + assertType("array{foo: string, nested: array{deep: bool, keep: int}, optional?: array{deep: bool}, nullable: array{deep: bool}|null, 'dot.key': int}", $array); + + $array = $shape; + assertType("'default'", Arr::pull($array, 'missing', 'default')); + assertType("array{foo: string, bar?: int, nested: array{deep: bool, keep: int}, optional?: array{deep: bool}, nullable: array{deep: bool}|null, 'dot.key': int}", $array); + + $array = $shape; + Arr::pull($array, $fooOrBar); + assertType("array{foo?: string, bar?: int, nested: array{deep: bool, keep: int}, optional?: array{deep: bool}, nullable: array{deep: bool}|null, 'dot.key': int}", $array); + + // Keys in "dot" notation keep the declared type + $array = $shape; + assertType('bool', Arr::pull($array, 'nested.deep')); + assertType('array', $array); + + assertType('int|null', Arr::pull($list, 0)); + assertType('array, int>', $list); + + assertType('mixed', Arr::pull($map, $anyKey)); + assertType('array', $map); +} + +/** + * @param list $list + * @param '0'|'1' $zeroOrOne + */ +function pullNumericStringKeys(array $list, string $zeroOrOne): void +{ + $array = $list; + Arr::pull($array, '0'); + assertType('array, string>', $array); + + $array = $list; + Arr::pull($array, '1'); + assertType('array<0|int<2, max>, string>', $array); + + $array = $list; + Arr::pull($array, $zeroOrOne); + assertType('array, string>', $array); +} + +/** @param array{'a.b': int, a: array{b: string}, c: int} $dotKey */ +function pullLiteralDotKey(array $dotKey): void +{ + assertType('int', Arr::pull($dotKey, 'a.b')); + assertType('array{a: array{b: string}, c: int}', $dotKey); +} + +class ArrGetPullOverridingArr extends Arr +{ + /** @return 'overridden' */ + public static function get(mixed $array, string|int|float|null $key, mixed $default = null): mixed + { + return 'overridden'; + } +} + +class ArrGetPullOverridingForget extends Arr +{ + public static function forget(array &$array, array|string|int|float|null $keys): void + { + } +} + +/** @param array{foo: string} $shape */ +function overriddenGet(array $shape): void +{ + assertType("'overridden'", ArrGetPullOverridingArr::get($shape, 'missing')); + + // The inherited pull() reads through the overriding get(). + assertType('mixed', ArrGetPullOverridingArr::pull($shape, 'foo')); +} + +/** @param array{foo: string, bar: int} $shape */ +function overriddenForget(array $shape): void +{ + assertType('string', ArrGetPullOverridingForget::pull($shape, 'foo')); + + // The inherited pull() removes the key through the overriding forget(). + assertType('array', $shape); +} From 19deee2aa64702bc4e74420dc3c54a3ea90b1bd1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:56:00 +0000 Subject: [PATCH 063/303] feat: infer collection construction and narrowing Port collection construction and narrowing against Hypervel's actual generic contracts. Reuse public array_filter inference for callback narrowing, preserving declaration fallbacks for custom methods, unresolved templates and unsupported receiver shapes. Register the helper and collection type fixtures in the analyzer harness, including Arrayable construction, key preservation and whereNotNull boundaries. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- ...FilterRejectDynamicReturnTypeExtension.php | 150 ++++++++++++ ...WhereNotNullDynamicReturnTypeExtension.php | 101 ++++++++ .../ReturnTypes/Helpers/CollectExtension.php | 81 +++++++ src/phpstan/src/Support/CollectionHelper.php | 229 ++++++++++++++++++ ...llectionDynamicReturnTypeExtensionTest.php | 34 +++ ...lectionDynamicReturnTypeExtensionsTest.php | 36 +++ tests/PHPStan/Type/GeneralTypeTest.php | 9 + tests/PHPStan/Type/data/arr-map.php | 47 ++++ tests/PHPStan/Type/data/arrayable.php | 42 ++++ tests/PHPStan/Type/data/collection-helper.php | 38 +++ .../Type/data/collection-key-types.php | 100 ++++++++ .../Type/data/collection-make-static.php | 30 +++ .../data/collection-narrowing-boundaries.php | 108 +++++++++ .../Type/data/collection-nullsafe-chain.php | 28 +++ .../Type/data/collection-stubs-l12-20.php | 13 + tests/PHPStan/Type/data/collection-stubs.php | 122 ++++++++++ .../Type/data/collection-where-not-null.php | 22 ++ 17 files changed, 1190 insertions(+) create mode 100644 src/phpstan/src/ReturnTypes/CollectionFilterRejectDynamicReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/CollectionWhereNotNullDynamicReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php create mode 100644 src/phpstan/src/Support/CollectionHelper.php create mode 100644 tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionTest.php create mode 100644 tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionsTest.php create mode 100644 tests/PHPStan/Type/data/arr-map.php create mode 100644 tests/PHPStan/Type/data/arrayable.php create mode 100644 tests/PHPStan/Type/data/collection-helper.php create mode 100644 tests/PHPStan/Type/data/collection-key-types.php create mode 100644 tests/PHPStan/Type/data/collection-make-static.php create mode 100644 tests/PHPStan/Type/data/collection-narrowing-boundaries.php create mode 100644 tests/PHPStan/Type/data/collection-nullsafe-chain.php create mode 100644 tests/PHPStan/Type/data/collection-stubs-l12-20.php create mode 100644 tests/PHPStan/Type/data/collection-stubs.php create mode 100644 tests/PHPStan/Type/data/collection-where-not-null.php diff --git a/src/phpstan/src/ReturnTypes/CollectionFilterRejectDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/CollectionFilterRejectDynamicReturnTypeExtension.php new file mode 100644 index 000000000..530424f11 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/CollectionFilterRejectDynamicReturnTypeExtension.php @@ -0,0 +1,150 @@ + ['filter'], + 'reject' => ['reject', 'useAsCallable', 'filter'], + 'where' => ['where', 'operatorForWhere', 'useAsCallable', 'filter'], + ]; + + /** + * Get the class the extension applies to. + */ + public function getClass(): string + { + return Enumerable::class; + } + + /** + * Determine whether the method narrows the collection's values. + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return isset(self::METHODS[$methodReflection->getName()]); + } + + /** + * Get the collection type with the values the callback keeps. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + /** @var 'filter'|'reject'|'where' $methodName */ + $methodName = $methodReflection->getName(); + $calledOnType = TypeCombinator::removeNull($scope->getType($methodCall->var)); + $classReflections = $calledOnType->getObjectClassReflections(); + + // The result is rebuilt from the key and value types, so the collection's own templates must be exactly those two. + // A collection that overrides the methods may keep other values. + if (count($classReflections) !== 1 + || count($classReflections[0]->getTemplateTypeMap()->getTypes()) !== 2 + || ! CollectionHelper::usesFrameworkMethods($classReflections[0], self::METHODS[$methodName])) { + return null; + } + + $templateTypeMap = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap(); + $keyType = $templateTypeMap->getType('TKey'); + $valueType = $templateTypeMap->getType('TValue'); + + // Upstream narrows a template's bound instead, which drops the template and the static return type in generic code. + if ($keyType === null || $valueType === null || $keyType->hasTemplateOrLateResolvableType() || $valueType->hasTemplateOrLateResolvableType()) { + return null; + } + + if (count($methodCall->getArgs()) < 1) { + $modifiedType = match ($methodName) { + 'filter' => TypeCombinator::removeFalsey($valueType), + 'reject' => TypeCombinator::removeTruthy($valueType), + 'where' => null, + }; + + return $modifiedType !== null + ? new GenericObjectType($classReflections[0]->getName(), [$keyType, $modifiedType]) + : null; + } + + $callbackArg = $methodCall->getArgs()[0]->value; + $callbackType = $scope->getType($callbackArg); + + // where() and reject() call only callables that are not strings, and compare anything else. + if ($methodName !== 'filter' && ! ($callbackType->isCallable()->yes() && $callbackType->isString()->no())) { + return null; + } + + if ($methodName === 'reject') { + $callbackArg = $this->negate($callbackArg); + + if ($callbackArg === null) { + return null; + } + } + + // The collection's callbacks receive the value and the key, as array_filter() callbacks do with + // ARRAY_FILTER_USE_BOTH, so PHPStan's array_filter() narrowing applies to both. Upstream narrows + // only the value, through a MutatingScope method outside PHPStan's backward compatibility promise. + $filteredType = $scope->getType(new FuncCall(new FullyQualified('array_filter'), [ + new Arg(new TypeExpr(new ArrayType($keyType, $valueType))), + new Arg($callbackArg), + new Arg(new ConstFetch(new FullyQualified('ARRAY_FILTER_USE_BOTH'))), + ])); + + return new GenericObjectType($classReflections[0]->getName(), [ + $filteredType->getIterableKeyType(), + $filteredType->getIterableValueType(), + ]); + } + + /** + * Get a callback that returns the opposite of an inline callback's single returned expression. + */ + private function negate(Expr $callback): ?ArrowFunction + { + if ($callback instanceof ArrowFunction) { + $expr = $callback->expr; + } elseif ($callback instanceof Closure && count($callback->stmts) === 1 && $callback->stmts[0] instanceof Return_ && $callback->stmts[0]->expr !== null) { + $expr = $callback->stmts[0]->expr; + } else { + return null; + } + + return new ArrowFunction([ + 'params' => $callback->params, + 'expr' => new BooleanNot($expr), + ]); + } +} diff --git a/src/phpstan/src/ReturnTypes/CollectionWhereNotNullDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/CollectionWhereNotNullDynamicReturnTypeExtension.php new file mode 100644 index 000000000..a87ad3f59 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/CollectionWhereNotNullDynamicReturnTypeExtension.php @@ -0,0 +1,101 @@ +getName() === 'whereNotNull'; + } + + /** + * Get the collection type without the values whereNotNull() removes. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $calledOnType = TypeCombinator::removeNull($scope->getType($methodCall->var)); + $classReflections = $calledOnType->getObjectClassReflections(); + + // The result is rebuilt from the key and value types, so the collection's own templates must be exactly those two. + // A collection that overrides whereNotNull() or the methods it runs may keep other values. + if (count($classReflections) !== 1 + || count($classReflections[0]->getTemplateTypeMap()->getTypes()) !== 2 + || ! CollectionHelper::usesFrameworkMethods($classReflections[0], ['whereNotNull', 'where', 'operatorForWhere', 'useAsCallable', 'filter'])) { + return null; + } + + $templateTypeMap = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap(); + $keyType = $templateTypeMap->getType('TKey'); + $valueType = $templateTypeMap->getType('TValue'); + + // Upstream narrows a template's bound instead, which drops the template and the static return type in generic code. + if ($keyType === null || $valueType === null || $keyType->hasTemplateOrLateResolvableType() || $valueType->hasTemplateOrLateResolvableType()) { + return null; + } + + $nonNullType = TypeCombinator::removeNull($valueType); + + if (! $this->argumentIsString($methodCall, $scope)) { + return new GenericObjectType($classReflections[0]->getName(), [$keyType, $nonNullType]); + } + + // A keyed lookup on a scalar value finds nothing, so scalar values are removed. + $scalarTypes = TypeCombinator::union( + new StringType, + new IntegerType, + new FloatType, + new BooleanType, + ); + + return new GenericObjectType($classReflections[0]->getName(), [$keyType, TypeCombinator::remove($nonNullType, $scalarTypes)]); + } + + /** + * Determine whether the call passes a key to compare. + */ + public function argumentIsString(MethodCall $methodCall, Scope $scope): bool + { + if (count($methodCall->getArgs()) === 0) { + return false; + } + + $argType = $scope->getType($methodCall->getArgs()[0]->value); + + return $argType->isNull()->no(); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php b/src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php new file mode 100644 index 000000000..7485c52d6 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php @@ -0,0 +1,81 @@ +getName() === 'collect'; + } + + /** + * Get the collection type for the collected value. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + if (count($functionCall->getArgs()) < 1) { + if ($this->inferFromUsage) { + return null; + } + + return $this->emptyCollectionType ??= new GenericObjectType(Collection::class, [new BenevolentUnionType([new IntegerType, new StringType]), new MixedType]); + } + + $argument = $functionCall->getArgs()[0]; + + // An unpacked argument's expression is the list holding the value, not the value. + if ($argument->unpack) { + return null; + } + + $valueType = $scope->getType($argument->value); + + if ($this->inferFromUsage && $valueType->isArray()->yes() && $valueType->isIterableAtLeastOnce()->no()) { + return null; + } + + return $this->collectionHelper->determineGenericCollectionTypeFromType($valueType); + } +} diff --git a/src/phpstan/src/Support/CollectionHelper.php b/src/phpstan/src/Support/CollectionHelper.php new file mode 100644 index 000000000..b6f1f3c47 --- /dev/null +++ b/src/phpstan/src/Support/CollectionHelper.php @@ -0,0 +1,229 @@ + $methods + */ + public static function usesFrameworkMethods(ClassReflection $classReflection, array $methods): bool + { + foreach ($methods as $method) { + if (! $classReflection->hasNativeMethod($method) + || ! in_array($classReflection->getNativeMethod($method)->getDeclaringClass()->getName(), self::FRAMEWORK_COLLECTIONS, true)) { + return false; + } + } + + return true; + } + + /** + * Get the collection type that collect() makes from a scalar, null or an empty array. + * + * collect()'s declared signature already describes objects and other arrays. Upstream + * also typed objects here, casting Arrayable objects with (array) instead of reading + * toArray(), and forcing integer keys onto Eloquent collections. + */ + public function determineGenericCollectionTypeFromType(Type $type): ?GenericObjectType + { + if (! $type->isObject()->no()) { + return null; + } + + // Matches Arr::wrap(), which collect() uses for scalars and null. + $arrayType = $type->toArray(); + + if ($arrayType->isIterableAtLeastOnce()->no()) { + return new GenericObjectType(Collection::class, [new BenevolentUnionType([new IntegerType, new StringType]), new MixedType]); + } + + if ($type->isArray()->yes()) { + return null; + } + + return new GenericObjectType(Collection::class, [$arrayType->getIterableKeyType(), $arrayType->getIterableValueType()]); + } + + /** + * Get the collection type declared by the model's `CollectedBy` attribute or by the + * return type of its `newCollection()` method, which can be a union or an intersection. + */ + private function determineCollectionType(string $modelClassName): Type + { + try { + $modelReflection = $this->reflectionProvider->getClass($modelClassName); + $newCollectionMethod = $modelReflection->getNativeMethod('newCollection'); + + // The attribute is read by HasCollection::newCollection(), so it has no effect when that method is overridden. + $newCollectionFileName = $newCollectionMethod->getDeclaringClass()->getNativeReflection()->getMethod('newCollection')->getFileName(); + + if ($newCollectionFileName === $this->reflectionProvider->getClass(HasCollection::class)->getFileName()) { + $collectionClassName = $this->findCollectedByClassName($modelReflection); + + if ($collectionClassName !== null) { + return new ObjectType($collectionClassName); + } + } + + return $newCollectionMethod->getVariants()[0]->getReturnType(); + } catch (MissingMethodFromReflectionException|ShouldNotHappenException) { + return new ObjectType(EloquentCollection::class); + } + } + + /** + * Find the collection class of the nearest `CollectedBy` attribute, as + * HasCollection::resolveCollectionFromAttribute() does. + */ + private function findCollectedByClassName(ClassReflection $modelReflection): ?string + { + foreach ([$modelReflection, ...$modelReflection->getParents()] as $classReflection) { + $attributes = $classReflection->getNativeReflection()->getAttributes(CollectedBy::class); + + if ($attributes === []) { + continue; + } + + // The attribute is instantiated, so its argument may be named. + $arguments = $attributes[0]->getArgumentsExpressions(); + $expr = $arguments['collectionClass'] ?? $arguments[0] ?? null; + + if ($expr instanceof ClassConstFetch && $expr->class instanceof Name) { + return $expr->class->toString(); + } + + if ($expr instanceof String_) { + return ltrim($expr->value, '\\'); + } + + return null; + } + + return null; + } + + /** + * Get the collection type of the model, with the model and key types filled in. + */ + public function determineCollectionClass(string $modelClassName, ?Type $modelType = null, ?Type $keyType = null): Type + { + $modelType ??= new ObjectType($modelClassName); + + return TypeTraverser::map($this->determineCollectionType($modelClassName), function (Type $type, callable $traverse) use ($modelType, $keyType): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + // Members that are not collections keep their declared type arguments. + if (! (new ObjectType(Enumerable::class))->isSuperTypeOf($type)->yes()) { + return TypeTraverser::map($type, static fn (Type $type, callable $traverse): Type => $type instanceof StaticType ? $modelType : $traverse($type)); + } + + $classNames = $type->getObjectClassNames(); + + if (count($classNames) !== 1) { + return $type; + } + + $collectionClassName = $classNames[0]; + $collectionReflection = $this->reflectionProvider->getClass($collectionClassName); + + if ($collectionReflection->isGeneric()) { + $typeMap = $collectionReflection->getActiveTemplateTypeMap(); + + // Specifies key and value + if ($typeMap->count() === 2) { + return new GenericObjectType($collectionClassName, [$keyType ?? new IntegerType, $modelType]); + } + + // Specifies only value + if (($typeMap->count() === 1) && $typeMap->hasType('TModel')) { + return new GenericObjectType($collectionClassName, [$modelType]); + } + } + + // Not generic. So return the type as is + return new ObjectType($collectionClassName); + }); + } + + /** + * Replace each Eloquent collection in the type with its model's collection type. + */ + public function replaceCollectionsInType(Type $type): Type + { + if (! in_array(EloquentCollection::class, $type->getReferencedClasses(), true)) { + return $type; + } + + return TypeTraverser::map($type, function ($type, $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if (! (new ObjectType(EloquentCollection::class))->isSuperTypeOf($type)->yes()) { + return $traverse($type); + } + + $models = $type->getTemplateType(EloquentCollection::class, 'TModel')->getObjectClassNames(); + + if (count($models) === 0) { + return $type; + } + + return TypeCombinator::union(...array_map([$this, 'determineCollectionClass'], $models)); + }); + } +} diff --git a/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionTest.php b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionTest.php new file mode 100644 index 000000000..5ad0e3d60 --- /dev/null +++ b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionTest.php @@ -0,0 +1,34 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-narrowing-boundaries.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-where-not-null.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-nullsafe-chain.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../../src/phpstan/extension.neon']; + } +} diff --git a/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionsTest.php b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionsTest.php new file mode 100644 index 000000000..19a4aea33 --- /dev/null +++ b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionsTest.php @@ -0,0 +1,36 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-helper.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-make-static.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-stubs.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-key-types.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-stubs-l12-20.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php index 2bb62d7cc..0739c9d34 100644 --- a/tests/PHPStan/Type/GeneralTypeTest.php +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -23,16 +23,25 @@ public static function dataFileAsserts(): iterable { yield from self::gatherAssertTypes(__DIR__ . '/data/app-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/application-make.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/arr-get-pull.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/arr-map.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/arrayable.php'); // Upstream never registers artisan.php. yield from self::gatherAssertTypes(__DIR__ . '/data/artisan.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/auth.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/benchmark.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/conditionable.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/console-commands.php'); // REMOVED: container-array-access.php; container ArrayAccess is unsupported. yield from self::gatherAssertTypes(__DIR__ . '/data/container-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/contracts.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/gate-facade.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/helpers.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/higher-order-collection-proxies.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/optional-helper.php'); // Upstream never registers request-user.php, which shares request-object.php's namespace. yield from self::gatherAssertTypes(__DIR__ . '/data/request-user.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/tappable.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/translate.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/translator.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/view-exists.php'); diff --git a/tests/PHPStan/Type/data/arr-map.php b/tests/PHPStan/Type/data/arr-map.php new file mode 100644 index 000000000..32f0840f1 --- /dev/null +++ b/tests/PHPStan/Type/data/arr-map.php @@ -0,0 +1,47 @@ + $items + */ +function test(array $items, string $label): void +{ + assertType('array', Arr::map($items, static function (int $value, string $key) use ($label): string { + return $label; + })); + + assertType('array>', Arr::map($items, static function ($value, $key) { + assertType('int', $value); + assertType('string', $key); + + return [$key => $value]; + })); + + assertType('array', Arr::map($items, static fn ($value) => (string) $value)); + assertType('array', Arr::map($items, 'strval')); +} + +/** + * @param array $items + * @param list $users + */ +function testMapWithKeys(array $items, array $users): void +{ + assertType('array', Arr::mapWithKeys($items, static function ($value, $key) { + assertType('int', $value); + assertType('string', $key); + + return [$key => (string) $value]; + })); + + assertType('array', Arr::mapWithKeys($items, static fn (int $value, string $key): array => [$value => $key])); + + assertType("array<'total', int>", Arr::mapWithKeys($items, static fn (int $value): array => ['total' => $value])); + assertType('array', Arr::mapWithKeys($users, static fn (User $user): array => [$user->email => $user])); +} diff --git a/tests/PHPStan/Type/data/arrayable.php b/tests/PHPStan/Type/data/arrayable.php new file mode 100644 index 000000000..4d8651b7f --- /dev/null +++ b/tests/PHPStan/Type/data/arrayable.php @@ -0,0 +1,42 @@ +> */ +class Counts implements Arrayable +{ + public function toArray(): array + { + return ['total' => 1]; + } +} + +/** @implements Arrayable> */ +class Totals implements Arrayable +{ + /** @return array{total: int} */ + public function toArray(): array + { + return ['total' => 1]; + } +} + +/** + * @param Arrayable> $arrayable + * @param Collection $users + */ +function test(Counts $counts, Totals $totals, Arrayable $arrayable, Collection $users): void +{ + assertType('array', $counts->toArray()); + assertType('array{total: int}', $totals->toArray()); + assertType('array', $arrayable->toArray()); + assertType('array', $users->toArray()); +} diff --git a/tests/PHPStan/Type/data/collection-helper.php b/tests/PHPStan/Type/data/collection-helper.php new file mode 100644 index 000000000..4c07e4bc1 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-helper.php @@ -0,0 +1,38 @@ + $ints + * @param EloquentCollection $users + * @param Traversable $traversable + */ +function test( + EloquentCollection $ints, + EloquentCollection $users, + Traversable $traversable +): void { + assertType('Hypervel\Support\Collection<(int|string), mixed>', collect()); + assertType('Hypervel\Support\Collection<0, 1>', collect(1)); + assertType('Hypervel\Support\Collection<0, \'foo\'>', collect('foo')); + assertType('Hypervel\Support\Collection<0, 3.14>', collect(3.14)); + assertType('Hypervel\Support\Collection<0, true>', collect(true)); + assertType('Hypervel\Support\Collection<(int|string), mixed>', collect([])); + assertType('Hypervel\Support\Collection', collect([1, 2, 3])); + assertType('Hypervel\Support\Collection', collect(['foo', 'bar', 'baz'])); + assertType('Hypervel\Support\Collection', collect([1.0, 2.0, 3.0])); + assertType('Hypervel\Support\Collection', collect([1, 'foo', 1.0])); + assertType("Hypervel\Support\Collection", collect([['a', 'b', 'c']])); + assertType("Hypervel\Support\Collection", collect([['a', 'b', 'c']])->push(array_fill(0, 3, 'x'))); + assertType("Hypervel\Support\Collection", collect([new User, new User])); + assertType("Hypervel\Support\Collection", collect([[new User, new User, new User]])); + assertType('Hypervel\Support\Collection', collect($ints)); + assertType('Hypervel\Support\Collection', collect($users)); + assertType('Hypervel\Support\Collection', collect($traversable)); +} diff --git a/tests/PHPStan/Type/data/collection-key-types.php b/tests/PHPStan/Type/data/collection-key-types.php new file mode 100644 index 000000000..c741cbf94 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-key-types.php @@ -0,0 +1,100 @@ + $stringKeys + * @param Collection $intKeys + * @param Collection $arrayKeys + * @param LazyCollection $lazy + * @param EloquentCollection $users + * @param array $map + * @param array $keyedUsers + */ +function test( + Collection $stringKeys, + Collection $intKeys, + Collection $arrayKeys, + LazyCollection $lazy, + EloquentCollection $users, + array $map, + array $keyedUsers, +): void { + assertType('Hypervel\Support\Collection', $stringKeys->merge($map)); + assertType('Hypervel\Support\Collection', $intKeys->merge([1])); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $arrayKeys->merge([1])); + // Hypervel: mergeRecursive() turns values under a repeated string key into arrays. + assertType('Hypervel\Support\Collection|int>', $stringKeys->mergeRecursive($map)); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $arrayKeys->mergeRecursive([1])); + assertType('Hypervel\Support\LazyCollection', $lazy->merge($map)); + assertType('Hypervel\Database\Eloquent\Collection', $users->merge($keyedUsers)); + + assertType('Hypervel\Support\Collection', $stringKeys->concat($map)); + assertType('Hypervel\Support\Collection', $intKeys->concat($map)); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $arrayKeys->concat($map)); + // Hypervel: the lazy concat() calls values(), as in Laravel, so its keys are always integers. + assertType('Hypervel\Support\LazyCollection', $lazy->concat($map)); + + assertType('Hypervel\Support\Collection', $intKeys->flatMap(fn (int $value) => $map)); + assertType('Hypervel\Support\Collection', $stringKeys->flatMap(fn (int $value) => [$value])); + assertType('Hypervel\Support\LazyCollection', $lazy->flatMap(fn (int $value) => [$value])); + + assertType('Hypervel\Support\Collection', $stringKeys->shuffle()); + assertType('Hypervel\Support\Collection', $stringKeys->multiply(2)); + assertType('Hypervel\Support\LazyCollection', $lazy->shuffle()); + assertType('Hypervel\Support\LazyCollection', $lazy->multiply(2)); + + assertType('Hypervel\Support\Collection>', $stringKeys->split(2)); + assertType('Hypervel\Support\Collection>', $arrayKeys->split(2)); + assertType('Hypervel\Support\LazyCollection>', $lazy->split(2)); +} + +/** + * @param Collection $appended + * @param Collection $prepended + * @param Collection $pushedArrayKeys + * @param Collection $shifted + * @param Collection $shiftedArrayKeys + * @param Collection $spliced + * @param Collection $put + */ +function mutations( + Collection $appended, + Collection $prepended, + Collection $pushedArrayKeys, + Collection $shifted, + Collection $shiftedArrayKeys, + Collection $spliced, + Collection $put, +): void +{ + $appended->push(1); + assertType('Hypervel\Support\Collection', $appended); + + $prepended->prepend(1, 'key'); + assertType('Hypervel\Support\Collection', $prepended); + $prepended->prepend(1); + assertType('Hypervel\Support\Collection', $prepended); + + $pushedArrayKeys->add(1); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $pushedArrayKeys); + + $shifted->shift(); + assertType('Hypervel\Support\Collection', $shifted); + + $shiftedArrayKeys->shift(); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $shiftedArrayKeys); + + assertType('Hypervel\Support\Collection', $spliced->splice(1)); + assertType('Hypervel\Support\Collection', $spliced); + + assertType('int|null', $put->getOrPut(1, null)); + assertType('Hypervel\Support\Collection<1|string, int|null>', $put); +} diff --git a/tests/PHPStan/Type/data/collection-make-static.php b/tests/PHPStan/Type/data/collection-make-static.php new file mode 100644 index 000000000..80bace3bb --- /dev/null +++ b/tests/PHPStan/Type/data/collection-make-static.php @@ -0,0 +1,30 @@ + $users + * @param Traversable $traversable + */ +function test(EloquentCollection $users, Traversable $traversable): void +{ + // assertType('Hypervel\Support\Collection', SupportCollection::make([])); + assertType('Hypervel\Support\Collection', SupportCollection::make([1, 2, 3])); + assertType('Hypervel\Support\Collection', SupportCollection::make(['foo', 'bar', 'baz'])); + assertType('Hypervel\Support\Collection', SupportCollection::make([1.0, 2.0, 3.0])); + assertType('Hypervel\Support\Collection', SupportCollection::make([1, 'foo', 1.0])); + + assertType('Hypervel\Support\Collection', SupportCollection::make($users)); + + /** @var EloquentCollection $users */ + assertType('Hypervel\Support\Collection', SupportCollection::make($users)); + + assertType('Hypervel\Support\Collection', SupportCollection::make($traversable)); +} diff --git a/tests/PHPStan/Type/data/collection-narrowing-boundaries.php b/tests/PHPStan/Type/data/collection-narrowing-boundaries.php new file mode 100644 index 000000000..07b83fcd4 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-narrowing-boundaries.php @@ -0,0 +1,108 @@ + $values + */ +function templateValues(Collection $values): void +{ + assertType('Hypervel\Support\Collection', $values->filter()); + assertType('Hypervel\Support\Collection', $values->reject(fn ($value) => $value === null)); + assertType('Hypervel\Support\Collection', $values->whereNotNull()); +} + +/** + * Upstream rebuilt a union of collections as its first collection class. + * + * @param Collection|LazyCollection $values + */ +function unionOfCollections(Collection|LazyCollection $values): void +{ + assertType('Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $values->filter()); + assertType('Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $values->whereNotNull()); +} + +/** + * Upstream rebuilt a collection with one template as if it took a key and a value. + * + * @param OnlyValueGenericCollection $models + */ +function oneTemplate(OnlyValueGenericCollection $models): void +{ + assertType('App\OnlyValueGenericCollection', $models->filter()); + assertType('App\OnlyValueGenericCollection', $models->whereNotNull()); +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class KeepingCollection extends Collection +{ + public function filter(?callable $callback = null): static + { + return $this; + } +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class KeepingWhereNotNullCollection extends Collection +{ + public function whereNotNull(?string $key = null): static + { + return $this; + } +} + +/** + * A collection that overrides a narrowing method, or one that it runs, may keep other values. + * + * @param KeepingCollection $keeping + * @param KeepingWhereNotNullCollection $keepingWhereNotNull + */ +function customImplementations(KeepingCollection $keeping, KeepingWhereNotNullCollection $keepingWhereNotNull): void +{ + assertType('CollectionNarrowingBoundaries\KeepingCollection', $keeping->filter()); + assertType('CollectionNarrowingBoundaries\KeepingCollection', $keeping->reject(fn ($value) => $value === null)); + assertType('CollectionNarrowingBoundaries\KeepingCollection', $keeping->where(fn ($value) => $value !== null)); + assertType('CollectionNarrowingBoundaries\KeepingCollection', $keeping->whereNotNull()); + assertType('CollectionNarrowingBoundaries\KeepingWhereNotNullCollection', $keepingWhereNotNull->whereNotNull()); + assertType('CollectionNarrowingBoundaries\KeepingWhereNotNullCollection', $keepingWhereNotNull->filter(fn ($value) => $value !== null)); +} + +/** + * Hypervel: callbacks receive the key as well as the value, and only callables that are not strings narrow reject() and where(). + * + * @param Collection $values + * @param LazyCollection $lazyValues + */ +function callbackArguments(Collection $values, LazyCollection $lazyValues): void +{ + assertType('Hypervel\Support\Collection', $values->filter(fn ($value, $key) => is_string($key))); + assertType('Hypervel\Support\LazyCollection', $lazyValues->reject(fn ($value, $key) => is_string($key))); + assertType('Hypervel\Support\Collection|int<1, max>>', $values->filter(null)); + assertType('Hypervel\Support\Collection', $values->where(function ($value) { + return $value !== null; + })); + assertType('Hypervel\Support\Collection', $values->where('id', 1)); + assertType('Hypervel\Support\Collection', $values->reject(null)); +} diff --git a/tests/PHPStan/Type/data/collection-nullsafe-chain.php b/tests/PHPStan/Type/data/collection-nullsafe-chain.php new file mode 100644 index 000000000..ebe39d7aa --- /dev/null +++ b/tests/PHPStan/Type/data/collection-nullsafe-chain.php @@ -0,0 +1,28 @@ + $users */ + public function __construct(public Collection $users) + { + } +} + +function test(?Holder $holder): void +{ + assertType('Hypervel\Support\Collection|null', $holder?->users->filter(fn (?User $user) => $user instanceof User)); + assertType('Hypervel\Support\Collection|null', $holder?->users->filter(function (?User $user) { + return $user instanceof User; + })); + assertType('Hypervel\Support\Collection|null', $holder?->users->filter()); + assertType('Hypervel\Support\Collection|null', $holder?->users->reject(fn (?User $user) => $user === null)); + assertType('Hypervel\Support\Collection|null', $holder?->users->whereNotNull()); + assertType('Hypervel\Support\Collection|null', $holder?->users->values()->filter(fn (?User $user) => $user instanceof User)); +} diff --git a/tests/PHPStan/Type/data/collection-stubs-l12-20.php b/tests/PHPStan/Type/data/collection-stubs-l12-20.php new file mode 100644 index 000000000..5ac697556 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-stubs-l12-20.php @@ -0,0 +1,13 @@ +', User::get()->pluck(fn (User $user) => "$user->id - $user->email", 'id')); + assertType('Hypervel\Support\Collection', User::get()->pluck('id', fn (User $user) => "$user->id - $user->email")); + assertType('Hypervel\Support\Collection', User::get()->pluck(fn (User $user) => $user->email, fn (User $user) => "$user->id - $user->email")); +} diff --git a/tests/PHPStan/Type/data/collection-stubs.php b/tests/PHPStan/Type/data/collection-stubs.php new file mode 100644 index 000000000..518580e3e --- /dev/null +++ b/tests/PHPStan/Type/data/collection-stubs.php @@ -0,0 +1,122 @@ + $collection + * @param SupportCollection $items + * @param SupportCollection $collectionOfUsers + */ +function test( + EloquentCollection $collection, + SupportCollection $items, + SupportCollection $collectionOfUsers, + User $user, +): void { + assertType('Hypervel\Database\Eloquent\Collection', User::all()->each(function (User $user, int $key): void { + })); + + assertType('Hypervel\Support\Collection', $items->each(function (): bool { + return false; + })); + + assertType('Hypervel\Support\Collection', $items->map(function (int $item): string { + return (string) $item; + })); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->find($items)); + assertType('Hypervel\Database\Eloquent\Collection', $collection->find([1])); + assertType('App\User|null', $collection->find($user)); + assertType('App\User|null', $collection->find(1)); + assertType('App\User|false', $collection->find(1, false)); + + assertType('Hypervel\Support\Collection<(int|string), mixed>', $collection->pluck('id')); + + // Hypervel: Eloquent map() returns a base collection unless every result is a model, so mapInto() declares + // either. flatMap() always returns a base collection, because the Eloquent collapse() calls toBase(). + assertType('Hypervel\Support\Collection', User::all()->mapInto(User::class)); + assertType('Hypervel\Support\Collection', $collection->flatMap(function (User $user, int $id): array { + return [$user]; + })); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->tap(function ($collection): void { + })); + + $foo = collect([ + [ + 'id' => 1, + 'type' => 'A', + ], + [ + 'id' => 1, + 'type' => 'B', + ], + ]); + + $foo + ->groupBy('type') + ->map(function ($grouped, $groupKey): array { + assertType('(int|string)', $groupKey); + }); + + assertType('App\User|null', $collection->first()); + assertType('App\User|false', $collection->first(null, false)); + assertType('App\User|null', $collection->first(function ($user) { + assertType('App\User', $user); + + return $user->id > 1; + })); + assertType('App\User|false', $collection->first(function (User $user) { + assertType('App\User', $user); + + return $user->id > 1; + }, function () { + return false; + })); + + assertType('App\User|null', $collection->firstWhere('blocked')); + assertType('App\User|null', $collection->firstWhere('blocked', true)); + assertType('App\User|null', $collection->firstWhere('blocked', '=', true)); + + assertType('App\User|null', $collection->last()); + assertType('App\User|false', $collection->last(null, false)); + assertType('App\User|null', $collection->last(function (User $user) { + return $user->id > 1; + })); + assertType('App\User|false', $collection->last(function (User $user) { + return $user->id > 1; + }, function () { + return false; + })); + + assertType('App\User|null', $collection->get(1)); + assertType('App\User', $collection->get(1, new User())); + + assertType('App\User|null', $collection->pull(1)); + assertType('App\User', $collection->pull(1, new User())); + assertType('Hypervel\Database\Eloquent\Collection', User::all()->filter()); + + assertType('App\User', $collection->random()); + assertType('Hypervel\Database\Eloquent\Collection', $collection->random(5)); + + assertType('App\User', $collectionOfUsers->random()); + assertType('Hypervel\Support\Collection', $collectionOfUsers->random(5)); + + assertType('App\User|null', $collection->pop()); + assertType('Hypervel\Database\Eloquent\Collection', $collection->pop(5)); + + assertType('App\User|null', $collectionOfUsers->pop()); + assertType('Hypervel\Support\Collection', $collectionOfUsers->pop(5)); + + assertType('App\User|null', $collection->shift()); + assertType('Hypervel\Database\Eloquent\Collection', $collection->shift(5)); + + assertType('App\User|null', $collectionOfUsers->shift()); + assertType('Hypervel\Support\Collection', $collectionOfUsers->shift(5)); +} diff --git a/tests/PHPStan/Type/data/collection-where-not-null.php b/tests/PHPStan/Type/data/collection-where-not-null.php new file mode 100644 index 000000000..aa18dc9f7 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-where-not-null.php @@ -0,0 +1,22 @@ + $foo */ +function test(EloquentCollection $foo): void +{ + assertType('Hypervel\Support\Collection', collect([1, 2, null, '', 'hello'])->whereNotNull()); + assertType('Hypervel\Support\Collection', collect([1, 2, null, '', 'hello'])->whereNotNull()); + assertType('Hypervel\Support\Collection', collect([1, 2, null, '', 'hello'])->whereNotNull(null)); + assertType( + 'Hypervel\Support\Collection', + collect([new \App\User, new \stdClass, ['id' => 'foo'], 'foo', true, 22])->whereNotNull('id') + ); + assertType("Hypervel\Database\Eloquent\Collection", $foo->whereNotNull('blocked')); + assertType("Hypervel\Database\Eloquent\Collection", $foo->whereNotNull('blocked')); + assertType("Hypervel\Database\Eloquent\Collection", $foo->whereNotNull()); +} From 51e7e36ce407de9e082907a8940575947f1fb5b0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:56:00 +0000 Subject: [PATCH 064/303] feat: port framework helper return inference Replace the combined factory-helper extension with the tracked per-helper extensions and a shared argument-count concern. Preserve explicit-null and unpacked-call behavior for response, validator and view factories. Infer literal shapes in one pass without optional-key power-set expansion, preserve value closure semantics, and document the inherited no-argument str proxy limitation. Keep helper stubs only where source declarations are insufficient. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- .../src/Concerns/InfersFactoryHelperCalls.php | 36 +++ .../HelperFunctionReturnTypeExtension.php | 63 ---- .../ReturnTypes/Helpers/LiteralExtension.php | 184 +++++++++++ .../ReturnTypes/Helpers/ResponseExtension.php | 51 ++++ .../src/ReturnTypes/Helpers/StrExtension.php | 51 ++++ .../Helpers/ValidatorExtension.php | 53 ++++ .../ReturnTypes/Helpers/ValueExtension.php | 72 +++++ .../src/ReturnTypes/Helpers/ViewExtension.php | 55 ++++ src/phpstan/stubs/common/Helpers.stub | 12 + tests/PHPStan/Type/data/helpers.php | 287 ++++++++++++++++++ 10 files changed, 801 insertions(+), 63 deletions(-) create mode 100644 src/phpstan/src/Concerns/InfersFactoryHelperCalls.php delete mode 100644 src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/LiteralExtension.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/StrExtension.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php create mode 100644 tests/PHPStan/Type/data/helpers.php diff --git a/src/phpstan/src/Concerns/InfersFactoryHelperCalls.php b/src/phpstan/src/Concerns/InfersFactoryHelperCalls.php new file mode 100644 index 000000000..1dca39884 --- /dev/null +++ b/src/phpstan/src/Concerns/InfersFactoryHelperCalls.php @@ -0,0 +1,36 @@ +getArgs(); + + if ($arguments === []) { + return $factoryType; + } + + foreach ($arguments as $argument) { + if (! $argument->unpack) { + return $resultType; + } + } + + return TypeCombinator::union($factoryType, $resultType); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php deleted file mode 100644 index 968b5e360..000000000 --- a/src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php +++ /dev/null @@ -1,63 +0,0 @@ - [ResponseFactory::class, Response::class], - 'validator' => [ValidationFactory::class, Validator::class], - 'view' => [ViewFactory::class, ViewContract::class], - ]; - - /** - * Determine whether the helper selects its return type by argument count. - */ - public function isFunctionSupported(FunctionReflection $functionReflection): bool - { - return isset(self::HELPERS[$functionReflection->getName()]); - } - - /** - * Infer the helper result without treating an explicit null as an omitted argument. - */ - public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type - { - [$factory, $result] = self::HELPERS[$functionReflection->getName()]; - - $arguments = $functionCall->getArgs(); - - if ($arguments === []) { - return new ObjectType($factory); - } - - foreach ($arguments as $argument) { - if (! $argument->unpack) { - return new ObjectType($result); - } - } - - return TypeCombinator::union(new ObjectType($factory), new ObjectType($result)); - } -} diff --git a/src/phpstan/src/ReturnTypes/Helpers/LiteralExtension.php b/src/phpstan/src/ReturnTypes/Helpers/LiteralExtension.php new file mode 100644 index 000000000..5764cf37c --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/LiteralExtension.php @@ -0,0 +1,184 @@ +getName() === 'literal'; + } + + /** + * Get the type of the single positional argument, or of the object built from the arguments. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + $args = $functionCall->getArgs(); + + // Handle the case of a single argument, returning its type directly + if (count($args) === 1) { + $argType = $scope->getType($args[0]->value); + + $nameOfParam = $args[0]->getAttributes()['originalArg']->name->name ?? null; + + if ($nameOfParam === null) { + // Upstream also returns a non-constant unpacked array itself, which literal() + // never returns, an object for a one-item list, which it returns unwrapped, + // and keeps integer keys that PHP passes by position. + if ($args[0]->unpack) { + $constantArrays = $argType->getConstantArrays(); + + return $constantArrays === [] + ? new MixedType + : TypeCombinator::union(...array_map($this->getTypeFromConstantArray(...), $constantArrays)); + } + + if ($argType->isObject()->yes() && in_array(stdClass::class, $argType->getReferencedClasses(), true)) { + return TypeCombinator::intersect(new ObjectShapeType([], []), $argType); + } + + return $argType; + } + } + + $properties = []; + + foreach ($args as $index => $argExpression) { + // The names an unpacked argument passes are not known here. + if ($argExpression->unpack) { + return new MixedType; + } + + $nameOfParam = $argExpression->getAttributes()['originalArg']->name->name ?? null; + + if ($nameOfParam === null) { + // Handle unnamed arguments + $nameOfParam = $index; + } + + $properties[(string) $nameOfParam] = $scope->getType($argExpression->value); + } + + return TypeCombinator::intersect( + new ObjectShapeType($properties, []), + new ObjectType(stdClass::class), + ); + } + + /** + * Get the type literal() returns for the given unpacked arguments. + */ + protected function getTypeFromConstantArray(ConstantArrayType $argType): Type + { + // An unsealed array may pass any number of other arguments. + if ($argType->isUnsealed()->yes()) { + return new MixedType; + } + + $valueTypes = $argType->getValueTypes(); + $positionalTypes = []; + $requiredPositionalTypes = []; + $positionalProperties = []; + $namedProperties = []; + $optionalProperties = []; + $optionalPosition = null; + $passesName = false; + + foreach ($argType->getKeyTypes() as $i => $keyType) { + $key = $keyType->getValue(); + $isOptional = $argType->isOptionalKey($i); + + if (is_string($key)) { + $namedProperties[$key] = $valueTypes[$i]; + + if ($isOptional) { + $optionalProperties[] = $key; + } else { + $passesName = true; + } + + continue; + } + + // PHP passes integer keys by position, and throws when one follows a named argument, + // so an optional one is never passed then. + if ($passesName) { + if ($isOptional) { + continue; + } + + return new NeverType(true); + } + + // An optional argument moves the ones after it, so only earlier positions are known. + if ($optionalPosition === null) { + $position = count($positionalTypes); + $positionalProperties[$position] = $valueTypes[$i]; + + if ($isOptional) { + $optionalPosition = $position; + } + } + + $positionalTypes[] = $valueTypes[$i]; + + if (! $isOptional) { + $requiredPositionalTypes[] = $valueTypes[$i]; + } + } + + // The optional argument's own position is known only when no positional argument follows it. + if ($optionalPosition !== null) { + if ($optionalPosition === count($positionalTypes) - 1) { + $optionalProperties[] = $optionalPosition; + } else { + unset($positionalProperties[$optionalPosition]); + } + } + + $types = []; + + // A single positional argument is returned as it is. + if (! $passesName && $positionalTypes !== [] && count($requiredPositionalTypes) <= 1) { + $types[] = $requiredPositionalTypes[0] ?? TypeCombinator::union(...$positionalTypes); + } + + if (count($positionalTypes) !== 1 || $requiredPositionalTypes === [] || $namedProperties !== []) { + $types[] = TypeCombinator::intersect( + new ObjectShapeType($positionalProperties + $namedProperties, $optionalProperties), + new ObjectType(stdClass::class), + ); + } + + return TypeCombinator::union(...$types); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php new file mode 100644 index 000000000..0e4da78bd --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php @@ -0,0 +1,51 @@ +getName() === 'response'; + } + + /** + * Get the factory for a call without arguments, and otherwise the response. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + // Upstream counts arguments, so it types every call with unpacked arguments as a response, though they may be empty. + return $this->getFactoryHelperType( + $functionCall, + $this->responseFactoryType ??= new ObjectType(ResponseFactory::class), + $this->responseType ??= new ObjectType(Response::class), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/StrExtension.php b/src/phpstan/src/ReturnTypes/Helpers/StrExtension.php new file mode 100644 index 000000000..3a918855e --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/StrExtension.php @@ -0,0 +1,51 @@ +getName() === 'str'; + } + + /** + * Get mixed for a call without arguments, and otherwise a stringable. + * + * Without arguments, str() returns an anonymous class that passes method calls to Str. This + * extension does not model that proxy, so it keeps upstream's mixed type instead of the + * declared object type, which would reject every call. + */ + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + // Upstream counts arguments, so it types every call with unpacked arguments as a stringable, though they may be empty. + return $this->getFactoryHelperType( + $functionCall, + $this->mixedType ??= new MixedType, + $this->stringableType ??= new ObjectType(Stringable::class), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php new file mode 100644 index 000000000..81ee4bb4f --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php @@ -0,0 +1,53 @@ +getName() === 'validator'; + } + + /** + * Get the factory for a call without arguments, and otherwise the validator. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + // Upstream counts arguments, so it types every call with unpacked arguments as a validator, though they may be empty. + // It also claims the concrete validator, but the helper makes one through the bound factory + // contract, whose make() promises only the validator contract. + return $this->getFactoryHelperType( + $functionCall, + $this->factoryType ??= new ObjectType(Factory::class), + $this->validatorType ??= new ObjectType(Validator::class), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php new file mode 100644 index 000000000..1d5299097 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php @@ -0,0 +1,72 @@ +getName() === 'value'; + } + + /** + * Get the value's type, or the return type of a Closure value. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + if (count($functionCall->getArgs()) === 0) { + return new NeverType; + } + + $argument = $functionCall->getArgs()[0]; + + // Upstream types the list an unpacked argument holds as the value itself. + if ($argument->unpack) { + return null; + } + + $argType = $scope->getType($argument->value); + + return TypeTraverser::map($argType, static function (Type $type, callable $traverse) use ($scope): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + // Like value(), only call a Closure and return any other value as it is. + $isClosure = (new ObjectType(Closure::class))->isSuperTypeOf($type); + + if ($isClosure->no() || ! $type->isCallable()->yes()) { + return $type; + } + + $returnType = $type->getCallableParametersAcceptors($scope)[0]->getReturnType(); + + return $isClosure->yes() ? $returnType : TypeCombinator::union($type, $returnType); + }); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php new file mode 100644 index 000000000..16b32872b --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php @@ -0,0 +1,55 @@ +getName() === 'view'; + } + + /** + * Get the factory for a call without arguments, and otherwise the view. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + return $this->getFactoryHelperType( + $functionCall, + $this->factoryType ??= new ObjectType(Factory::class), + $this->viewType ??= new ObjectType(View::class), + ); + } +} diff --git a/src/phpstan/stubs/common/Helpers.stub b/src/phpstan/stubs/common/Helpers.stub index 9101b50a9..870421c3d 100644 --- a/src/phpstan/stubs/common/Helpers.stub +++ b/src/phpstan/stubs/common/Helpers.stub @@ -13,5 +13,17 @@ use Hypervel\Contracts\View\View as ViewContract; */ function view(?string $view = null, array|Arrayable $data = [], array $mergeData = []): ViewFactory|ViewContract {} +// Upstream's optional() stub is omitted: it types an Optional as mixed to accept any method call, +// while the source declares Optional, whose calls OptionalExtension passes to the value. + // Upstream's trans() and __() are omitted: the source already declares their conditional // return, and upstream's replacement type rejects objects Hypervel's translator accepts. + +// Upstream's blank(), filled(), rescue(), retry(), tap() and transform() stubs and its TapExtension +// are omitted: the source declares their types. + +// Upstream's config() and env() stubs and EnvFunctionDynamicFunctionReturnTypeExtension are omitted: +// the source declares their types, and an env() default does not decide the type of the value read. + +// Upstream's NowAndTodayExtension and DateExtension are omitted: they type dates as the class of +// now() when analysis runs, while now(), today() and the Date facade declare CarbonInterface. diff --git a/tests/PHPStan/Type/data/helpers.php b/tests/PHPStan/Type/data/helpers.php new file mode 100644 index 000000000..6ef0651c9 --- /dev/null +++ b/tests/PHPStan/Type/data/helpers.php @@ -0,0 +1,287 @@ +guard('web')); + assertType('Hypervel\Auth\SessionGuard', auth('web')); + assertType('App\Admin|App\User|null', auth()->user()); + assertType('bool', auth()->check()); + assertType('App\User|null', auth()->guard('web')->user()); + assertType('App\User|null', auth('web')->user()); + assertType('App\Admin|null', auth()->guard('admin')->user()); + assertType('App\Admin|null', auth('admin')->user()); + assertType('int|string|null', auth()->id()); + assertType('int|string|null', auth('web')->id()); + assertType('int|string|null', auth('admin')->id()); + assertType('Hypervel\Contracts\Auth\Authenticatable|false', auth()->loginUsingId(1)); + assertType('null', auth()->login(new User())); + + // REMOVED: now() and today() assertions; their CarbonInterface return types come from source, and date-extension-l1{1,2}.php covers the omitted extension. + + assertType('Hypervel\Http\RedirectResponse', redirect('/')); + assertType('Hypervel\Routing\Redirector', redirect()); + + assertType('Hypervel\Http\Request', request()); + assertType('mixed', request('foo')); + // Hypervel: the source declares the input keys and values that request() returns for an array. + assertType('array', request(['foo', 'bar'])); + + assertType('\'ok\'|null', rescue(function () { + if (mt_rand(0, 1)) { + throw new Exception(); + } + + return 'ok'; + })); + + assertType('\'failed\'|\'ok\'', rescue(function () { + if (mt_rand(0, 1)) { + throw new Exception(); + } + + return 'ok'; + }, 'failed')); + + assertType('0|\'ok\'', rescue(function () { + if (mt_rand(0, 1)) { + throw new Exception(); + } + + return 'ok'; + }, function () { + return 0; + })); + + assertType('0|\'ok\'', rescue(function () { + if (mt_rand(0, 1)) { + throw new Exception(); + } + + return 'ok'; + }, function (Throwable $e) { + return 0; + })); + + assertType('\'failed\'|\'ok\'', rescue(function () { + if (mt_rand(0, 1)) { + throw new Exception(); + } + + return 'ok'; + }, 'failed', false)); + + assertType('Hypervel\Http\Response', response('foo')); + assertType('Hypervel\Contracts\Routing\ResponseFactory', response()); + + assertType('null', retry(3, function () { + })); + + assertType('5', retry(3, function (): int { + return 5; + })); + + assertType('App\User|null', retry(5, function (): ?User { + return User::first(); + }, 0, function (): bool { + return true; + })); + + assertType('false', retry(5, function (int $attempt): bool { + return false; + }, 0, function (Exception $e): bool { + return true; + })); + + assertType('Hypervel\Support\Stringable', str('foo')); + assertType('mixed', str()); + + // Hypervel: replacing changes the subject, so only its keys are kept. + assertType('string', Str::replace('foo', 'bar', 'Laravel')); + assertType('array<0|1, string>', Str::replace('foo', 'bar', ['Laravel', 'Framework'])); + assertType('array', Str::replace('foo', 'bar', collect(['Laravel', 'Framework']))); + + assertType('App\User', tap(new User(), function (User $user): void { + $user->name = 'Can Vural'; + $user->save(); + })); + + assertType('Hypervel\Support\HigherOrderTapProxy', tap(new User())); + assertType('App\User', tap(new User())->update(['name' => 'Taylor Otwell'])); + // Hypervel: validator() returns the Validator contract its factory declares, so contract methods replace valid() and addReplacers(). + assertType('Hypervel\Contracts\Validation\Validator', tap(validator([], []))->after(fn () => null)); + + assertType('string', url('/path')); + assertType('Hypervel\Contracts\Routing\UrlGenerator', url()); + + assertType('Hypervel\Contracts\Validation\Factory', validator()); + assertType('Hypervel\Contracts\Validation\Validator', validator(['foo' => 'bar'], ['foo' => 'required'])); + assertType('array', validator(['foo' => 'bar'], ['foo' => 'required'])->validated()); + + assertType('App\User|null', value(function (): ?User { + return User::first(); + })); + + assertType('5', value(5)); + assertType('int|string', value($intOrClosure)); + assertType('mixed', value($intOrClosureWithNoDocBlock)); + assertType('Helpers\InvokableValue', value(new InvokableValue())); + assertType("'strtoupper'", value('strtoupper')); + assertType("array{Helpers\InvokableValue, '__invoke'}", value([new InvokableValue(), '__invoke'])); + assertType('array{Closure(): 1}', value([fn () => 1])); + assertType('Hypervel\Support\Collection', value(collect([fn () => 1]))); + + assertType('array|null', transform(User::first(), fn (User $user) => $user->getAttributes())); + assertType('array', transform(User::sole(), fn (User $user) => $user->getAttributes())); + + // falls back to default if provided + // Hypervel: optional() is an Optional object, which is never blank, so a value that may be null shows the default. + assertType('1', transform(optional(), fn () => 1, 'default')); + assertType("1|'default'", transform(User::first(), fn () => 1, 'default')); + // default as callable + assertType('1', transform(optional(), fn () => 1, fn () => 'string')); + assertType("1|'string'", transform(User::first(), fn () => 1, fn () => 'string')); + + // non empty values + assertType('1', transform('filled', fn () => 1)); + assertType('1', transform(['filled'], fn () => 1)); + assertType('1', transform(new User(), fn () => 1)); + + // "empty" values + assertType('null', transform(null, fn () => 1)); + assertType('null', transform('', fn () => 1)); + assertType('null', transform([], fn () => 1)); + + // Hypervel: integers are never blank, so only null remains when the value is blank. + if (filled($value)) { + assertType('int', $value); + } else { + assertType('null', $value); + } + + if (blank($value)) { + assertType('null', $value); + } else { + assertType('int', $value); + } + + // Hypervel: a default does not decide the type of a value that is set. + assertType('bool|string|null', env('foo')); + assertType('bool|string|null', env('foo')); + assertType('120|bool|string|null', env('foo', 120)); + assertType('bool|string|null', env('foo', '')); + assertType('120|bool|string|null', env('foo', fn () => 120)); + assertType('bool|string|null', env('foo', null)); + assertType('bool|string|null', env('foo', fn () => null)); + + assertType('true', literal(true)); + assertType('int<0, 10>', literal(random_int(0,10))); + assertType("object{bar: 'bar'}&stdClass", literal(bar: "bar")); + assertType("object{foo: 22, bar: 'bar'}&stdClass", literal(foo: 22, bar: "bar")); + assertType("object{foo: int<0, 5>, bar: 'bar'}&stdClass", literal(foo: random_int(0,5), bar: "bar")); + assertType("object{}&stdClass", literal(new \stdClass)); + assertType("object{}&stdClass", literal()); + assertType("object{0: 'bar', 1: 'foo'}&stdClass", literal('bar', 'foo')); + assertType("object{0: 4, bar: 'foo'}&stdClass", literal(4, bar:'foo')); + assertType("App\User", literal(new User())); + assertType("array{foo: 22, bar: 'bar'}", literal(['foo' => 22, 'bar' => "bar"])); + assertType("object{0: 5, 1: 7}&stdClass", literal(...[5,7])); + assertType("object{foo: 22, bar: 'bar'}&stdClass", literal(...['foo' => 22, 'bar' => "bar"])); +} + +/** + * Hypervel: helpers decide by the arguments given, and an unpacked argument is the list holding the values. + * + * @param list<\Closure(): 1> $closures + * @param list $arguments + */ +function testHypervelArguments(array $closures, array $arguments): void +{ + assertType('Hypervel\Support\HigherOrderTapProxy', tap(new User(), null)); + assertType('Hypervel\Support\Stringable', str(null)); + assertType('1', value(...$closures)); + assertType('Hypervel\Support\Collection', collect(...[[1, 2]])); + assertType('mixed', literal(...$arguments)); + // An unpacked list may be empty, so these may return the factory or the result. + assertType('Hypervel\Contracts\Routing\ResponseFactory|Hypervel\Http\Response', response(...$arguments)); + assertType('mixed', str(...$arguments)); + assertType('Hypervel\Contracts\Validation\Factory|Hypervel\Contracts\Validation\Validator', validator(...$arguments)); + assertType('Hypervel\Contracts\View\Factory|Hypervel\Contracts\View\View', view(...$arguments)); +} + +/** + * Hypervel: literal() receives integer keys by position and string keys by name. + * + * @param array{0: 'a', 1?: 'b'} $maybeTwo + * @param array{5?: 'a', 9: 'b'} $maybeFirst + * @param array{0?: 'a', 1?: 'b', 2?: 'c', 3?: 'd', 4?: 'e', 5?: 'f', 6?: 'g', 7?: 'h', 8?: 'i', 9?: 'j', 10?: 'k'} $maybeAny + * @param array{x: 'a', y?: 'b'} $maybeNamed + * @param array{x: 'a', 5?: 'b'} $namedThenMaybe + * @param array{x: 'a', ...} $unsealed + */ +function testLiteralUnpacking(array $maybeTwo, array $maybeFirst, array $maybeAny, array $maybeNamed, array $namedThenMaybe, array $unsealed): void +{ + assertType("object{0: 'a', 1: 'b'}&stdClass", literal(...[5 => 'a', 9 => 'b'])); + assertType("'a'", literal(...[5 => 'a'])); + assertType("object{0: 'a', x: 'b'}&stdClass", literal(...[5 => 'a', 'x' => 'b'])); + assertType('mixed', literal(...$unsealed)); + + // An optional argument moves the positions after it, and any single positional argument may be returned. + assertType("'a'|(object{0: 'a', 1?: 'b'}&stdClass)", literal(...$maybeTwo)); + assertType("'b'|(object{}&stdClass)", literal(...$maybeFirst)); + assertType("'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|(object{}&stdClass)", literal(...$maybeAny)); + assertType("object{x: 'a', y?: 'b'}&stdClass", literal(...$maybeNamed)); + + // PHP throws when a positional argument follows a named one. + assertType('*NEVER*', literal(...['x' => 'a', 5 => 'b'])); + assertType("object{x: 'a'}&stdClass", literal(...$namedThenMaybe)); +} + +/** + * @param array{loo:'loo'}|array{foo:'foo',bar:'baa'} $parameter + */ +function testUnion($parameter): int { + assertType("(object{foo: 'foo', bar: 'baa'}&stdClass)|(object{loo: 'loo'}&stdClass)", literal(...$parameter)); + + return 0; +}; + +/** + * @param callable(): int $intCallable + */ +function testCallable(callable $intCallable): void +{ + assertType('(callable(): int)|int', value($intCallable)); +} + +class InvokableValue +{ + public function __invoke(): int + { + return 1; + } +} From 3ca55607c532806623045c896316516a3cedf9b2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:56:00 +0000 Subject: [PATCH 065/303] feat: diagnose unnecessary value and with calls safely Port the value and with diagnostics with resolved function identity and named-argument handling. Preserve invalid callbacks and argument evaluation when deciding whether an automatic replacement is safe. Build replacements from the fixer's own AST to avoid corrupting files containing imported new expressions and multiple fixes. Cover namespaces, unsafe discarded arguments and replacement output in the rule fixtures. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- .../NoUselessValueFunctionCallsRule.php | 98 +++++++++++++++++ .../NoUselessWithFunctionCallsRule.php | 104 ++++++++++++++++++ .../NoUselessValueFunctionCallsRuleTest.php | 54 +++++++++ .../NoUselessWithFunctionCallsRuleTest.php | 47 ++++++++ .../data/CorrectValueFunctionCall.php | 26 +++++ .../data/CorrectWithFunctionCall.php | 31 ++++++ .../data/UselessValueFunctionCall.php | 50 +++++++++ .../data/UselessValueFunctionCallFixed.php | 50 +++++++++ .../data/UselessWithFunctionCall.php | 28 +++++ .../data/UselessWithFunctionCallFixed.php | 28 +++++ .../Rules/data/NamespacedValueFunction.php | 18 +++ 11 files changed, 534 insertions(+) create mode 100644 src/phpstan/src/Rules/UselessConstructs/NoUselessValueFunctionCallsRule.php create mode 100644 src/phpstan/src/Rules/UselessConstructs/NoUselessWithFunctionCallsRule.php create mode 100644 tests/PHPStan/Rules/UselessConstructs/NoUselessValueFunctionCallsRuleTest.php create mode 100644 tests/PHPStan/Rules/UselessConstructs/NoUselessWithFunctionCallsRuleTest.php create mode 100644 tests/PHPStan/Rules/UselessConstructs/data/CorrectValueFunctionCall.php create mode 100644 tests/PHPStan/Rules/UselessConstructs/data/CorrectWithFunctionCall.php create mode 100644 tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCall.php create mode 100644 tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCallFixed.php create mode 100644 tests/PHPStan/Rules/UselessConstructs/data/UselessWithFunctionCall.php create mode 100644 tests/PHPStan/Rules/UselessConstructs/data/UselessWithFunctionCallFixed.php create mode 100644 tests/PHPStan/Rules/data/NamespacedValueFunction.php diff --git a/src/phpstan/src/Rules/UselessConstructs/NoUselessValueFunctionCallsRule.php b/src/phpstan/src/Rules/UselessConstructs/NoUselessValueFunctionCallsRule.php new file mode 100644 index 000000000..04723e671 --- /dev/null +++ b/src/phpstan/src/Rules/UselessConstructs/NoUselessValueFunctionCallsRule.php @@ -0,0 +1,98 @@ + + */ +#[AutowiredService] +class NoUselessValueFunctionCallsRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * Report value() calls whose value is never a Closure. + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Node\Name || strtolower($this->reflectionProvider->resolveFunctionName($node->name, $scope) ?? '') !== 'value') { + return []; + } + + $args = $node->getArgs(); + + foreach ($args as $arg) { + if ($arg->unpack) { + return []; + } + } + + $valueArg = $node->getArg('value', 0); + + if ($valueArg === null) { + return []; + } + + if ($scope->getType($valueArg->value)->isSuperTypeOf(new ClosureType([], new MixedType, true))->no() === false) { + return []; + } + + $error = RuleErrorBuilder::message("Calling the helper function 'value()' without a closure as the first argument simply returns the first argument without doing anything") + ->line($node->getStartLine()) + ->identifier('hypervel.uselessConstructs.value'); + + // value() still evaluates its other arguments, so the call is replaced only when they are null, true or false. + foreach ($args as $arg) { + if ($arg !== $valueArg && ! ($arg->value instanceof ConstFetch && in_array($arg->value->name->toLowerString(), ['null', 'true', 'false'], true))) { + return [$error->build()]; + } + } + + return [$error->fixNode($node, static function (FuncCall $call): Node { + // The fixer passes a copy of the call from its own parse of the file; a node from the + // analyzed tree has positions the fixer's printer cannot use. + /** @var Arg $valueArg */ + $valueArg = $call->getArg('value', 0); + + return $valueArg->value; + })->build()]; + } +} diff --git a/src/phpstan/src/Rules/UselessConstructs/NoUselessWithFunctionCallsRule.php b/src/phpstan/src/Rules/UselessConstructs/NoUselessWithFunctionCallsRule.php new file mode 100644 index 000000000..3d0b7343d --- /dev/null +++ b/src/phpstan/src/Rules/UselessConstructs/NoUselessWithFunctionCallsRule.php @@ -0,0 +1,104 @@ + + */ +#[AutowiredService] +class NoUselessWithFunctionCallsRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * Report with() calls whose callback is missing or null. + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Node\Name || strtolower($this->reflectionProvider->resolveFunctionName($node->name, $scope) ?? '') !== 'with') { + return []; + } + + $args = $node->getArgs(); + + foreach ($args as $arg) { + if ($arg->unpack) { + return []; + } + } + + $valueArg = $node->getArg('value', 0); + + if ($valueArg === null) { + return []; + } + + $callbackArg = $node->getArg('callback', 1); + + // Any other value that is not callable fails the ?callable parameter, so only null returns the value. + if ($callbackArg === null) { + $message = "Calling the helper function 'with()' with only one argument simply returns the value itself. If you want to chain methods on a construct, use '(new ClassName())->foo()' instead"; + } elseif ($scope->getType($callbackArg->value)->isNull()->yes()) { + $message = "Calling the helper function 'with()' without a callable as the second argument simply returns the value without doing anything"; + } else { + return []; + } + + $error = RuleErrorBuilder::message($message) + ->line($node->getStartLine()) + ->identifier('hypervel.uselessConstructs.with'); + + // with() still evaluates its other arguments, so the call is replaced only when they are null, true or false. + foreach ($args as $arg) { + if ($arg !== $valueArg && ! ($arg->value instanceof ConstFetch && in_array($arg->value->name->toLowerString(), ['null', 'true', 'false'], true))) { + return [$error->build()]; + } + } + + return [$error->fixNode($node, static function (FuncCall $call): Node { + // The fixer passes a copy of the call from its own parse of the file; a node from the + // analyzed tree has positions the fixer's printer cannot use. + /** @var Arg $valueArg */ + $valueArg = $call->getArg('value', 0); + + return $valueArg->value; + })->build()]; + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/NoUselessValueFunctionCallsRuleTest.php b/tests/PHPStan/Rules/UselessConstructs/NoUselessValueFunctionCallsRuleTest.php new file mode 100644 index 000000000..5183ee442 --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/NoUselessValueFunctionCallsRuleTest.php @@ -0,0 +1,54 @@ + */ +class NoUselessValueFunctionCallsRuleTest extends RuleTestCase +{ + public function testNoFalsePositives(): void + { + $this->analyse( + [ + __DIR__ . '/data/CorrectValueFunctionCall.php', + __DIR__ . '/../data/NamespacedValueFunction.php', + ], + [], + ); + } + + public function testUselessValueCalls(): void + { + $message = "Calling the helper function 'value()' without a closure as the first argument simply returns the first argument without doing anything"; + + $this->analyse( + [ + __DIR__ . '/data/UselessValueFunctionCall.php', + ], + [ + [$message, 13], + [$message, 18], + [$message, 23], + [$message, 28], + [$message, 33], + [$message, 38], + [$message, 43], + ], + ); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/UselessValueFunctionCall.php', __DIR__ . '/data/UselessValueFunctionCallFixed.php'); + } + + protected function getRule(): Rule + { + return new NoUselessValueFunctionCallsRule($this->createReflectionProvider()); + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/NoUselessWithFunctionCallsRuleTest.php b/tests/PHPStan/Rules/UselessConstructs/NoUselessWithFunctionCallsRuleTest.php new file mode 100644 index 000000000..dfadc5deb --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/NoUselessWithFunctionCallsRuleTest.php @@ -0,0 +1,47 @@ + */ +class NoUselessWithFunctionCallsRuleTest extends RuleTestCase +{ + public function testNoFalsePositives(): void + { + $this->analyse( + [ + __DIR__ . '/data/CorrectWithFunctionCall.php', + ], + [], + ); + } + + public function testUselessWithCalls(): void + { + $this->analyse( + [ + __DIR__ . '/data/UselessWithFunctionCall.php', + ], + [ + ["Calling the helper function 'with()' with only one argument simply returns the value itself. If you want to chain methods on a construct, use '(new ClassName())->foo()' instead", 11], + ["Calling the helper function 'with()' without a callable as the second argument simply returns the value without doing anything", 16], + ["Calling the helper function 'with()' with only one argument simply returns the value itself. If you want to chain methods on a construct, use '(new ClassName())->foo()' instead", 26], + ], + ); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/UselessWithFunctionCall.php', __DIR__ . '/data/UselessWithFunctionCallFixed.php'); + } + + protected function getRule(): Rule + { + return new NoUselessWithFunctionCallsRule($this->createReflectionProvider()); + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/data/CorrectValueFunctionCall.php b/tests/PHPStan/Rules/UselessConstructs/data/CorrectValueFunctionCall.php new file mode 100644 index 000000000..d0ed92e33 --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/data/CorrectValueFunctionCall.php @@ -0,0 +1,26 @@ + 'foo'); + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/data/CorrectWithFunctionCall.php b/tests/PHPStan/Rules/UselessConstructs/data/CorrectWithFunctionCall.php new file mode 100644 index 000000000..8d2a98a14 --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/data/CorrectWithFunctionCall.php @@ -0,0 +1,31 @@ + strtoupper($bar), value: 'foo'); + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCall.php b/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCall.php new file mode 100644 index 000000000..75842a0cd --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCall.php @@ -0,0 +1,50 @@ +count()); + } + + public function undefinedConstantArgument(): string + { + return value('foo', UNDEFINED_CONSTANT); + } + + public function created(): ArrayObject + { + return value(new ArrayObject()); + } + + private function count(): int + { + return 1; + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCallFixed.php b/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCallFixed.php new file mode 100644 index 000000000..caf3661c5 --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCallFixed.php @@ -0,0 +1,50 @@ +count()); + } + + public function undefinedConstantArgument(): string + { + return value('foo', UNDEFINED_CONSTANT); + } + + public function created(): ArrayObject + { + return new ArrayObject(); + } + + private function count(): int + { + return 1; + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/data/UselessWithFunctionCall.php b/tests/PHPStan/Rules/UselessConstructs/data/UselessWithFunctionCall.php new file mode 100644 index 000000000..995f06ced --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/data/UselessWithFunctionCall.php @@ -0,0 +1,28 @@ + Date: Thu, 5 Nov 2026 12:56:00 +0000 Subject: [PATCH 066/303] feat: recommend all for collections without Arrayable values Port the unnecessary toArray diagnostic for collections whose items require no conversion. Respect custom collection implementations and delegated map or all behavior before recommending a replacement. Keep focused rule fixtures for ordinary values, Arrayable items and overridden conversion methods. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- ...oUnnecessaryEnumerableToArrayCallsRule.php | 89 +++++++++++++++++++ ...ecessaryEnumerableToArrayCallsRuleTest.php | 28 ++++++ .../unnecessary-enumerable-toArray-calls.php | 67 ++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 src/phpstan/src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php create mode 100644 tests/PHPStan/Rules/NoUnnecessaryEnumerableToArrayCallsRuleTest.php create mode 100644 tests/PHPStan/Rules/data/unnecessary-enumerable-toArray-calls.php diff --git a/src/phpstan/src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php b/src/phpstan/src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php new file mode 100644 index 000000000..56aea8512 --- /dev/null +++ b/src/phpstan/src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php @@ -0,0 +1,89 @@ +toArray() + * could be simplified to: + * collect([1, 2, 3])->all() + * + * @implements Rule + */ +#[Conditional(attribute: new AutowiredService, if: '%noUnnecessaryEnumerableToArrayCalls%')] +final class NoUnnecessaryEnumerableToArrayCallsRule implements Rule +{ + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Report toArray() calls that return the same array as all(). + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $name = $node->name; + + if (! $name instanceof Identifier || $name->toString() !== 'toArray') { + return []; + } + + $calledOnType = $scope->getType($node->var); + + if (! (new ObjectType(Enumerable::class))->isSuperTypeOf($calledOnType)->yes()) { + return []; + } + + $valueType = $calledOnType->getTemplateType(Enumerable::class, 'TValue'); + + if (! (new ObjectType(Arrayable::class))->isSuperTypeOf($valueType)->no()) { + return []; + } + + // Upstream checks only the items. A collection that overrides toArray(), all() or the + // map() that toArray() runs may convert its items differently, as may another Enumerable. + // The Eloquent collection's map() only changes the collection class it returns. + foreach ($calledOnType->getObjectClassReflections() as $classReflection) { + if (! CollectionHelper::usesFrameworkMethods($classReflection, ['toArray', 'all', 'map'])) { + return []; + } + } + + return [ + RuleErrorBuilder::message( + 'Called [toArray()] on an Enumerable which does not contain any Arrayables.', + ) + ->tip('Use [all()] to get the items as an array.') + ->identifier('hypervel.unnecessaryEnumerableToArrayCall') + ->line($name->getStartLine()) + ->build(), + ]; + } +} diff --git a/tests/PHPStan/Rules/NoUnnecessaryEnumerableToArrayCallsRuleTest.php b/tests/PHPStan/Rules/NoUnnecessaryEnumerableToArrayCallsRuleTest.php new file mode 100644 index 000000000..c9f406ef3 --- /dev/null +++ b/tests/PHPStan/Rules/NoUnnecessaryEnumerableToArrayCallsRuleTest.php @@ -0,0 +1,28 @@ + */ +class NoUnnecessaryEnumerableToArrayCallsRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new NoUnnecessaryEnumerableToArrayCallsRule; + } + + public function testRule(): void + { + $message = "Called [toArray()] on an Enumerable which does not contain any Arrayables.\n 💡 Use [all()] to get the items as an array."; + + $this->analyse([__DIR__ . '/data/unnecessary-enumerable-toArray-calls.php'], [ + [$message, 33], + [$message, 34], + ]); + } +} diff --git a/tests/PHPStan/Rules/data/unnecessary-enumerable-toArray-calls.php b/tests/PHPStan/Rules/data/unnecessary-enumerable-toArray-calls.php new file mode 100644 index 000000000..3209060fc --- /dev/null +++ b/tests/PHPStan/Rules/data/unnecessary-enumerable-toArray-calls.php @@ -0,0 +1,67 @@ + $ints + * @param PlainCollection $plain + * @param EloquentCollection $models + * @param LazyCollection $innerUnion + * @param EloquentCollection|Collection $outerUnion + * @param ToArrayOverridingCollection $overriding + * @param Enumerable $enumerable + */ +function test( + mixed $unknown, + Collection $ints, + PlainCollection $plain, + EloquentCollection $models, + LazyCollection $innerUnion, + Enumerable $outerUnion, + ToArrayOverridingCollection $overriding, + Enumerable $enumerable, +): void { + // should be reported + $ints->toArray(); + $plain->toArray(); + + // should not be reported + $unknown->toArray(); + $models->toArray(); + $innerUnion->toArray(); + $outerUnion->toArray(); + $overriding->toArray(); + $enumerable->toArray(); +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class PlainCollection extends Collection +{ +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class ToArrayOverridingCollection extends Collection +{ + public function toArray(): array + { + return ['count' => $this->count()]; + } +} From efddefd2a53a992e0a65fde45788b8452e32d4d8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:56:00 +0000 Subject: [PATCH 067/303] refactor: simplify framework collection and helper consumers Use all where source item types prove that no Arrayable conversion occurs, and remove value calls that cannot invoke a Closure. Remove obsolete local suppressions now covered by the shared proxy inference. Preserve the existing consumer behavior and runtime API contracts across foundation, database, process, routing, pagination, support and view code. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- src/database/src/Eloquent/Casts/AsEnumArrayObject.php | 2 +- src/database/src/Eloquent/Casts/AsEnumCollection.php | 2 +- src/database/src/Eloquent/Collection.php | 9 +++++++-- src/foundation/src/Bus/Dispatchable.php | 8 ++++---- src/foundation/src/Console/AboutCommand.php | 2 +- src/foundation/src/Console/ChannelListCommand.php | 2 +- src/foundation/src/Console/CliDumper.php | 2 +- src/foundation/src/Http/HtmlDumper.php | 2 +- src/pagination/src/LengthAwarePaginator.php | 2 +- src/process/src/FakeProcessSequence.php | 2 +- src/process/src/PendingProcess.php | 2 +- src/routing/src/Route.php | 2 +- src/support/src/DefaultProviders.php | 4 ++-- src/view/src/Compilers/ComponentTagCompiler.php | 2 +- src/view/src/View.php | 4 ++-- types/Database/Eloquent/Collection.php | 6 ++++++ 16 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/database/src/Eloquent/Casts/AsEnumArrayObject.php b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php index b4a4063da..b7034cccc 100644 --- a/src/database/src/Eloquent/Casts/AsEnumArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php @@ -113,7 +113,7 @@ public function serialize(Model $model, string $key, mixed $value, array $attrib { return (new Collection($value->getArrayCopy())) ->map(fn ($enum) => $this->getStorableEnumValue($enum)) - ->toArray(); + ->all(); } /** diff --git a/src/database/src/Eloquent/Casts/AsEnumCollection.php b/src/database/src/Eloquent/Casts/AsEnumCollection.php index e20d5ebc1..f4d3c0234 100644 --- a/src/database/src/Eloquent/Casts/AsEnumCollection.php +++ b/src/database/src/Eloquent/Casts/AsEnumCollection.php @@ -97,7 +97,7 @@ public function serialize(Model $model, string $key, mixed $value, array $attrib { return (new Collection($value)) ->map(fn ($enum) => $this->getStorableEnumValue($enum)) - ->toArray(); + ->all(); } /** diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index 545d62ad1..a329979b0 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -14,6 +14,7 @@ use Hypervel\Support\Collection as BaseCollection; use LogicException; use Override; +use Stringable; /** * @template TKey of array-key @@ -861,7 +862,12 @@ public function partition(mixed $key, mixed $operator = null, mixed $value = nul /** * Get the values of a given key. * - * @return BaseCollection + * @template TPluckValue + * @template TPluckKey of array-key|Stringable + * + * @param null|array|(Closure(TModel): TPluckValue)|int|string $value + * @param null|array|(Closure(TModel): TPluckKey)|int|string $key + * @return BaseCollection<($key is Closure ? (TPluckKey is int ? int : string) : array-key), ($value is Closure ? TPluckValue : mixed)> */ #[Override] public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|null $key = null): BaseCollection @@ -1012,7 +1018,6 @@ public function getQueueableRelations(): array return []; } - // @phpstan-ignore method.nonObject (HigherOrderProxy returns Collection, not array) $relations = $this->map->getQueueableRelations()->all(); if (count($relations) === 0 || $relations === [[]]) { diff --git a/src/foundation/src/Bus/Dispatchable.php b/src/foundation/src/Bus/Dispatchable.php index 299cf3f53..b01f65691 100644 --- a/src/foundation/src/Bus/Dispatchable.php +++ b/src/foundation/src/Bus/Dispatchable.php @@ -29,12 +29,12 @@ public static function dispatchIf(bool|Closure $boolean, mixed ...$arguments): F if ($boolean instanceof Closure) { $dispatchable = new static(...$arguments); - return value($boolean, $dispatchable) // @phpstan-ignore ternary.alwaysTrue (phpstan over-narrows TValue from conditional return PHPDoc) + return value($boolean, $dispatchable) ? static::newPendingDispatch($dispatchable) : new Fluent; } - return value($boolean) + return $boolean ? static::newPendingDispatch(new static(...$arguments)) : new Fluent; } @@ -47,12 +47,12 @@ public static function dispatchUnless(bool|Closure $boolean, mixed ...$arguments if ($boolean instanceof Closure) { $dispatchable = new static(...$arguments); - return ! value($boolean, $dispatchable) // @phpstan-ignore booleanNot.alwaysFalse + return ! value($boolean, $dispatchable) ? static::newPendingDispatch($dispatchable) : new Fluent; } - return ! value($boolean) + return ! $boolean ? static::newPendingDispatch(new static(...$arguments)) : new Fluent; } diff --git a/src/foundation/src/Console/AboutCommand.php b/src/foundation/src/Console/AboutCommand.php index 73d327719..2613c1613 100644 --- a/src/foundation/src/Console/AboutCommand.php +++ b/src/foundation/src/Console/AboutCommand.php @@ -247,7 +247,7 @@ protected function determineStoragePathLinkStatus(callable $formatStorageLinkedS return [public_path($path) => static::format(is_link($link), console: $formatStorageLinkedStatus)]; }) - ->toArray(); + ->all(); } /** diff --git a/src/foundation/src/Console/ChannelListCommand.php b/src/foundation/src/Console/ChannelListCommand.php index b098d4c6d..0de80ab1c 100644 --- a/src/foundation/src/Console/ChannelListCommand.php +++ b/src/foundation/src/Console/ChannelListCommand.php @@ -92,7 +92,7 @@ protected function forCli(Collection $channels): array return $lines ->prepend('') ->push('')->push($channelCount)->push('') - ->toArray(); + ->all(); } /** diff --git a/src/foundation/src/Console/CliDumper.php b/src/foundation/src/Console/CliDumper.php index 85321db4b..4fdd28400 100644 --- a/src/foundation/src/Console/CliDumper.php +++ b/src/foundation/src/Console/CliDumper.php @@ -52,7 +52,7 @@ public function __construct( */ public static function register(string $basePath, string $compiledViewPath): void { - $cloner = tap(new VarCloner)->addCasters(ReflectionCaster::UNSET_CLOSURE_FILE_INFO); // @phpstan-ignore method.notFound (tap proxy __call) + $cloner = tap(new VarCloner)->addCasters(ReflectionCaster::UNSET_CLOSURE_FILE_INFO); $dumper = new static(new ConsoleOutput, $basePath, $compiledViewPath); diff --git a/src/foundation/src/Http/HtmlDumper.php b/src/foundation/src/Http/HtmlDumper.php index e4dfdc9e9..597d817f1 100644 --- a/src/foundation/src/Http/HtmlDumper.php +++ b/src/foundation/src/Http/HtmlDumper.php @@ -46,7 +46,7 @@ public function __construct( */ public static function register(string $basePath, string $compiledViewPath): void { - $cloner = tap(new VarCloner)->addCasters(ReflectionCaster::UNSET_CLOSURE_FILE_INFO); // @phpstan-ignore method.notFound (tap proxy __call) + $cloner = tap(new VarCloner)->addCasters(ReflectionCaster::UNSET_CLOSURE_FILE_INFO); $dumper = new static($basePath, $compiledViewPath); diff --git a/src/pagination/src/LengthAwarePaginator.php b/src/pagination/src/LengthAwarePaginator.php index b879f7d68..9be9dc405 100644 --- a/src/pagination/src/LengthAwarePaginator.php +++ b/src/pagination/src/LengthAwarePaginator.php @@ -200,7 +200,7 @@ public function toArray(): array 'from' => $this->firstItem(), 'last_page' => $this->lastPage(), 'last_page_url' => $this->url($this->lastPage()), - 'links' => $this->linkCollection()->toArray(), + 'links' => $this->linkCollection()->all(), 'next_page_url' => $this->nextPageUrl(), 'path' => $this->path(), 'per_page' => $this->perPage(), diff --git a/src/process/src/FakeProcessSequence.php b/src/process/src/FakeProcessSequence.php index fb91cc7ba..caac3d4da 100644 --- a/src/process/src/FakeProcessSequence.php +++ b/src/process/src/FakeProcessSequence.php @@ -93,7 +93,7 @@ public function __invoke(): FakeProcessDescription|ProcessResultContract } if (! $this->failWhenEmpty && count($this->processes) === 0) { - return value($this->emptyProcess ?? new FakeProcessResult); + return $this->emptyProcess ?? new FakeProcessResult; } return array_shift($this->processes); diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index f9aa57bd9..24a909b66 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -230,7 +230,7 @@ public function run(array|string|null $command = null, ?callable $output = null) ConnectionResolver::releaseIdleConnections(); - return new ProcessResult(tap($process)->run($this->guardProcessOutput($process, $output))); // @phpstan-ignore method.notFound (tap proxy __call) + return new ProcessResult(tap($process)->run($this->guardProcessOutput($process, $output))); } catch (SymfonyTimeoutException $e) { throw ProcessTimedOutException::make($e, new ProcessResult($process)); } catch (Throwable $exception) { diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index 57510f1ba..b3bc060c1 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -1245,7 +1245,7 @@ protected function staticallyProvidedControllerMiddleware(string $class, string }) ->map ->middleware - ->flatten() // @phpstan-ignore method.nonObject (HigherOrderCollectionProxy result can't be statically typed) + ->flatten() ->values() ->all(); } diff --git a/src/support/src/DefaultProviders.php b/src/support/src/DefaultProviders.php index 4fc4dab41..97581f0cb 100644 --- a/src/support/src/DefaultProviders.php +++ b/src/support/src/DefaultProviders.php @@ -94,7 +94,7 @@ public function replace(array $replacements): static $current = is_int($key) ? $current->replace([$key => $to]) : $current; } - return new static($current->values()->toArray()); + return new static($current->values()->all()); } /** @@ -107,7 +107,7 @@ public function except(array $providers): static return new static((new Collection($this->providers)) ->diff($providers) ->values() - ->toArray()); + ->all()); } /** diff --git a/src/view/src/Compilers/ComponentTagCompiler.php b/src/view/src/Compilers/ComponentTagCompiler.php index d34d643d0..81111facc 100644 --- a/src/view/src/Compilers/ComponentTagCompiler.php +++ b/src/view/src/Compilers/ComponentTagCompiler.php @@ -621,7 +621,7 @@ protected function getAttributesFromAttributeString(string $attributeString): ar } return [$attribute => $value]; - })->toArray(); + })->all(); } /** diff --git a/src/view/src/View.php b/src/view/src/View.php index 04c08479f..83d176f03 100755 --- a/src/view/src/View.php +++ b/src/view/src/View.php @@ -77,7 +77,7 @@ public function fragments(?array $fragments = null): string */ public function fragmentIf(bool $boolean, string $fragment): string|Htmlable { - if (value($boolean)) { + if ($boolean) { return $this->fragment($fragment); } @@ -89,7 +89,7 @@ public function fragmentIf(bool $boolean, string $fragment): string|Htmlable */ public function fragmentsIf(bool $boolean, ?array $fragments = null): string { - if (value($boolean)) { + if ($boolean) { return $this->fragments($fragments); } diff --git a/types/Database/Eloquent/Collection.php b/types/Database/Eloquent/Collection.php index 4ef1b4c33..1b26627a6 100644 --- a/types/Database/Eloquent/Collection.php +++ b/types/Database/Eloquent/Collection.php @@ -4,6 +4,7 @@ use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Support\Stringable; use function PHPStan\Testing\assertType; @@ -226,6 +227,11 @@ function assertEloquentCollectionAggregateExpressionTypes(Collection $collection assertType('Hypervel\Support\Collection<(int|string), mixed>', $collection->pluck('string')); assertType('Hypervel\Support\Collection<(int|string), mixed>', $collection->pluck(['string'])); +assertType('Hypervel\Support\Collection<(int|string), int>', $collection->pluck(fn (User $user): int => 1)); +assertType('Hypervel\Support\Collection', $collection->pluck('string', fn (User $user): string => 'key')); +assertType('Hypervel\Support\Collection', $collection->pluck('string', fn (User $user) => 'key')); +assertType('Hypervel\Support\Collection', $collection->pluck(fn (User $user): string => 'value', fn (User $user): int => 1)); +assertType('Hypervel\Support\Collection', $collection->pluck(fn (User $user): int => 1, fn (User $user): Stringable => new Stringable('key'))); assertType('Hypervel\Support\Collection', $collection->keys()); From a2749217b27778e9392a5762786a9b21c8d79753 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:56:00 +0000 Subject: [PATCH 068/303] refactor: adopt precise collection access in framework integrations Use direct collection items where conversion is unnecessary in the affected integration packages. Remove stale forwarding suppressions now resolved by the package's higher-order and Optional typing. Keep the integrations' runtime behavior unchanged, including enum/scalar values, repository results and captured event or job data. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- src/data/src/Support/Transformation/DataTransformer.php | 2 +- src/horizon/src/Console/ContinueSupervisorCommand.php | 2 +- src/horizon/src/Console/PauseSupervisorCommand.php | 2 +- src/horizon/src/Console/SupervisorStatusCommand.php | 2 +- src/horizon/src/Http/Controllers/WorkloadController.php | 2 +- .../src/Repositories/RedisMasterSupervisorRepository.php | 1 - src/horizon/src/Repositories/RedisWorkloadRepository.php | 2 +- src/inertia/src/Middleware.php | 2 +- src/mail/src/Transport/SesV2Transport.php | 2 +- src/permission/src/Commands/ShowCommand.php | 2 +- src/scout/src/Engines/MeilisearchEngine.php | 2 +- src/telescope/src/Storage/DatabaseEntriesRepository.php | 4 ++-- src/telescope/src/Watchers/EventWatcher.php | 4 ++-- src/telescope/src/Watchers/ExceptionWatcher.php | 2 +- src/telescope/src/Watchers/JobWatcher.php | 2 +- src/telescope/src/Watchers/ViewWatcher.php | 2 +- src/wayfinder/src/Route.php | 2 +- 17 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/data/src/Support/Transformation/DataTransformer.php b/src/data/src/Support/Transformation/DataTransformer.php index 115d32f74..ea45140e2 100644 --- a/src/data/src/Support/Transformation/DataTransformer.php +++ b/src/data/src/Support/Transformation/DataTransformer.php @@ -404,7 +404,7 @@ public function paginationInformation( ): array { if ($paginator instanceof LengthAwarePaginator) { return [ - 'links' => $paginator->linkCollection()->toArray(), + 'links' => $paginator->linkCollection()->all(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'first_page_url' => $paginator->url(1), diff --git a/src/horizon/src/Console/ContinueSupervisorCommand.php b/src/horizon/src/Console/ContinueSupervisorCommand.php index 1923f016d..6ecef9c1b 100644 --- a/src/horizon/src/Console/ContinueSupervisorCommand.php +++ b/src/horizon/src/Console/ContinueSupervisorCommand.php @@ -29,7 +29,7 @@ class ContinueSupervisorCommand extends Command */ public function handle(SupervisorRepository $supervisors): int { - $processId = (int) optional(collect($supervisors->all())->first(function ($supervisor) { // @phpstan-ignore property.notFound (optional() __get proxy) + $processId = (int) optional(collect($supervisors->all())->first(function ($supervisor) { return Str::startsWith($supervisor->name, MasterSupervisor::basename()) && Str::endsWith($supervisor->name, $this->argument('name')); }))->pid; diff --git a/src/horizon/src/Console/PauseSupervisorCommand.php b/src/horizon/src/Console/PauseSupervisorCommand.php index 6555e687a..87c16025f 100644 --- a/src/horizon/src/Console/PauseSupervisorCommand.php +++ b/src/horizon/src/Console/PauseSupervisorCommand.php @@ -29,7 +29,7 @@ class PauseSupervisorCommand extends Command */ public function handle(SupervisorRepository $supervisors): int { - $processId = (int) optional(collect($supervisors->all())->first(function ($supervisor) { // @phpstan-ignore property.notFound (optional() __get proxy) + $processId = (int) optional(collect($supervisors->all())->first(function ($supervisor) { return Str::startsWith($supervisor->name, MasterSupervisor::basename()) && Str::endsWith($supervisor->name, $this->argument('name')); }))->pid; diff --git a/src/horizon/src/Console/SupervisorStatusCommand.php b/src/horizon/src/Console/SupervisorStatusCommand.php index e557642fe..9ca376fce 100644 --- a/src/horizon/src/Console/SupervisorStatusCommand.php +++ b/src/horizon/src/Console/SupervisorStatusCommand.php @@ -31,7 +31,7 @@ public function handle(SupervisorRepository $supervisors): int { $name = $this->argument('name'); - $supervisorStatus = optional(collect($supervisors->all())->first(function ($supervisor) use ($name) { // @phpstan-ignore property.notFound (optional() __get proxy) + $supervisorStatus = optional(collect($supervisors->all())->first(function ($supervisor) use ($name) { return Str::startsWith($supervisor->name, MasterSupervisor::basename()) && Str::endsWith($supervisor->name, $name); }))->status; diff --git a/src/horizon/src/Http/Controllers/WorkloadController.php b/src/horizon/src/Http/Controllers/WorkloadController.php index a29932a7a..4e0528f41 100644 --- a/src/horizon/src/Http/Controllers/WorkloadController.php +++ b/src/horizon/src/Http/Controllers/WorkloadController.php @@ -13,6 +13,6 @@ class WorkloadController extends Controller */ public function index(WorkloadRepository $workload): array { - return collect($workload->get())->sortBy('name')->values()->toArray(); + return collect($workload->get())->sortBy('name')->values()->all(); } } diff --git a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php index 8dd123f45..9e358f5cb 100644 --- a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php +++ b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php @@ -84,7 +84,6 @@ public function get(array $names): array */ public function update(MasterSupervisor $master): void { - // @phpstan-ignore method.nonObject (HigherOrderProxy: ->map->name returns Collection, not string) $supervisors = $master->supervisors->map->name->all(); $this->pipeline(function ($pipe) use ($master, $supervisors) { diff --git a/src/horizon/src/Repositories/RedisWorkloadRepository.php b/src/horizon/src/Repositories/RedisWorkloadRepository.php index 61ed1724c..0f5ae28df 100644 --- a/src/horizon/src/Repositories/RedisWorkloadRepository.php +++ b/src/horizon/src/Repositories/RedisWorkloadRepository.php @@ -73,7 +73,7 @@ public function get(): array 'processes' => $totalProcesses, 'split_queues' => $splitQueues, ]; - })->values()->toArray(); + })->values()->all(); } /** diff --git a/src/inertia/src/Middleware.php b/src/inertia/src/Middleware.php index df1315708..3714c1f28 100644 --- a/src/inertia/src/Middleware.php +++ b/src/inertia/src/Middleware.php @@ -333,7 +333,7 @@ public function resolveValidationErrors(Request $request): object return (object) collect($bags)->map(function ($bag) { return (object) collect($bag->messages())->map(function ($errors) { return $this->withAllErrors ? $errors : $errors[0]; - })->toArray(); + })->all(); })->pipe(function ($bags) use ($request) { $errorBag = $request->header(Header::ERROR_BAG); diff --git a/src/mail/src/Transport/SesV2Transport.php b/src/mail/src/Transport/SesV2Transport.php index fd51d649d..ad69392d2 100644 --- a/src/mail/src/Transport/SesV2Transport.php +++ b/src/mail/src/Transport/SesV2Transport.php @@ -62,7 +62,7 @@ protected function doSend(SentMessage $message): void 'ToAddresses' => (new Collection($message->getEnvelope()->getRecipients())) ->map ->toString() - ->values() // @phpstan-ignore method.nonObject (HigherOrderProxy: ->map->toString() returns Collection, not string) + ->values() ->all(), ], 'Content' => [ diff --git a/src/permission/src/Commands/ShowCommand.php b/src/permission/src/Commands/ShowCommand.php index 9f3e68027..5213d1f3b 100644 --- a/src/permission/src/Commands/ShowCommand.php +++ b/src/permission/src/Commands/ShowCommand.php @@ -88,7 +88,7 @@ public function handle(PermissionRegistrar $permissionRegistrar): int array_pop($name); return implode('_', $name); - })->toArray(); + })->all(); array_unshift($roleHeaders, new TableCell('')); $teamHeaders = $teams ? $teams->toArray() : []; diff --git a/src/scout/src/Engines/MeilisearchEngine.php b/src/scout/src/Engines/MeilisearchEngine.php index 4378f3255..c268d1d4a 100644 --- a/src/scout/src/Engines/MeilisearchEngine.php +++ b/src/scout/src/Engines/MeilisearchEngine.php @@ -394,7 +394,7 @@ protected function buildSortFromOrderByClauses(Builder $builder): array { return collect($builder->orders) ->map(fn (array $order) => $order['column'] . ':' . $order['direction']) - ->toArray(); + ->all(); } /** diff --git a/src/telescope/src/Storage/DatabaseEntriesRepository.php b/src/telescope/src/Storage/DatabaseEntriesRepository.php index 03c0b4256..c9cc0ba7b 100644 --- a/src/telescope/src/Storage/DatabaseEntriesRepository.php +++ b/src/telescope/src/Storage/DatabaseEntriesRepository.php @@ -223,7 +223,7 @@ protected function storeExceptions(Collection $exceptions): void array_merge($exception->content, ['occurrences' => $occurrences[$familyHash]]) ), ]); - })->toArray(); + })->all(); $connection = DB::connection($this->connection); @@ -328,7 +328,7 @@ protected function updateTags(EntryUpdate $entry): void 'entry_uuid' => $entry->uuid, 'tag' => $tag, ]; - })->toArray() + })->all() ); } catch (UniqueConstraintViolationException) { // Ignore tags that already exist... diff --git a/src/telescope/src/Watchers/EventWatcher.php b/src/telescope/src/Watchers/EventWatcher.php index 7020c777e..98d638998 100644 --- a/src/telescope/src/Watchers/EventWatcher.php +++ b/src/telescope/src/Watchers/EventWatcher.php @@ -73,7 +73,7 @@ protected function extractPayload(string $event, array $payload): array 'class' => get_class($value), 'properties' => JsonNormalizer::normalize($value), ] : $value; - })->toArray(); + })->all(); } /** @@ -112,7 +112,7 @@ protected function formatListeners(string $eventName): array 'name' => $listener, 'queued' => $queued ?? false, ]; - })->values()->toArray(); + })->values()->all(); } /** diff --git a/src/telescope/src/Watchers/ExceptionWatcher.php b/src/telescope/src/Watchers/ExceptionWatcher.php index ea6098aea..0994f8128 100644 --- a/src/telescope/src/Watchers/ExceptionWatcher.php +++ b/src/telescope/src/Watchers/ExceptionWatcher.php @@ -39,7 +39,7 @@ public function recordException(MessageLogged $event): void $trace = Collection::make($exception->getTrace())->map(function ($item) { return Arr::only($item, ['file', 'line']); - })->toArray(); + })->all(); $content = [ 'class' => get_class($exception), diff --git a/src/telescope/src/Watchers/JobWatcher.php b/src/telescope/src/Watchers/JobWatcher.php index e6fffa720..421e8993a 100644 --- a/src/telescope/src/Watchers/JobWatcher.php +++ b/src/telescope/src/Watchers/JobWatcher.php @@ -44,7 +44,7 @@ class JobWatcher extends Watcher public function register(Application $app): void { Queue::createPayloadUsing(function ($connection, $queue, $payload) { - return ['telescope_uuid' => optional($this->recordJob($connection, $queue, $payload))->uuid]; // @phpstan-ignore property.notFound (optional() __get proxy) + return ['telescope_uuid' => optional($this->recordJob($connection, $queue, $payload))->uuid]; }); $app->make(Dispatcher::class) diff --git a/src/telescope/src/Watchers/ViewWatcher.php b/src/telescope/src/Watchers/ViewWatcher.php index e83f02817..ae2062145 100644 --- a/src/telescope/src/Watchers/ViewWatcher.php +++ b/src/telescope/src/Watchers/ViewWatcher.php @@ -74,7 +74,7 @@ protected function extractKeysFromData(ViewContract $view): array { return Collection::make($view->getData())->filter(function ($value, $key) { return ! in_array($key, ['app', '__env', 'obLevel', 'errors'], true); - })->keys()->toArray(); + })->keys()->all(); } /** diff --git a/src/wayfinder/src/Route.php b/src/wayfinder/src/Route.php index b5cb0a2e6..d75d7cc57 100644 --- a/src/wayfinder/src/Route.php +++ b/src/wayfinder/src/Route.php @@ -340,7 +340,7 @@ private function rawUri(): string } $uri = str($uri) - ->replace($defaultParams->keys()->toArray(), $defaultParams->values()->toArray()) + ->replace($defaultParams->keys()->all(), $defaultParams->values()->all()) ->toString(); if ($uri !== '/') { From 16b971d50b89658f2bee28ba81b5dbbbe8e1de1e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:56:00 +0000 Subject: [PATCH 069/303] docs: publish static analysis guidance and remaining port work Add the canonical Static Analysis page with preferred patterns, actionable diagnostics, supported exceptions and application/Testbench inference boundaries. Link it from framework navigation, the package README and concise contributor guidance. Record the approved generic-proxy deprecation limitation and remaining port, macro-precedence, stale-ignore and cache verification work. Preserve the final contributor-guidance review requirement. Validation: php-cs-fixer, root/types/skeleton PHPStan, 782 analyzer tests and targeted runtime suites passed for the combined checkpoint. --- AGENTS.md | 15 +- .../2026-11-04-hypervel-phpstan-checklist.md | 36 +-- docs/plans/2026-11-04-hypervel-phpstan.md | 33 ++- src/docs/documentation.md | 1 + src/docs/static-analysis.md | 250 ++++++++++++++++++ src/phpstan/README.md | 7 +- 6 files changed, 303 insertions(+), 39 deletions(-) create mode 100644 src/docs/static-analysis.md diff --git a/AGENTS.md b/AGENTS.md index a0daf7413..f007259b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,8 @@ ParaTest defaults to the machine CPU count when no process count is specified. R To run a single test class: `./vendor/bin/phpunit --no-progress path/to/TestClass.php`. +The analyzer tests in `tests/PHPStan` run as a separate suite: use `composer test:phpstan`, or `./vendor/bin/phpunit -c phpunit.phpstan.xml.dist --no-progress path/to/TestClass.php` for a single class. + ## Change Workflow ### Classify the change @@ -117,7 +119,7 @@ Anything found follows When to Stop and Report — "the task didn't ask me to fi **Run verification commands sequentially** — Never overlap formatting, static analysis or tests. Wait for each command to finish before starting another. A tool’s built-in parallelism, such as ParaTest workers, is fine. -**During implementation**, run each new or changed test file immediately with `./vendor/bin/phpunit --no-progress path/to/TestClass.php`. +**During implementation**, run each new or changed test file immediately with `./vendor/bin/phpunit --no-progress path/to/TestClass.php` (add `-c phpunit.phpstan.xml.dist` for `tests/PHPStan`). Do not routinely run formatting or PHPStan during edits. Run targeted PHPStan earlier only when investigating a typing issue or when its feedback is needed to guide implementation. @@ -765,6 +767,8 @@ See the existing entries for database, Redis, Meilisearch, and Typesense as exam ## Static Analysis +Components is analyzed with its own `hypervel/phpstan` extension. [Static Analysis](src/docs/static-analysis.md#fixing-and-ignoring-diagnostics) is the source of truth for each diagnostic's intended fix and legitimate exceptions; the rules below add Components-specific requirements. + The `tests/` directory is excluded from phpstan. Do not run phpstan on tests. Run full PHPStan checks with `composer analyse`. During implementation, use targeted PHPStan only when investigating or validating a specific type issue. @@ -774,15 +778,16 @@ Run full PHPStan checks with `composer analyse`. During implementation, use targ **When fixing phpstan errors:** 1. **Investigate before coding.** For each error: read the code, check the Laravel equivalent's types (native and docblock), trace through callers and dependents. Report findings with the single, most correct fix. -2. **Don't make the code worse or more convoluted just to satisfy PHPStan.** Fix real issues in the code, but don't add awkward wrappers, fake branches, casts, or wider types just to silence PHPStan. A phpstan fix is a typing change: it must not change runtime behavior, add overhead, or introduce new edge cases. If you cannot satisfy PHPStan within those constraints, reach consensus on a compliant solution through a second-opinion loop with your peer agent. If the code is correct and PHPStan cannot understand it, follow the narrowing / suppression order below. -3. **Native types vs docblocks determine what's dead code.** If a native return type makes a guard unreachable, the guard is dead code — remove it. If only a docblock suggests always-true, the guard is legitimate runtime defense — leave it. +2. **Don't make the code worse or more convoluted just to satisfy PHPStan.** Fix real issues in the code, but don't add awkward wrappers, fake branches, casts, or wider types just to silence PHPStan. A type-only fix must not change runtime behavior, add overhead, or introduce new edge cases. A genuine runtime bug that PHPStan exposes is fixed through the normal bug-fix workflow. If you cannot satisfy PHPStan within those constraints, reach consensus on a compliant solution through a second-opinion loop with your peer agent. If the code is correct and PHPStan cannot understand it, follow the narrowing / suppression order below. +3. **Native types vs docblocks determine what's dead code.** If a native return type makes a guard unreachable, the guard is dead code — remove it. If only a docblock suggests always-true, the guard is legitimate runtime defense — leave it. A type inferred from one booted application or a shipped config default does not prove a branch is dead for every supported consumer; keep the branch and declare the contract it supports. 4. **Don't change contract/concrete boundaries to fix phpstan.** Swapping a contract for a concrete (or vice versa) to satisfy a type check diverges from Laravel's API. Only do this when Laravel's typing is genuinely incorrect. 5. **Methods can be added to contracts only if they represent behavior any conforming implementation must provide.** Implementation-specific methods, internal helpers, or driver-specific features don't belong on contracts — find another fix even if adding them would satisfy phpstan. 6. **Wrong docblock types should be fixed**, not suppressed. Check the actual runtime behavior (extension docs, reflection, tests) to determine the correct type. 7. **Type decisions must be evidence-based.** See Development Conventions — check Laravel/Hyperf signatures and docblocks, then trace real control flow. Don't guess. -8. **Narrowing / suppression order.** When the code is correct but PHPStan can't follow it, in order: (1) fix the type signature or docblock; (2) `@var` to narrow to the correct runtime type; (3) a line- or identifier-scoped `@phpstan-ignore` (e.g. magic `__call`/`__get` forwarding). Never use `assert()` to narrow types, and never add a neon-wide rule on your own (see #9). - When a container string key is also a PHP class name, keep the canonical service key and use `@var` for its actual runtime type; do not change service resolution solely for PHPStan. +8. **Narrowing / suppression order.** When the code is correct but PHPStan can't follow it, in order: (1) fix the type signature or docblock; (2) a truthful local `@var` for the correct runtime type, which may narrow `mixed` or widen an over-specific inferred type to the consumer contract the code supports; (3) an identifier-specific `@phpstan-ignore identifier (reason)` (e.g. magic `__call`/`__get` forwarding). Do not use `@phpstan-ignore-line`, `@phpstan-ignore-next-line` or an ignore without a reason. For a confirmed PHPStan regression, start the reason with `@TODO: Remove once PHPStan` and the fixed behavior; do not add a NEON ignore or `docs/todo.md` entry for it. Never use `assert()` to narrow types, and never add a neon-wide rule on your own (see #9). + Keep canonical service keys; do not change service resolution solely for PHPStan. The extension types container keys from the booted application, including keys that are also facade class names, so add `@var` only where it still states something analysis cannot know. 9. **Don't add patterns to `phpstan.neon.dist` on your own.** The neon file's global ignores cover fundamental framework patterns (Eloquent magic, generics, `new static`). Fix new phpstan errors at the source, not by masking them with new neon rules. Global suppression is a rare exception. First, reach consensus through a second-opinion loop with your peer agent that the error cannot be fixed at the source or narrowed locally, and that the pattern is frequent enough to justify global suppression. Then STOP, explain the reason and recommended suppression to the owner, and wait for approval before adding it. +10. **Environment reads outside config files.** Framework source reads the environment outside config files only for a deliberate infrastructure need, such as reads before configuration loads, process transport like `__HYPERVEL_CONTEXT`, or test infrastructure like worker tokens. Use `Env::get()` with an inline `@phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (reason)` stating that need. `Env::get()` is the convention for these intentional reads; switching from `env()` does not fix a read in the wrong place. Do not add directory, path or class-wide exemptions. ## Porting Packages diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index 87b1c743c..31e4cf241 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -11,13 +11,9 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - `extension.neon` services: restore upstream's `AbortIfFunctionTypeSpecifyingExtension` (abort/throw), `AppEnvironmentReturnTypeExtension`, `CheckDispatchArgumentTypesCompatibleWithClassConstructorRule` (bus/events `Dispatchable`) and `migrationsParser` entries with their classes; omit the container ArrayAccess entries - `extension.neon` and Components integration: set `reportIgnoresWithoutComments: true`; add `phpstan-deprecation-rules` includes to `phpstan.neon.dist`/`phpstan.types.neon.dist` - omit: `phpcs.xml.dist`, `phpstan.neon.dist`, `phpstan-baseline.neon`, `phpunit.xml.dist`, `phpunit-bootstrap.php`, `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, `RELEASE.md`, `UPGRADE.md`, `assets/` (repository tooling; Components owns its own) -- merge into `src/docs/phpstan.md`: `docs/custom-config-parameters.md`, `docs/custom-types.md`, `docs/errors-to-ignore.md`, `docs/features.md`, `docs/rules.md` +- merge into `src/docs/static-analysis.md`: `docs/custom-config-parameters.md`, `docs/custom-types.md`, `docs/errors-to-ignore.md`, `docs/features.md`, `docs/rules.md` - omit: `e2e/` Laravel project baselines (replaced by the plan's real-project checks) -## Existing Hypervel extensions - -- merge: `src/phpstan/src/ReturnTypes/Helpers/HelperFunctionReturnTypeExtension.php` (moved from Foundation) -> `ReturnTypes/Helpers/{ResponseExtension,ValidatorExtension}.php`, plus a Hypervel `ViewExtension.php` keeping its `view()` argument-count inference (upstream relies on the stub's false explicit-null return) - ## Runtime prerequisites - `src/bus/src/PendingChain.php`, `src/bus/src/Queueable.php`, `src/bus/src/ChainedBatch.php`, `src/support/src/Testing/Fakes/{QueueFake,PendingChainFake}.php` and their existing tests (chain/fake precedence) @@ -27,18 +23,19 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `ClosureTypes/RelationshipQueryCallbackExtension.php` - copy with the queue rules: `Concerns/InspectsQueuedJobs.php` - omit: `Internal/ComposerHelper.php` (`Support\Composer::vendorPath()` resolves the actual install) -- copy: `Methods/{BuilderHelper,FacadesMethodsExtension,HigherOrderCollectionProxyExtension,HigherOrderTapProxyExtension,Macro,MacroMethodsClassReflectionExtension,ManagersMethodsExtension,ModelFactoryMethodsClassReflectionExtension,StorageMethodsClassReflectionExtension}.php` +- copy: `Methods/{BuilderHelper,FacadesMethodsExtension,Macro,MacroMethodsClassReflectionExtension,ManagersMethodsExtension,ModelFactoryMethodsClassReflectionExtension,StorageMethodsClassReflectionExtension}.php` + - `Optional::__call()` runs a macro before the held object's method, so a macro sharing a held object's method name must take precedence over `OptionalExtension` (by extension order, or by `OptionalExtension` skipping macro names). Cover it in the Optional fixture. - merge into moved forwarding extensions: `Methods/{EloquentBuilderForwardsCallsExtension,ModelForwardsCallsExtension,RelationForwardsCallsExtension}.php` - omit: `Methods/ContractsMethodsExtension.php` (interfaces stay honest; README and `REMOVED:` recorded, so add its source comment to `ManagersMethodsExtension`'s class docblock, the sibling container-forwarding extension, when that ports) -- copy: `Properties/*` (17 files, including `Schema/MySqlDataTypeToPhpTypeConverter.php`) +- copy: `Properties/*` except the ported `HigherOrderCollectionProxyPropertyExtension` (16 files, including `Schema/MySqlDataTypeToPhpTypeConverter.php`) - copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection,ReflectionHelper,StaticMethodReflection}.php` - merge into moved scope reflections: `Reflection/ModelScopeMethodReflection.php` -- copy: `ReturnTypes/*` except the ported auth, config, console, container typing and translation files and the omitted `ContainerArrayAccessDynamicMethodReturnTypeExtension`/`ApplicationMakeDynamicReturnTypeExtension` (including `Helpers/`) -- copy: `Rules/*` except below and the ported auth request-scope, config, console, `NoEnvCallsOutsideOfConfigRule`, `NoMissingTranslationsRule` and `UnusedViewsRule` rules (including `Queue/` and `UselessConstructs/`) +- copy: `ReturnTypes/{AppFacadeEnvironmentReturnTypeExtension,EloquentBuilderExtension,FactoryDynamicMethodReturnTypeExtension,FormRequestSafeDynamicMethodReturnTypeExtension,ModelDynamicStaticMethodReturnTypeExtension,ModelFactoryDynamicStaticMethodReturnTypeExtension,ModelOnlyDynamicMethodReturnTypeExtension,ModelSerializationDynamicMethodReturnTypeExtension,RelationCollectionExtension,RequestFileExtension,RequestRouteExtension,StorageDynamicStaticMethodReturnTypeExtension,TestCaseExtension}.php` +- copy with the Eloquent collection port: `ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php`, moving `common/EloquentCollection.stub`'s `map()`/`mapWithKeys()` conditionals into the Eloquent `Collection` source +- copy: `Rules/*` except below and the ported `ArrKeyExistenceRule`, auth request-scope, config, console, `NoEnvCallsOutsideOfConfigRule`, `NoMissingTranslationsRule`, `NoUnnecessaryEnumerableToArrayCallsRule`, `UnusedViewsRule` and `UselessConstructs/` rules (including `Queue/`) - omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` - copy: `SQL/*` (7 files) -- copy: `Support/{ArrKeyLookup,CollectionHelper,HigherOrderCollectionProxyHelper}.php` -- copy: `Types/*` except the ported `ViewString*` files (13 files) +- copy: `Types/*` except the ported `ArrPullParameterOutTypeExtension` and `ViewString*` files (12 files) ## New Hypervel source @@ -49,14 +46,15 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## stubs -- copy: `common/*` except below, the ported view stubs and `Foundation/Console/Kernel.stub` (its `command()` closure type moved to source; the stub keeps only the empty declaration the `Artisan` mixin needs for stub validation) (75 files) -- merge into `common/Facades.stub` and `common/Helpers.stub` with their topics: the remaining facade and helper entries (only Artisan, View, Route and `view()` are ported; `trans()`/`__()` are omitted, as the stub explains, and `config()`/`env()` are omitted because their source declares the types) +- copy: `common/*` except below, the ported view stubs, `Foundation/Console/Kernel.stub` (its `command()` closure type moved to source; the stub keeps only the empty declaration the `Artisan` mixin needs for stub validation) and the stubs whose types are now in source: `Collection` (except below), `Conditionable`, `Enumerable`, `EnumeratesValues`, `HigherOrderProxies` and `Support/{Arr,Benchmark,Reflector,Str,Tappable}` (60 files) +- add to source with the bleeding-edge collection tests: `common/Collection.stub`'s value-before-callable order for the `$key`/`$callback` parameter of `contains()`, `containsStrict()`, `some()`, `every()`, `partition()` and `reject()` (with the callable first, bleeding-edge PHPStan rejects an argument instead of widening a usage-inferred collection) +- merge into `common/Facades.stub` and `common/Helpers.stub` with their topics: the remaining facade entries, and the `cookie()`, `logger()`, `logs()`, `redirect()`, `request()`, `session()` and `url()` helper entries, whose conditional returns the source already declares (only Artisan, View, Route and `view()` are ported; `optional()` is typed in source and `OptionalExtension`; the stub records the omitted helpers) - omit: `common/Config/Repository.stub` (only upstream's omitted `config()` helper stub references it) - add to source with the routing stubs: upstream `common/Route.stub`'s `methods()` and `middleware()` element types as `Route` PHPDoc in `src/routing`, with a `types/` fixture (the stub keeps only the empty declaration stub validation needs) - omit: `common/Redis/{PredisConnection,PredisFacade}.stub`, `common/Gate.stub` (Hypervel's `Gate::forUser()` and its contract already return `static`) - add to source: upstream `common/MailMessage.stub`'s property shapes (`$view`, `$from`, `$replyTo`, `$attachments`, `$rawAttachments`) as `MailMessage` PHPDoc in `src/notifications`, with a `types/` fixture - omit: `common/Contracts/Translation.stub` (only upstream's omitted `trans()` helper stub references it) -- merge into `common/`: `12.20.0/Enumerable.stub`, `12.41.0/Http/PendingRequest.stub`, `13.3.0/{EloquentBuilder,Model}.stub`, `13.35.0/{BelongsToMany,HasOneOrMany}.stub`, `13.37.0/HasOneOrManyThrough.stub` +- merge into `common/`: `12.41.0/Http/PendingRequest.stub`, `13.3.0/{EloquentBuilder,Model}.stub`, `13.35.0/{BelongsToMany,HasOneOrMany}.stub`, `13.37.0/HasOneOrManyThrough.stub` ## tests @@ -64,10 +62,14 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: the remaining `application/` fixture app (app, config, factories, migrations, schema dump) - merge into `Integration/IntegrationTest.php`: the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties; copy `Integration/BleedingEdgeIntegrationTest.php` - copy: `Reflection/MacroMethodsClassReflectionExtensionTest.php` -- copy: `Rules/*Test.php` except below and the ported auth, config, console, environment, view and translation rule tests, the remaining `Rules/data/*`, `Rules/Queue/data/*`, `Rules/UselessConstructs/data/*` +- copy: `Rules/*Test.php` except below and the ported `ArrKeyExistence`, auth, config, console, environment, enumerable `toArray()`, view, translation and useless-construct rule tests, the remaining `Rules/data/*`, `Rules/Queue/data/*` - omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` -- copy: `Type/*Test.php` except below, the ported config tests, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php`, with the remaining `Type/data/*` +- copy: `Type/*Test.php` except below, the ported collection and config tests, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php`, with the remaining `Type/data/*` +- copy next: `Type/BleedingEdgeTypeTest.php` with `bleeding-edge/{collection-keys,collection-usage-inference}.php`; `bleeding-edge/closure-usage-inference.php` (relation callbacks) and `bleeding-edge/eloquent-collection-map.php` join it with the Eloquent ports +- merge into `Type/CollectionDynamicReturnTypeExtensionsTest.php` with the Eloquent collection port, switching it to `config-with-migrations.neon` (they need model properties and the custom collections): `collection-generic-static-methods{,-l12}.php`, `eloquent-collection-map.php` +- merge into `Type/CollectionDynamicReturnTypeExtensionTest.php` with the model fixtures: `collection-filter.php`, `collection-reject.php`, `collection-where.php` (they need `accounts()->active()->get()` to return `AccountCollection`) +- omit: `Type/data/collection-generic-static-methods-l11.php` (Laravel 11's Eloquent `partition()` returned `static`; Hypervel follows the Laravel 12 base collection) - merge into `Type/MethodsClassReflectionExtensionTest.php` with the macro port: `macros.php` and `macros-php-81.php` (unconditional; Hypervel requires PHP 8.4) -- merge: the remaining `Type/GeneralTypeTest.php` data entries, and its `data/config-with-migrations.neon` configuration once migrations are ported +- merge: the remaining `Type/GeneralTypeTest.php` data entries, and its `data/config-with-migrations.neon` configuration once migrations are ported; `higher-order-collection-proxy-methods.php` needs model properties and Hypervel's proxy results (`avg` is `float|int|null`, `max`/`min` include `null`) - merge: `Type/{RedisDriverNullTypeTest,RedisDriverPhpRedisTypeTest}.php` and their data into one phpredis test; omit `Type/RedisDriverPredisTypeTest.php` with `redis-driver-predis.*` - copy: `Unit/*Test.php` except `Support/{BootstrapErrorHandlerTest,RecursiveDirectoryIteratorTest}.php` (11 classes, including `SQL/` and `Support/`), `Unit/data/*` diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 48adc52d9..aa5ef465f 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -16,8 +16,10 @@ Remaining work: - Also completed: the authentication flow (guard and user return types, the request-scope rules, the fixture app's auth models and config) with `AuthGuardTypeTest` for configuration-dependent guard typing. - Also completed: console command analysis (argument/option return types, the undefined-input rule, the fixture commands and the `Artisan` facade stub). - Also completed: configuration inference and diagnostics, environment-reader policy and source types, consumer config contracts, and the Foundation/Testbench nested-config loader fix, with their regression coverage. Shared result-cache tracking remains below. -- Next, before further porting: claude-stan and codex-stan each independently re-audit this plan, all checkpointed code and the remaining work for ported behavior that is too loose, incomplete or ineffective: checks with easy bypasses, changes that only satisfy a check, suppressions broader than the intended exception, best-effort inference boundaries presented as guarantees, and choices justified by unverified upstream or codebase precedent. Also review AGENTS.md's static-analysis guidance and propose a canonical package documentation section on intended coding patterns, rules, proper fixes and intentional exceptions, linked from AGENTS.md (instruction-file edits need owner approval). Then hold a consensus round and bring the owner a concise bullet proposal to approve before any audit-driven change or further porting. -- After the audit and owner direction, port the checklist's remaining `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. +- Also completed: the independent audit. The owner approved its changes, which this plan now specifies: config-file `@return` types regardless of `treatPhpDocTypesAsCertain`, actionable config diagnostics, the canonical Static Analysis page (see Documentation), the targeted AGENTS.md edits (see Completion criteria) and the warm-run cache reuse check. +- The approved audit source, documentation and AGENTS.md changes are applied; the warm-run reuse check lands with the shared cache work. +- Also completed: the helpers and collections flow (higher-order proxies, `Arr` types and `ArrKeyExistenceRule`, collection narrowing, helper extensions, the enumerable `toArray()` and useless-construct rules, collection stub types moved to source) except its bleeding-edge part. +- Next: the bleeding-edge collection tests with the value-before-callable parameter order (see the checklist), together with the Configuration contract's stale-ignore audit and removal of the consumer `reportUnmatchedIgnoredErrors: false` overrides. Then port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -26,7 +28,7 @@ Remaining work: - Boot the actual application; use Hypervel Testbench for packages. Retain runtime discovery of container bindings, macros, facade roots, authentication configuration and console definitions. Do not build a second static service-container simulator or require an alternative no-boot mode. - Retain the upstream `extension.neon`, parameter schemas, PHPStan DI attributes and applicable rule switches. Select stricter Hypervel defaults. Keep settings for retained features, including paths and scanning/cache options; omit settings only when their feature is omitted. Do not implement anti-override mechanisms, non-ignorable policy errors, multiple strictness profiles, or a custom configuration language. - Register `extension.neon` in `extra.phpstan.includes`. Document explicit inclusion and optional discovery through `phpstan/extension-installer`; installing the analyzer alone does not install or authorize that Composer plugin. -- Include maintained deprecation reporting through a required `phpstan/phpstan-deprecation-rules` dependency. Use normal `@deprecated Use X instead.` metadata for future Hypervel deprecations; do not build duplicate deprecation rules. Reporting covers known deprecated APIs from dependencies and PHP as well as Hypervel, subject to upstream metadata and deprecated-scope behavior. +- Include maintained deprecation reporting through a required `phpstan/phpstan-deprecation-rules` dependency. Use normal `@deprecated Use X instead.` metadata for future Hypervel deprecations; do not build duplicate deprecation rules. Reporting covers known deprecated APIs from dependencies and PHP as well as Hypervel, subject to upstream metadata and deprecated-scope behavior. PHPStan's restricted-usage rules look members up on the plain class, so deprecated members reached through generic proxies (higher-order collection, `when()`, `Optional`, tap) are not reported; proxy reflections still carry the target's deprecation metadata, the docs state the limit, and no duplicate rule or dependency patch is added. - The public configuration catalog below is the source of truth for defaults. Preserve PHPStan's own level, paths, parallelism, baseline and suppression settings. PHPStan already defaults unmatched-ignore reporting to true; remove the three consumer false overrides after auditing their ignores rather than redundantly setting that default in the extension. Stronger defaults do not change the meaning of retained options. - Package-owned diagnostics use stable `hypervel.*` identifiers, state the concrete problem and give the replacement. Rename Larastan-owned identifiers (`larastan.*` and `rules.modelAppends`) only; preserve PHPStan-owned identifiers emitted by reused offset/argument checks, such as `offsetAccess.notFound`. Keep ordinary suppressions for legitimate exceptions and analyzer limitations. New rule switches follow Larastan's boolean parameter/conditional-registration convention; do not add detailed tuning without a real requirement. - Use PHPStan rules, type/reflection extensions, restricted-usage extensions and collectors for their intended jobs. Collectors can supply cross-file diagnostic facts after analysis; they cannot feed same-run type inference. Share an existing helper where needed, not a parallel inference engine. @@ -148,7 +150,7 @@ PHPStan documentation: [extension testing](https://phpstan.org/developing-extens ## Package and repository wiring -1. Use `src/permission/{composer.json,README.md,LICENSE.md}` as the third-party-port skeleton, preserving Larastan's MIT attribution. Set `type: phpstan-extension`, namespace `Hypervel\PHPStan\`, PHP `^8.4`, normal Hypervel support links and branch alias. The README uses `Documentation: https://hypervel.org/docs/phpstan` for the page authored in `src/docs/phpstan.md`, states deliberate public differences, and ends with `Ported from: https://github.com/larastan/larastan`. +1. Use `src/permission/{composer.json,README.md,LICENSE.md}` as the third-party-port skeleton, preserving Larastan's MIT attribution. Set `type: phpstan-extension`, namespace `Hypervel\PHPStan\`, PHP `^8.4`, normal Hypervel support links and branch alias. The README uses `Documentation: https://hypervel.org/docs/static-analysis` for the page authored in `src/docs/static-analysis.md`, states deliberate public differences, and ends with `Ported from: https://github.com/larastan/larastan`. 2. Preserve the upstream package-root layout: `bootstrap.php`, `extension.neon`, `src/`, and necessary `stubs/`. Use existing `Methods`, `Properties`, `Reflection`, `ReturnTypes`, `Rules`, `Support`, `Types`, `Collectors`, `SQL` and `Concerns` directories. Put new model rules at `Rules/` root beside upstream's model rules; use `Rules/Coroutine` and `Rules/Validation` for those genuinely new domains. Do not introduce separate engines for Hypervel additions. 3. Declare direct production dependencies of the analyzer in its own manifest, even though consumers install the analyzer with `--dev`. Require `phpstan/phpstan-deprecation-rules`: included deprecation reporting is part of the supported installation contract, not an unrelated suggested tool. Check the latest compatible stable release on Packagist before adding it; the verified 2.0.5 release requires PHPStan `^2.1.39`, compatible with the planned analyzer range. Translate required Illuminate components to their actual Hypervel owners. Do not depend on `hypervel/components`, the entire framework metapackage, Head, Scout, private packages or every optional integration. Suggest `hypervel/testbench` for package analysis. Guard optional integrations using existing reflection/stub-extension mechanisms; preserve an absent-component installation test. 4. Add root autoloading and `replace` metadata for `hypervel/phpstan`. The root replaces the split package, so it must declare the analyzer's external dependencies itself; add `phpstan/phpstan-deprecation-rules` to root `require-dev` with `composer require --dev` at the selected compatible constraint. Use Composer commands for other root dependency changes; subpackage manifests without lockfiles may be edited directly. Do not add a runtime service provider just to register PHPStan services. Register them in PHPStan's container. @@ -196,8 +198,8 @@ Remove each old `src/{database,foundation,head,scout}/extension.neon`, old `extr - Boot once per PHPStan process, not per file or model. Parallel analysis boots independently in worker processes. Do not start HTTP/queue workers, run migrations, or probe backend services merely for analysis. Provider code can itself perform I/O; do not hide real boot failures or claim boot is free of side effects. - Retain `BootstrapErrorHandler`'s clear CLI failure and nonzero exit. Adapt optional renderer integrations to supported Hypervel packages; do not add Whoops solely to preserve an unsupported Laravel integration. Do not continue with an empty container after a failed boot. Every failure gets one neutral title and tip list with the real exception and trace (upstream's `/vendor/` substring classification misreports custom vendor directories and path repositories), and exception text is escaped as literal console text. `bootstrap.php` also fails through the handler when there is no `bootstrap/app.php` and Testbench is absent (install message), or when `bootstrap/app.php` returns a non-Application; the consumer-installation fixture covers the missing-Testbench case. - Document the actual CLI boot requirements: Swoole (not OpenSwoole), `swoole.use_shortname=Off`, and Swoole built with OpenSSL and native cURL support. `Application::checkEnvironment()` already throws specific corrective messages. Preserve those messages through `BootstrapErrorHandler` and test that they remain visible; do not duplicate the checks or add exception-message matching to manufacture another tip. -- Preserve runtime macro/reflection discovery, facade resolution, class-string/string-key container returns, configured authentication user types and console argument/option definitions. Keep interfaces honest: an arbitrary interface variable does not acquire implementation-only methods merely because the container currently binds one implementation. -- **Container typing.** Boot registers facade aliases eagerly (`RegisterFacades`, unchanged), and PHP class names are case-insensitive, so keys such as `'config'` and `'request'` become class-strings that the declared `class-string` returns would type as facades. `AppMakeHelper` types constant keys from the booted container for `make`/`makeWith`/`makeTransient`/`get`/`resolve`/`factory` on the container contract (which also covers the Application contract, so upstream's `ApplicationMakeDynamicReturnTypeExtension` is omitted), the same `App` facade methods except `resolve`, and `app()`/`resolve()`. `factory()` wraps the result as `Closure(): T`. A non-constant or null abstract returns null, so the declared return applies (generic class-strings keep `TClass`; `app()`/`app(null)` stay `Application`). When resolution fails or returns a non-object, a key keeps its class type only when it names that class exactly (ignoring a leading `\`); otherwise, including unknown keys, unknown class literals, scalar bindings and alias keys, the result is `mixed`. An unpacked first argument is `mixed`: PHPStan reorders named arguments for extensions but does not expand unpacked ones. The source fixes this exposed keep public APIs except one recorded Laravel difference: `CacheTokenRepository` accepts the cache contract that `CacheManager::store()` returns (auth README). +- Preserve runtime macro/reflection discovery, facade resolution, class-string/string-key container returns, configured authentication user types and console argument/option definitions. Keep interfaces honest: an arbitrary interface variable does not acquire implementation-only methods merely because the container currently binds one implementation. For manager forwarding, prefer a truthful source `@mixin` where a manager forwards a shared driver API (as `MaintenanceModeManager`'s drivers implement `MaintenanceMode`); decide upstream's booted-default-driver fallback in that slice after checking supported custom managers. +- **Container typing.** Boot registers facade aliases eagerly (`RegisterFacades`, unchanged), and PHP class names are case-insensitive, so keys such as `'config'` and `'request'` become class-strings that the declared `class-string` returns would type as facades. `AppMakeHelper` types constant keys from the booted container for `make`/`makeWith`/`makeTransient`/`get`/`resolve`/`factory` on the container contract (which also covers the Application contract, so upstream's `ApplicationMakeDynamicReturnTypeExtension` is omitted), the same `App` facade methods except `resolve`, and `app()`/`resolve()`. `factory()` wraps the result as `Closure(): T`. A non-constant or null abstract returns null, so the declared return applies (generic class-strings keep `TClass`; `app()`/`app(null)` stay `Application`). When resolution fails or returns a non-object, a key keeps its class type only when it names that class exactly (ignoring a leading `\`); otherwise, including unknown keys, unknown class literals, scalar bindings and alias keys, the result is `mixed`. An unpacked first argument is `mixed`: PHPStan reorders named arguments for extensions but does not expand unpacked ones. Explicit resolution is typed from the booted binding, so a driver the analysed environment selects (the default queue or database connection, for example) decides the type; the docs state this boundary. Do not replace it with declared or alias-ranked types, which would lose an application's own bound implementations. The source fixes this exposed keep public APIs except one recorded Laravel difference: `CacheTokenRepository` accepts the cache contract that `CacheManager::store()` returns (auth README). - Before changing AOP behavior, add a focused app/package subprocess fixture with an aspect, a class-map replacement, a provider registering a macro, and parallel PHPStan workers. Check original source diagnostics, inferred signatures and registration behavior. `GenerateProxies` seals registries and exposes `proxySources()`; `ProxyManager` reuses fingerprints and writes atomically. Parallel writes alone do not justify bypassing it. Preserve ordinary boot unless the fixture proves a conflict. Resolve a proven source-lookup conflict using the existing original-source mapping and PHPStan's supported facilities; no blanket disabling of aspects, custom AST rewriting, or speculative alternative class loader. If a supported API cannot preserve both semantics, bring the concrete failure and simplest alternatives to the owner before a capability change. ### Package-mode boot and Composer installs @@ -218,7 +220,7 @@ Port every applicable upstream inference feature, not just the rules in the next | Area | Required adaptation and coverage | |---|---| -| Models | Native declarations and explicit PHPDoc take precedence appropriately; infer schema properties, relations, accessors, casts, separate read/write types, nullability, custom collections, factories and serialization shapes via `ModelSerializationDynamicMethodReturnTypeExtension`. Honor Hypervel attributes, visibility/appends and inheritance. Unknown schema knowledge must not erase declared properties or known relationships. | +| Models | Native declarations and explicit PHPDoc take precedence appropriately; infer schema properties, relations, accessors, casts, separate read/write types, nullability, custom collections, factories and serialization shapes via `ModelSerializationDynamicMethodReturnTypeExtension`. Honor Hypervel attributes, visibility/appends and inheritance; Hypervel instantiates `CollectedBy`, so test a named `collectionClass:` argument and a parent's attribute. Unknown schema knowledge must not erase declared properties or known relationships. | | Builder/relation forwarding | Preserve model generics, custom builders, relation-specific fluent returns, attributed scopes, SoftDeletes/Scout and macro callbacks. Merge existing Hypervel capabilities with applicable upstream cases. | | Helpers and collections | Preserve conditional argument behavior, `Arr` shapes, `filter()` narrowing, higher-order proxy callbacks and generic collections. Compare inherited annotations before adding a stub. | | Authentication | One shared guard-type helper serves `Auth::guard()`, `Factory::guard()` and `auth($guard)`, following `AuthManager::resolve()`: a booted custom creator's declared return (`ClosureTypeFactory`, never invoked), else the booted manager's `create{Driver}Driver()` declaration, else the declared return. `viaRequest()`'s creator declares `RequestGuard`, so request guards are typed; an untyped creator keeps the declared return. Never claim `StatefulGuard` for an unmodelled guard: only `SessionGuard` is stateful, and token/request/Sanctum/JWT/Passport guards are not. An omitted, null or empty name is the union of every configured guard's type, because `shouldUse()` changes the default per coroutine; so is an unknown or unpacked name, because resolving an unconfigured name throws. A known set of string or enum names gives the union of those guards. `auth()` and `auth(null)` type as the booted factory concrete; unlike `guard(null)`, a maybe-null or unpacked `auth()` argument can return the factory, so it keeps the declared conditional. Guard names come only from `guard()` on the factory or facade, the global `auth()` (resolved function identity) and `Request::user()`; enum cases map like `enum_value()` where the signature accepts enums. User models are provider-based inference: an Eloquent provider gives its model for any guard driver; a database provider gives `GenericUser` only for the stock session/token drivers (Sanctum accepts session-guard users when its provider has no `getModel()`). A guard without a modelled provider, or one whose provider driver has a custom creator, keeps the call's declaration. Preserve narrower declarations; manager and facade `user()` calls are typed only when they forward the `Guard` contract's method. Omit `AuthsMethodsExtension`: `AuthManager`'s mixins already forward guard methods, and contracts do not gain implementation methods. | @@ -249,7 +251,7 @@ $enabled = config('feature.enabled'); Upstream types only the application's files, claims an unresolved key holds its default (`config('logging.channels.sentry', [])` types as `array{}` while Sentry sets it), seals arrays that other sources extend, and reports every unknown `collection()` key. Replace that with one PHPStan type per config file key, composed from the sources Hypervel loads: -- **Sources, highest priority first.** Application files: `configDirectories` when set (replacing only this discovery), else the booted `config_path()` overlaid by the Testbench workbench config directory when its config discovery is on (a workbench file replaces the same key). Then framework defaults from `LoadConfiguration::frameworkConfigPath()` when `shouldMergeFrameworkConfiguration()` is true. Then definite package merges. Keep upstream's per-file return-scope evaluation (imports, preceding statements, casts, `@return` docblocks) and its nested-file handling (a key with config files below it stays unknown). Unlike upstream, which reads the final composition, a config file's own `config()` reads see the configuration as it stands while that file loads: an application file sees the framework entries without an application file of their own and the application files before it in natural key order, with no merges or writes. A file's own key is unset until it returns. Reads inside a function or closure body keep the declared type, since the body may run during loading or long after. Framework and package files' reads keep the declared type: framework files load into an empty repository and providers merge package files in an order the analysis does not follow. Package code is analysed against the booted application's config, including its own defaults. Code that accepts wider consumer config declares that contract at its consuming boundary with a local PHPDoc, as Telescope's `registerWatchers()` does for keyed and listed watchers; code that validates every entry declares the getter's own type. A typed getter reached only after a null check of a key whose default is null takes an inline `hypervel.configType` ignore with its reason, since `ConfigTypeRule` reads the key, not the local value. Upstream's config-file `@return` docblock is not the consumer contract: it must describe the whole file, nothing checks it against the values, and it is ignored when `treatPhpDocTypesAsCertain` is off, as in Components. Never infer a source as unknown from which paths a run analyses. +- **Sources, highest priority first.** Application files: `configDirectories` when set (replacing only this discovery), else the booted `config_path()` overlaid by the Testbench workbench config directory when its config discovery is on (a workbench file replaces the same key). Then framework defaults from `LoadConfiguration::frameworkConfigPath()` when `shouldMergeFrameworkConfiguration()` is true. Then definite package merges. Keep upstream's per-file return-scope evaluation (imports, preceding statements, casts, `@return` docblocks) and its nested-file handling (a key with config files below it stays unknown). Unlike upstream, which reads the final composition, a config file's own `config()` reads see the configuration as it stands while that file loads: an application file sees the framework entries without an application file of their own and the application files before it in natural key order, with no merges or writes. A file's own key is unset until it returns. Reads inside a function or closure body keep the declared type, since the body may run during loading or long after. Framework and package files' reads keep the declared type: framework files load into an empty repository and providers merge package files in an order the analysis does not follow. Package code is analysed against the booted application's config, including its own defaults. Code that accepts wider consumer config declares that contract at its consuming boundary with a local PHPDoc, as Telescope's `registerWatchers()` does for keyed and listed watchers; code that validates every entry declares the getter's own type. A typed getter reached only after a null check takes an inline `hypervel.configType` ignore with its reason only for an established inference limitation, such as a package's broader consumer contract against its null shipped default (`ConfigTypeRule` reads the key, not the local value); a genuinely incompatible value is fixed. Upstream's config-file `@return` docblock is not the consumer contract: it must describe the whole file and nothing checks it against the values. It applies regardless of `treatPhpDocTypesAsCertain`, unlike upstream: that setting only relaxes always-true checks, and PHPStan still infers from PHPDoc when it is off, as Components and its consumers configure it. Never infer a source as unknown from which paths a run analyses. - Loader fix: both `LoadConfiguration::loadConfigurationFiles()` copies (Foundation, Testbench) re-set every residual base entry after the app files load, discarding nested files below a framework-only name (`config/services/stripe.php` without `services.php`). Skip a residual entry that is base-only and identical to its initial value; added or changed residual values still apply. This preserves `loadConfigurationFile()`'s returned residual-value contract, not an override's direct repository write to a base-only key, which the old loop reset. Test the nested case and one override adding and changing residual values, in both copies. Then remove `ConfigParser::composeConfig()`'s special case that models the overwrite. - **Merging.** Follow the runtime: `array_merge` at the top level and at each mergeable option (string keys: the higher source wins; integer keys: appended and renumbered), and `replaceConfigRecursivelyFrom` replacing by key at every depth. A higher array with unknown keys (an unsealed `@return` shape) may replace any lower entry it does not list, so in both merges those entries become unknown. Read mergeable options once per instance and key from the real `mergeableOptions()` of a `LoadConfiguration` and of the booted provider; do not copy their tables. - **Provider merges.** Find `mergeConfigFrom()`/`replaceConfigRecursivelyFrom()` calls from the cached AST of loaded providers' class hierarchies and traits, excluding `ServiceProvider`. PHPStan scopes do not fold `__DIR__` (`usePathConstantsAsConstantString` is off, and must stay a consumer setting), so resolve arguments with php-parser's `ConstExprEvaluator`: the source file's `__DIR__`/`__FILE__`, global `dirname()` with constant arguments, class constants, and the current string value of a `self::`/`static::` property that is provider boot metadata (Sentry's documented boot-only `$abstract`). This is metadata argument resolution, not expression execution or runtime value typing. A merge is definite when its arguments resolve and it lies on the straight-line path from the effective (most-derived) `register()`: top-level `parent::register()` and `$this->helper()` statements followed under an active recursion guard (a helper called twice runs twice), with no earlier `return` outside nested closures. An earlier `throw` cannot have run in a booted application. Boot-time merges, merges of overlapping keys by different providers and every other resolved call are candidates; resolving arguments does not prove execution. Enumerate the registered providers once (`getProviders(ServiceProvider::class)`). Creating a callable runs nothing: `$this->helper(...)` is not followed, and a callable created from a merge method is an unresolvable merge. A resolved file is a package config file even when its key is not. @@ -282,6 +284,7 @@ Track connection/table identity where it changes the result, schema changes acro - Boot-derived config mutation targets (the recorded `ConfigMutationTracker` keys) depend on external conditions, such as Testbench rewriting `database.default` only when the SQLite database file is missing. A stale result can keep a wrong narrow type or an unnecessarily broad one, and provider hashes and environment identity do not notice the file appearing or disappearing. Include that condition (file presence) in the shared tracking, and cover a warm-cache presence transition in both directions against a cold run. No new tracking in the config parser, no boot during cache restoration and no database content hashing. - PHPStan restores its result cache in the main process before application bootstrap. A `ResultCacheMetaExtension` cannot assume a booted container. Use it only for global inputs available before boot, such as selected configuration/environment identity; use existing PHPStan facilities for per-file facts. Do not serialize the live container or eagerly boot it during cache restoration. - Test a changed boot-derived return type with unchanged analyzed source, on a warm cache. A real provider/macro fixture must change a formerly valid call into an error without manually clearing the cache, and recover when the provider is corrected. Include a `mixin()` class referenced only by provider registration. Verify the same result with a cold run. +- Verify unchanged warm-run reuse for an application and a Testbench-backed package: a second normal run with no changes restores the result cache and reanalyses nothing, even though Testbench creates a new runtime directory. Identical warm and cold diagnostics alone cannot show reuse. Use PHPStan's ordinary result-cache output; no timing threshold, benchmark, `--debug` or routine cache clearing. - Preserve the migration cache's locking, second read under lock and atomic replacement. Port upstream `MigrationCacheTest` and dependency tests, then add the changed-fingerprint regression: `getMTime()` has whole-second resolution, so ordinary edit-and-analyze loops can reuse a stale schema after two edits in one second. Keep mtime fixed explicitly in this regression instead of relying on test timing or sleeps. Use existing content fingerprints or a once-per-input `xxh128` digest and add a short WHY comment at the change so an upstream sync preserves it. Preserve upstream's timestamp-change invalidation alongside content changes so existing applicable tests remain meaningful. No new concurrency suite is needed for unchanged locking. Keep one parsed-schema cache, bounded in-process reflection caches and normal PHPStan temp storage; no extra persistent metadata service. - Keep runtime source changes free of analysis-only request overhead. Parse lazily, reuse reflection/ASTs, and do not resolve every container service or model for every node. Use ordinary bounded correctness runs during development; get fresh owner confirmation that the machine is idle before any benchmark. Compare cold/warm analysis and representative runtime paths only when needed to assess a concrete cost. @@ -293,11 +296,11 @@ Copy retained rule files first, then adapt them. Retain their relevant toggles a |---|---| | `ArrKeyExistenceRule` | Diagnose definitely absent `Arr::get`/`pull` keys; respect optional keys, defaults, dynamic shapes and actual argument binding. | | `CheckDispatchArgumentTypesCompatibleWithClassConstructorRule` | Preserve job/event constructor checking, conditional dispatch, named arguments, unpacking and callable dispatch variants actually supported by Hypervel. | -| `ConfigCollectionRule` | Port against the adapted config parser for every receiver (Config facade, repository contract, `config()`); report only known values that are definitely not arrays, never unknown keys. A new `ConfigTypeRule` (`hypervel.configType`, under `checkConfigTypes`) reports typed getters whose known value can never match; both share lookups through `ConfigHelper`. Do not forbid legitimate nullable/union `get()` calls. | +| `ConfigCollectionRule` | Port against the adapted config parser for every receiver (Config facade, repository contract, `config()`); report only known values that are definitely not arrays, never unknown keys. A new `ConfigTypeRule` (`hypervel.configType`, under `checkConfigTypes`) reports typed getters whose known value can never match; both share lookups through `ConfigHelper`. Both messages name the getter that throws and say to correct the config value or use the getter matching its type; the docs reserve `get()` for intentionally nullable, union or mixed values. Do not forbid legitimate nullable/union `get()` calls. | | `ConsoleCommand/UndefinedArgumentOrOptionRule` | Use booted command definitions, including signatures and inherited/configured definitions. | | `ModelAppendsRule` | Support `#[Appends]` and currently supported property declarations. A database-backed attribute can be appended with a getter/class cast. Distinguish readable accessors from setter-only `Attribute` definitions; preserve custom serialization. | | `ModelRelationDefaultsRule` | Validate `$with`/`$withCount` against real relations, nested paths, aliases and inherited definitions. | -| `NoEnvCallsOutsideOfConfigRule` | Retain the policy, closing upstream's bypass: match every normal reader, not only `env()`. These are the global `env`, `env_array` and `env_or_fail` functions, `app_id()` (a config-default helper that reads `APP_ID`/`APP_NAME`, so a runtime call gets a fallback id once configuration is cached), and the static `Env::get`, `getArray` and `getOrFail` methods, by resolved identity (case-insensitive names; `is(Env::class)` covers aliases and subclasses). One identifier; the message names the call. Raw `Env::getRepository()`, `getenv()` and superglobal access are deliberate low-level access and out of scope, so do not claim complete environment-access detection. Permit the application and framework config directories, `LoadConfiguration::frameworkConfigStubPath()` (the `config:publish` templates), plus package config files: merge paths (even when the key or execution is uncertain) and PHP sources `ServiceProvider::pathsToPublish()` maps into the config directory, including directory mappings. A merge path does not permit its whole directory. Do not claim outside-config calls return null: cached configuration only stops `.env` loading. Upstream's bare `str_starts_with()` lets `/app/config` permit `/app/config-sibling/x.php`; match the normalized directory itself or a path beneath it, and cover the sibling-prefix case in the existing test. Fixtures: one case per entry point, an alias, a subclass, the stub directory and the existing namespaced-function negatives. Components keep the rule on with no directory or path-scoped ignores: such ignores would hide a future service setting read from the environment instead of config. Framework `src/` outside config uses `Env::get()` (the owner's convention); each verified, deliberate infrastructure read, including the `env*()` wrappers' delegation, gets an inline `@phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (reason)`. In `tests/` (not analysed), plain reads use `Env::get()` and tests of the helpers keep calling them. | +| `NoEnvCallsOutsideOfConfigRule` | Retain the policy, closing upstream's bypass: match every normal reader, not only `env()`. These are the global `env`, `env_array` and `env_or_fail` functions, `app_id()` (a config-default helper that reads `APP_ID`/`APP_NAME`, so a runtime call gets a fallback id once configuration is cached), and the static `Env::get`, `getArray` and `getOrFail` methods, by resolved identity (case-insensitive names; `is(Env::class)` covers aliases and subclasses). One identifier; the message names the call. Only named calls are matched: creating a first-class callable is not a read, and string callables or dynamic receivers are not traced. Raw `Env::getRepository()`, `getenv()` and superglobal access are deliberate low-level access the rule does not match. The docs state these limits; do not claim complete environment-access detection. Permit the application and framework config directories, `LoadConfiguration::frameworkConfigStubPath()` (the `config:publish` templates), plus package config files: merge paths (even when the key or execution is uncertain) and PHP sources `ServiceProvider::pathsToPublish()` maps into the config directory, including directory mappings. A merge path does not permit its whole directory. Do not claim outside-config calls return null: cached configuration only stops `.env` loading. Upstream's bare `str_starts_with()` lets `/app/config` permit `/app/config-sibling/x.php`; match the normalized directory itself or a path beneath it, and cover the sibling-prefix case in the existing test. Fixtures: one case per entry point, an alias, a subclass, the stub directory and the existing namespaced-function negatives. Components keep the rule on with no directory or path-scoped ignores: such ignores would hide a future service setting read from the environment instead of config. Framework `src/` outside config uses `Env::get()` (the owner's spelling for intentional reads; it does not correct a placement violation); each verified, deliberate infrastructure read gets an inline `@phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (reason)` stating its semantic justification: reads before configuration loads, process transport such as `__HYPERVEL_CONTEXT`, test infrastructure such as worker tokens, and the `env*()` wrappers' delegation. In `tests/` (not analysed), plain reads use `Env::get()` and tests of the helpers keep calling them. | | `NoMissingTranslationsRule` | Reuse the booted translator, existing usage collectors and resource dependencies; do not rebuild lookup in a file scanner. Restrict default diagnostics to literal structured keys with a known group source and known standard file-backed resolution. See the translation predicate below. | | `NoModelMakeRule` | Use the conservative declaration/runtime-metadata exclusions below, without interpreting arbitrary model boot code. Unknown or customized construction stays silent. | | `NoPublicModelScopeAndAccessorRule` | Apply to the real Hypervel attributed-scope/accessor surface. Coordinate with the legacy-scope rule to avoid duplicate errors. Preserve inherited visibility and valid native methods. | @@ -453,7 +456,7 @@ Do not add repository-housekeeping rules for test method title docblocks or miss | Type engine | `assertType` fixtures for model properties/accessors/relations, scopes/custom builders, factories/collections, auth, app/container keys, helpers, config, console, macros, views and public custom PHPDoc types; native contracts remain valid. | | Current inference gaps | Baseline fixtures for `auth()->user()` (currently a false Factory `method.notFound`), `$model->relation`/`$model->column` (`property.notFound`), `app('cache')`, `$request->user()`, `Arr::get()` and `->map->getKey()` (currently mixed in the investigated examples), `collect([1, null])->filter()` (retains null), and a known model's `toArray()` (currently `array`). Assert the expected precise type for a fully specified fixture while preserving uncertainty for dynamic/custom cases. | | Schema/config | Real supported database dump/migration fixtures, custom-driver uncertainty, declared-property precedence, static env casts, nullable values, package/application merge ordering and typed getters. | -| Cache | Keep upstream dependency/cache tests. Add one warm-versus-cold regression per new or changed invalidation mechanism: file content, directory membership, provider input behind a runtime fact, macro/mixin source, a pre-boot global input, and same-second migration edits. Reuse existing coverage and combine fixtures where they exercise the same mechanism; do not multiply each input by every possible edit operation or add tests for unchanged locking. | +| Cache | Keep upstream dependency/cache tests. Check unchanged warm-run reuse for an app and a package. Add one warm-versus-cold regression per new or changed invalidation mechanism: file content, directory membership, provider input behind a runtime fact, macro/mixin source, a pre-boot global input, and same-second migration edits. Reuse existing coverage and combine fixtures where they exercise the same mechanism; do not multiply each input by every possible edit operation or add tests for unchanged locking. | | Boot | Skeleton app, Components through Testbench, standalone package with `testbench.yaml`, custom vendor directory, runtime-requirement/failure diagnostics, AOP/class replacements and optional Head/Scout absent/present. Verify each process's Testbench copy is removed on normal exit and copies left by terminated workers are purged on the next run under the existing ownership rules. No accidental server start or committed-skeleton writes. | | Consumer installation | A clean standalone Composer install of the split package with own dependencies; central and deprecation extensions each load exactly once with manual includes and with installer discovery. Reuse this fixture to assert a known deprecated call produces one upstream-identifier diagnostic. Required classes/stubs are present without Components root autoloading. Do not duplicate the dependency's own rule test suite. | | Runtime behavior | Focused existing framework tests for every source typing/correctness change. No weakened contracts or runtime casts/branches solely for PHPStan. | @@ -471,8 +474,10 @@ For e2e baselines, preserve known intentional analyzer limitations with reasons - Private Composer repositories currently point at the main Components `src/*`. Validate against this worktree through a task-local Composer setup with its own installed dependencies, without permanently committing a worktree-specific path or disrupting another workload's vendor directory. Final private manifests reference the intended durable package source/version. - Audit every suppression affected by improved inference or the new ignore policy in Components, both private packages, skeletons and affected apps. Convert every reason-less identifier ignore and every blanket line/next-line ignore in their analyzed paths to a source fix or an accurate identifier-specific ignore with a parenthesized reason. Do not limit this to newly ported files or errors that became unmatched. Remove obsolete inline ignores/global patterns and correct stale explanations such as claiming a boot-registered macro is unknowable. Retain narrowly justified limitations; new broad ignores require the normal owner approval. - Audit deprecated-API usage in those same consumers, including calls to PHP, Symfony, Carbon and other vendor APIs, not just Hypervel declarations. Use the supported replacement when it preserves the contract. Keep a reasoned identifier-specific ignore when deprecated usage remains necessary, including compatibility with a still-supported public API. Do not remove supported APIs, strip truthful deprecation tags, mark unrelated code deprecated to silence errors, or add runtime workarounds merely to satisfy analysis. Dependency defects follow the normal owner decision workflow. -- Add `src/docs/phpstan.md` and navigation links, following existing Laravel-style documentation. Explain installation, app/Testbench boot (package analysis types auth users from the configured Testbench app, whose users model `AUTH_MODEL` or the workbench `User` selects), supported versions, configuration/defaults, optional integrations, custom PHPDoc types, diagnostic examples, suppressions, safe fixes, cache behavior and the distinction between authoring recommendations and runtime API removals. State in the page and release notes that adopting the documented setup diagnoses existing reason-less/blanket ignores and uses the included deprecation extension for known deprecated APIs across dependencies, including PHP, Symfony and Carbon. Explain its standard loading and opt-out mechanisms and upstream deprecated-scope behavior; do not promise detection of every runtime deprecation regardless of available metadata. Keep the package README minimal. -- Add a concise `src/docs/porting-from-laravel.md` entry: replace `larastan/larastan` with `hypervel/phpstan`, update the extension include, migrate Larastan-owned ignore identifiers (`larastan.*`, `rules.modelAppends`) to the documented `hypervel.*` mapping, and retain PHPStan core identifiers. Explain that retained parameter names remain compatible while defaults and omitted Octane/deferred-provider settings differ; link to the canonical PHPStan page. +- Add `src/docs/static-analysis.md`, titled Static Analysis, as the canonical page, with navigation links, following existing Laravel-style documentation. Cover installation, app/Testbench boot (package analysis types auth users from the configured Testbench app, whose users model `AUTH_MODEL` or the workbench `User` selects), supported versions, configuration/defaults, optional integrations, custom PHPDoc types, diagnostics, suppressions, safe fixes, cache behavior and the distinction between authoring recommendations and runtime API removals. + - Authoring guidance: a small overview table links to short topic sections; each topic gives the intended pattern with a compact example and its legitimate exception. Keep detailed examples out of wide tables and do not copy the configuration catalog. Fix first; ignore only a legitimate exception or analyzer limitation with `@phpstan-ignore identifier (reason)`, never a baseline for a real defect. A fact inferred from one boot or a shipped default does not prove a branch dead in every consumer, so a truthful consumer-contract PHPDoc may widen an over-specific inferred type. Use typed getters for single-type config values and `get()` only for intentionally nullable, union or mixed ones. Each new diagnostic's guidance lands with its implementation slice. + - Boundaries: config types come from static expressions, never `.env` values; container resolution, auth guards and user models, console commands, views, translations and the default locale come from the booted application, whose environment can select drivers and models. Package mode sees the Testbench application's registrations, not every consuming application. State the inference limits: unexecuted config writes through unrecognized flows, and the env rule's named-call matching. State in the page and release notes that adopting the documented setup diagnoses existing reason-less/blanket ignores and uses the included deprecation extension for known deprecated APIs across dependencies, including PHP, Symfony and Carbon. Explain its standard loading and opt-out mechanisms and upstream deprecated-scope behavior; do not promise detection of every runtime deprecation regardless of available metadata. Keep the package README minimal. +- Add a concise `src/docs/porting-from-laravel.md` entry: replace `larastan/larastan` with `hypervel/phpstan`, update the extension include, migrate Larastan-owned ignore identifiers (`larastan.*`, `rules.modelAppends`) to the documented `hypervel.*` mapping, and retain PHPStan core identifiers. Explain that retained parameter names remain compatible while defaults and omitted Octane/deferred-provider settings differ; link to the Static Analysis page. - Update affected feature docs, skeleton setup, testing/type-fixture instructions, component manifests and package READMEs for centralized extension ownership. Amend `docs/todo.md` by removing completed items and the superseded instruction to keep extensions distributed. Preserve still-unimplemented runtime-removal and unrelated todos; update their references where this package now supplies checks. Never rewrite historical plans. - Record intentionally omitted upstream features in the package README, a concise source comment at the natural registration/insertion point and `REMOVED:` comments at matching test locations, as required by the porting policy. Do not create empty placeholder classes for omitted features or annotate routine namespace changes and bug fixes as public API differences. - Search the affected source, tests, types, active docs, manifests and scripts for obsolete namespaces, old extension paths, duplicated services, unused helper classes/settings, inaccurate compatibility claims and stale comments. Remove them together with their consumers. Keep upstream references/license attribution and historical plans intact. @@ -488,4 +493,4 @@ Add a named **Legacy validation rule contracts** entry under `docs/todo.md` Lega ## Completion criteria -The central split package works in an application and a standalone Testbench-backed package, owns every reusable existing extension, retains the applicable Larastan engine/configuration/tests, provides the agreed Hypervel checks, and passes the verification matrix. Source contracts and supported APIs remain correct; all real findings introduced or exposed by the integration are fixed or taken through the explicit owner decision process. Old extension files, registrations, dead code and stale active documentation are gone. The plan's implementer obtains peer code-review signoff, summarizes the result and remaining owner-authorized external steps, and sends the user notification only after the terminal summary. +The central split package works in an application and a standalone Testbench-backed package, owns every reusable existing extension, retains the applicable Larastan engine/configuration/tests, provides the agreed Hypervel checks, and passes the verification matrix. Source contracts and supported APIs remain correct; all real findings introduced or exposed by the integration are fixed or taken through the explicit owner decision process. Old extension files, registrations, dead code and stale active documentation are gone. The plan's implementer obtains peer code-review signoff, summarizes the result and remaining owner-authorized external steps, and sends the user notification only after the terminal summary. After the entire plan is implemented, show the owner the AGENTS.md changes in chat as BEFORE and AFTER, compared through Git against the pre-audit commit `113c4c4dc` (no duplicate instruction files), so the owner can request further changes. diff --git a/src/docs/documentation.md b/src/docs/documentation.md index 212d87e83..4ca0d5355 100644 --- a/src/docs/documentation.md +++ b/src/docs/documentation.md @@ -99,6 +99,7 @@ - [Console Tests](/docs/{{version}}/console-tests) - [Database](/docs/{{version}}/database-testing) - [Mocking](/docs/{{version}}/mocking) + - [Static Analysis](/docs/{{version}}/static-analysis) - ## Packages - [API Client](/docs/{{version}}/api-client) - [Doctor](/docs/{{version}}/doctor) diff --git a/src/docs/static-analysis.md b/src/docs/static-analysis.md new file mode 100644 index 000000000..5225288f5 --- /dev/null +++ b/src/docs/static-analysis.md @@ -0,0 +1,250 @@ +# Static Analysis + +- [Introduction](#introduction) +- [Installation](#installation) + - [Analyzing Packages](#analyzing-packages) + - [Runtime Requirements](#runtime-requirements) +- [What the Analysis Knows](#what-the-analysis-knows) +- [Fixing and Ignoring Diagnostics](#fixing-and-ignoring-diagnostics) + - [Environment Variables](#environment-variables) + - [Configuration Values](#configuration-values) + - [Array Keys](#array-keys) + - [Value and With Helpers](#value-and-with-helpers) + - [Converting Collections to Arrays](#converting-collections-to-arrays) + - [Translations](#translations) + - [Views](#views) + - [Console Input](#console-input) + - [Authentication in Requests](#authentication-in-requests) + - [Deprecations](#deprecations) + + +## Introduction + +Hypervel's `hypervel/phpstan` package is a [PHPStan](https://phpstan.org) extension that understands your Hypervel application. It is a port of [Larastan](https://github.com/larastan/larastan): it boots your application so PHPStan knows its container services, configuration, authentication guards, console commands, views and translations, and it adds rules that catch common mistakes in Hypervel code. + + +## Installation + +To get started, install the package as a development dependency: + +```shell +composer require hypervel/phpstan --dev +``` + +Next, include the extension and the bundled deprecation rules in your `phpstan.neon` file: + +```neon +includes: + - vendor/hypervel/phpstan/extension.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + +parameters: + level: 5 + paths: + - app +``` + +If your project uses the [PHPStan extension installer](https://github.com/phpstan/extension-installer), it loads both extensions automatically, so you should omit the `includes` entries. Once configured, you may analyze your application: + +```shell +./vendor/bin/phpstan analyse +``` + + +### Analyzing Packages + +When you analyze a package instead of an application, PHPStan boots the package's Testbench application, so the package should require `hypervel/testbench` as a development dependency. The analysis uses the providers, configuration and package discovery settings from your `testbench.yaml` file, just like your package's tests. Authentication types come from the Testbench application's configuration, where the `AUTH_MODEL` environment variable or the workbench `User` model selects the user model. + + +### Runtime Requirements + +Since PHPStan boots your application, the PHP CLI that runs it needs the same Swoole setup as your application: Swoole (not OpenSwoole) built with OpenSSL and native cURL support, with `swoole.use_shortname` set to `Off`. If the application cannot boot, PHPStan stops and displays the exception that prevented it. + + +## What the Analysis Knows + +Configuration types come from your configuration files themselves, never from the values on your machine. For example, the following `enabled` option is a `bool` no matter what your `.env` file contains: + +```php +// config/feature.php +return ['enabled' => (bool) env('FEATURE_ENABLED', false)]; + +// Inferred as bool... +$enabled = config('feature.enabled'); +``` + +The facts your application registers come from the booted application: container bindings, authentication guards and user models, console commands, views and translations, including the default locale. Your environment can influence these facts. For instance, `app('db.connection')` is typed as the connection class your `DB_CONNECTION` variable selects, and the authentication user model may come from `AUTH_MODEL`. So, you should run the analysis with the same environment locally and in CI. When you analyze a package, the analysis sees the Testbench application's registrations, not those of every application that may install the package. + +A value typed as an interface only has the interface's methods, even when the container binds a class with more of them. When your code needs a specific implementation, type it as that implementation. Resolving a service from the container, such as `app(Repository::class)`, is typed as the class the booted application binds. + +Dates are different: `now()`, `today()` and the `Date` facade return `CarbonInterface`, as they are declared, even when your application configures a specific date class. When your code needs a specific class, convert the value, such as with `toImmutable()`. + +Some facts cannot be known statically. When the analysis cannot tell what a configuration key holds, such as a value that provider code writes in a way the analysis does not recognize and that did not run while your application booted, it uses the declared return type instead of guessing. + +Calling `str()` without arguments returns an object that passes method calls to `Str`. The analysis does not check those calls. + + +## Fixing and Ignoring Diagnostics + +Whenever possible, fix the code a diagnostic points to. Ignore a diagnostic only for a legitimate exception or a limit of the analysis, and give the ignore the diagnostic's identifier and a reason: + +```php +// @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the test runner sets this token for each worker) +$token = Env::get('TEST_TOKEN'); +``` + +Avoid `@phpstan-ignore-line` and `@phpstan-ignore-next-line`, which hide every error on the line, and always give a reason, so the next reader knows why the diagnostic does not apply. Never add a real defect to a baseline. + +The following table summarizes the preferred pattern for each of Hypervel's diagnostics: + +| Topic | Preferred Pattern | Identifiers | +|---|---|---| +| [Environment Variables](#environment-variables) | Read the environment in configuration files and use `config()` elsewhere. | `hypervel.noEnvCallsOutsideOfConfig` | +| [Configuration Values](#configuration-values) | Use the typed getter that matches the configuration value. | `hypervel.configType`, `hypervel.configCollection` | +| [Array Keys](#array-keys) | Read keys the array has, or pass a default for optional keys. | `offsetAccess.notFound` | +| [Value and With Helpers](#value-and-with-helpers) | Use the value directly when no closure or callback runs. | `hypervel.uselessConstructs.value`, `hypervel.uselessConstructs.with` | +| [Converting Collections to Arrays](#converting-collections-to-arrays) | Use `all()` when no items need converting. | `hypervel.unnecessaryEnumerableToArrayCall` | +| [Translations](#translations) | Use translation keys that exist. | `hypervel.missingTranslations` | +| [Views](#views) | Use view names the application can find. | `hypervel.unusedViews`, `argument.type` for view names | +| [Console Input](#console-input) | Read the arguments and options the command defines. | `hypervel.console.undefinedArgument`, `hypervel.console.undefinedOption` | +| [Authentication in Requests](#authentication-in-requests) | Use `$request->user()` where a request is available. | `hypervel.noAuthFacadeInRequestScope`, `hypervel.noAuthHelperInRequestScope` | +| [Deprecations](#deprecations) | Use the replacement the deprecation names. | `method.deprecated` and the other deprecation identifiers | + + +### Environment Variables + +You should only read environment variables in your configuration files. Once your configuration is cached, the `.env` file is no longer loaded, so a read anywhere else only sees the process environment. Instead, read the variable in a configuration file and use `config()` elsewhere: + +```php +// config/services.php +'billing' => [ + 'key' => env('BILLING_KEY'), +], + +// Elsewhere in your application... +$key = config('services.billing.key'); +``` + +The rule checks calls to the `env`, `env_array`, `env_or_fail` and `app_id` helpers and to the `Env::get`, `Env::getArray` and `Env::getOrFail` methods. Calls within your configuration directories, the framework's configuration files and the configuration files that service providers merge or publish are allowed. Only direct calls are checked: callables, dynamic class names, `getenv()` and `$_ENV` are not. + +Some infrastructure code genuinely needs the process environment, such as code that runs before configuration loads, values passed to child processes, or settings provided by a test runner. Ignore those calls individually, with a reason. You may disable the rule using the `noEnvCallsOutsideOfConfig` parameter. + + +### Configuration Values + +The typed configuration getters, such as `Config::string()` and `config()->integer()`, throw an exception when the value has a different type. The analysis reports a typed getter, or a `collection()` call, whose key can never hold the required type: + +```php +// config/queue.php +'retries' => '3', + +// Config key 'queue.retries' is not an integer, so integer() throws... +$retries = Config::integer('queue.retries'); +``` + +Correct the configuration value, casting environment variables within the configuration file, or use the getter that matches the value's type. Reserve `get()` for values that are intentionally nullable, a union of types, or unrestricted: + +```php +'retries' => (int) env('QUEUE_RETRIES', 3), +``` + +Only values the analysis knows are checked. A key the analysis cannot follow keeps the getter's declared type. + +Configuration types include the defaults of any package whose configuration your application has not published. If a package supports configuration that is wider than its shipped default, its code should declare the contract it accepts where it reads the value, rather than relying on the type of the default: + +```php +/** @var list $allowedOrigins */ +$allowedOrigins = config()->array('mcp.allowed_origins'); +``` + +Similarly, when a package ships a `null` default that applications may replace, a typed getter reached only after a `null` check can still be reported, since the analysis reads the configuration key rather than your local value. Ignore that call with a reason. These checks may be disabled using the `checkConfigTypes` parameter. + + +### Array Keys + +Calls to `Arr::get()` and `Arr::pull()` are checked against the keys the array is known to have, following keys in "dot" notation through nested arrays. A key the array never has is reported, since the call always returns `null`: + +```php +/** + * @param array{name: string, email: string} $user + */ +function notify(array $user): void +{ + $email = Arr::get($user, 'mail'); // Offset 'mail' does not exist... +} +``` + +Correct the key. When a key is optional by design, pass the default you expect, since calls with a default are not reported: + +```php +$timeout = Arr::get($options, 'timeout', 30); +``` + +Arrays whose keys are not known, such as `array`, are not checked. + + +### Value and With Helpers + +The `value` helper only calls a closure, and the `with` helper only calls its callback, so a call that passes neither returns the value unchanged and is reported: + +```php +$name = value($user->name); // Reported... +$name = with($user->name); // Reported... + +$name = $user->name; +``` + +Use the value directly. When the value may be a closure, such as a default that callers may compute lazily, keep the `value` call; the analysis does not report it. + + +### Converting Collections to Arrays + +A collection's `toArray()` method converts each `Arrayable` item to an array as well, while `all()` returns the items as they are. When no item is `Arrayable`, the two return the same array, so the analysis recommends `all()`: + +```php +// Called [toArray()] on an Enumerable which does not contain any Arrayables... +$ids = collect([1, 2, 3])->toArray(); + +$ids = collect([1, 2, 3])->all(); +``` + +Keep `toArray()` when the items are, or may be, `Arrayable`, such as models. Collections that override `toArray()`, `all()` or `map()` are not checked. You may disable the rule using the `noUnnecessaryEnumerableToArrayCalls` parameter. + + +### Translations + +The `__`, `trans` and `trans_choice` helpers, the `Lang` facade and the translator are checked for keys that are missing from a translation file the application can load. Only literal `group.item` and `namespace::group.item` keys are checked, and only when the group's file exists for the requested locale or its fallback. When a lookup does not specify a locale, the application's default locale is used. JSON translations, dynamic keys and custom translators or loaders are not checked. + +To fix the diagnostic, add the missing line or correct the key. You may disable the rule using the `checkMissingTranslations` parameter. + + +### Views + +Methods that accept a view name, such as `view()` and `View::make()`, require a view the booted application can find, so a misspelled view name is reported as an `argument.type` error. If a package only registers a view namespace when a command runs or a feature is enabled, the analysis cannot see those views; ignore those calls with a reason. + +You may also enable the `checkUnusedViews` parameter to report views that no code refers to by a literal name. Views rendered by computed names or used by other packages may still be in use, so treat these diagnostics as a list to review rather than proof that a view can be deleted. + + +### Console Input + +Calls to a command's `argument()` and `option()` methods are checked against the command's definition. Define the argument or option in the command's signature, or check `hasArgument()` or `hasOption()` before reading an input the command may not define: + +```php +if ($this->hasOption('force') && $this->option('force')) { + // ... +} +``` + + +### Authentication in Requests + +When the `checkAuthCallsWhenInRequestScope` parameter is enabled, the analysis recommends `$request->user()` over `Auth::user()` and `auth()->user()` where a request is available. The facade and helper remain fully supported. Since a request's user resolver may return a different user than the `Auth` facade, review each change instead of replacing calls mechanically. + + +### Deprecations + +The bundled deprecation rules report uses of deprecated APIs from any of your dependencies, including PHP, Symfony and Carbon as well as Hypervel. Use the replacement the deprecation names. When you still need the deprecated API, such as to keep a public API compatible, ignore it with the reported identifier and a reason. + +Deprecated methods and properties reached through a proxy, such as `$users->each->notify()`, `optional($user)->notify()` or `$user->when($active)->notify()`, are not reported, since PHPStan's deprecation checks only look at the proxy class itself. + +To disable deprecation reporting, remove the `rules.neon` include. If you use the extension installer, add `phpstan/phpstan-deprecation-rules` to the `extra.phpstan/extension-installer.ignore` list in your `composer.json` file instead. diff --git a/src/phpstan/README.md b/src/phpstan/README.md index 491302051..092c4ae88 100644 --- a/src/phpstan/README.md +++ b/src/phpstan/README.md @@ -1,17 +1,18 @@ PHPStan for Hypervel === -Documentation: https://hypervel.org/docs/phpstan +Documentation: https://hypervel.org/docs/static-analysis ## Differences From Larastan - Package-owned diagnostic identifiers use the `hypervel.*` prefix instead of `larastan.*` (and `rules.modelAppends`). PHPStan's own identifiers are unchanged. -- Several checks are enabled by default, and Hypervel adds coroutine, worker-lifetime and authoring-policy checks. See the [configuration defaults](https://hypervel.org/docs/phpstan#configuration). +- Several checks are enabled by default, and Hypervel adds coroutine, worker-lifetime and authoring-policy checks. See the [configuration defaults](https://hypervel.org/docs/static-analysis#configuration). - There is no Octane compatibility rule or `checkOctaneCompatibility` setting; Hypervel's worker-lifetime checks replace it. There is no deferred service provider rule, because Hypervel has no deferred providers. - Container ArrayAccess return types are not provided, because the Hypervel container does not support ArrayAccess. -- Interfaces do not gain methods from their implementations. Upstream resolves an undefined method on a contract against its container binding, and on the auth user contracts against the configured auth models. +- A value typed as an interface does not gain methods from its implementations. Upstream resolves an undefined method on a contract against its container binding, and on the auth user contracts against the configured auth models. Explicit container resolution, such as `app(Repository::class)`, is still typed from the booted application's binding. - Without a guard name, `Auth::guard()` and `auth()->guard()` may return any configured guard, because `shouldUse()` changes the default guard per request. A guard whose type cannot be read from its driver keeps the declared `Guard` type rather than `StatefulGuard`. - The request-scope auth rules offer no automatic fix, because the request's user resolver can return a different user than the `Auth` facade or `auth()` helper. +- `now()`, `today()` and the `Date` facade return `CarbonInterface`, as declared, rather than the date class configured when analysis runs. Code that needs a specific class should convert the value, for example with `toImmutable()`. - The `generalizeEnvReturnType` and `redisDriver` settings are omitted. An `env()` default does not decide the type of the value it reads, and Redis types always describe Hypervel's phpredis connection. Ported from: https://github.com/larastan/larastan From b9e94317f0a88a08e37a37661ad307341a0a8f10 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:47:50 +0000 Subject: [PATCH 070/303] fix: preserve numeric string lengths and offset types in split Pass the integer returned by FILTER_VALIDATE_INT to mb_str_split so numeric string lengths retain Laravel's behavior under strict types. Describe offset-capture tuples without changing the runtime API or ordinary string results. Extend the existing chunk regression and add focused Stringable type fixtures. Validation: formatter, root/targeted PHPStan, types and skeleton analysis, 814 analyzer tests and the targeted Stringable runtime suite passed for the combined checkpoint. --- src/support/src/Stringable.php | 8 +++++--- tests/Support/SupportStringableTest.php | 3 +++ types/Support/Stringable.php | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 types/Support/Stringable.php diff --git a/src/support/src/Stringable.php b/src/support/src/Stringable.php index abbb8876b..988943421 100644 --- a/src/support/src/Stringable.php +++ b/src/support/src/Stringable.php @@ -279,12 +279,14 @@ public function explode(string $delimiter, int $limit = PHP_INT_MAX): Collection /** * Split a string using a regular expression or by length. * - * @return Collection + * PREG_SPLIT_OFFSET_CAPTURE makes a pattern split return [segment, offset] pairs; a length split ignores flags. + * + * @return ($flags is int-mask ? Collection : Collection}|string>) */ public function split(string|int $pattern, int $limit = -1, int $flags = 0): Collection { - if (filter_var($pattern, FILTER_VALIDATE_INT) !== false) { - return new Collection(mb_str_split($this->value, $pattern)); + if (($length = filter_var($pattern, FILTER_VALIDATE_INT)) !== false) { + return new Collection(mb_str_split($this->value, $length)); } $segments = preg_split($pattern, $this->value, $limit, $flags); diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php index f506e8680..4e65d90fe 100644 --- a/tests/Support/SupportStringableTest.php +++ b/tests/Support/SupportStringableTest.php @@ -1425,6 +1425,9 @@ public function testChunk(): void $this->assertInstanceOf(Collection::class, $chunks); $this->assertSame(['foo', 'bar', 'baz'], $chunks->all()); + + // Hypervel: strict types would otherwise reject a numeric string length that Laravel accepts. + $this->assertSame(['foo', 'bar', 'baz'], $this->stringable('foobarbaz')->split('3')->all()); } public function testJsonSerialize(): void diff --git a/types/Support/Stringable.php b/types/Support/Stringable.php new file mode 100644 index 000000000..7a2d32ad8 --- /dev/null +++ b/types/Support/Stringable.php @@ -0,0 +1,16 @@ +', $string->split('/ /')); +assertType('Hypervel\Support\Collection', $string->split('/( )/', -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE)); +assertType('Hypervel\Support\Collection', $string->split(2)); +assertType('Hypervel\Support\Collection}|string>', $string->split('/ /', -1, PREG_SPLIT_OFFSET_CAPTURE)); +assertType('Hypervel\Support\Collection}|string>', $string->split('/ /', -1, $flags)); From 75e80d7335fb31d94dc98698f7a66a97981f903c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:47:50 +0000 Subject: [PATCH 071/303] test: cover supported bleeding-edge collection inference Port the upstream bleeding-edge type and integration harnesses using the existing PHPStan test infrastructure. Cover key renumbering, empty-collection inference and incompatible mutations. Retain Laravel's existing covariant value contract, record incompatible value-widening expectations, and verify key widening independently. Correct Eloquent flatMap's expected base-collection result. Validation: formatter, root/targeted PHPStan, types and skeleton analysis, 814 analyzer tests and the targeted Stringable runtime suite passed for the combined checkpoint. --- .../BleedingEdgeIntegrationTest.php | 34 ++++++++ .../data/bleeding-edge/collections.php | 29 +++++++ tests/PHPStan/Type/BleedingEdgeTypeTest.php | 41 ++++++++++ .../data/bleeding-edge/collection-keys.php | 78 +++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 tests/PHPStan/Integration/BleedingEdgeIntegrationTest.php create mode 100644 tests/PHPStan/Integration/data/bleeding-edge/collections.php create mode 100644 tests/PHPStan/Type/BleedingEdgeTypeTest.php create mode 100644 tests/PHPStan/Type/data/bleeding-edge/collection-keys.php diff --git a/tests/PHPStan/Integration/BleedingEdgeIntegrationTest.php b/tests/PHPStan/Integration/BleedingEdgeIntegrationTest.php new file mode 100644 index 000000000..875bc0ac4 --- /dev/null +++ b/tests/PHPStan/Integration/BleedingEdgeIntegrationTest.php @@ -0,0 +1,34 @@ + */ + public static function dataIntegrationTests(): iterable + { + yield [ + __DIR__ . '/data/bleeding-edge/collections.php', + [ + 27 => ['Parameter #1 ...$values of method Hypervel\Support\Collection::push() expects int, string given.'], + ], + ]; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + // PHPStan's own bleedingEdge.neon includes the config through a phar alias that only exists when the phar is executed. + $pharRoot = dirname((string) (new ReflectionClass(PHPStanTestCase::class))->getFileName(), 3); + + return array_merge(parent::getAdditionalConfigFiles(), [$pharRoot . '/conf/bleedingEdge.neon']); + } +} diff --git a/tests/PHPStan/Integration/data/bleeding-edge/collections.php b/tests/PHPStan/Integration/data/bleeding-edge/collections.php new file mode 100644 index 000000000..033197e41 --- /dev/null +++ b/tests/PHPStan/Integration/data/bleeding-edge/collections.php @@ -0,0 +1,29 @@ + $ints */ +function takeInts(Collection $ints): void +{ +} + +/** @return Collection */ +function names(): Collection +{ + $names = collect(); + $names->push('Taylor'); + + return $names; +} + +// REMOVED: queries() searching collect([1, 2, 3]) for an int. Hypervel keeps Laravel's covariant TValue, +// and PHPStan 2.3.5 does not widen an already inferred covariant TValue to the argument. + +function wrongValue(): void +{ + $ints = collect(); + $ints->push('one'); + takeInts($ints); +} diff --git a/tests/PHPStan/Type/BleedingEdgeTypeTest.php b/tests/PHPStan/Type/BleedingEdgeTypeTest.php new file mode 100644 index 000000000..64f89c716 --- /dev/null +++ b/tests/PHPStan/Type/BleedingEdgeTypeTest.php @@ -0,0 +1,41 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/bleeding-edge/collection-keys.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + // PHPStan's own bleedingEdge.neon includes the config through a phar alias that only exists when the phar is executed. + $pharRoot = dirname((string) (new ReflectionClass(TypeInferenceTestCase::class))->getFileName(), 3); + + return [ + __DIR__ . '/../phpstan-tests.neon', + $pharRoot . '/conf/bleedingEdge.neon', + ]; + } +} diff --git a/tests/PHPStan/Type/data/bleeding-edge/collection-keys.php b/tests/PHPStan/Type/data/bleeding-edge/collection-keys.php new file mode 100644 index 000000000..e679f2828 --- /dev/null +++ b/tests/PHPStan/Type/data/bleeding-edge/collection-keys.php @@ -0,0 +1,78 @@ + $list + * @param array $map + * @param array $mixed + */ +function renumberedKeys(array $list, array $map, array $mixed, User $user): void +{ + assertType('Hypervel\Support\Collection<0|1|2, 1|2|3>', collect([1, 2, 3])); + + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->merge([4, 5])); + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->merge($list)); + assertType("Hypervel\Support\Collection<'a'|'b'|'c', 1|2|3>", collect(['a' => 1, 'b' => 2])->merge(['c' => 3])); + assertType('Hypervel\Support\Collection', collect(['a' => 1, 'b' => 2])->merge($map)); + assertType("Hypervel\Support\Collection<'a'|'b'|int, 1|2|4>", collect(['a' => 1, 'b' => 2])->merge([4])); + assertType("Hypervel\Support\Collection", collect([5 => 'x', 9 => 'y'])->merge([1 => 'z'])); + assertType('Hypervel\Support\Collection<(int|string), mixed>', collect($mixed)->merge($mixed)); + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->mergeRecursive([4])); + + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->concat([4, 5])); + assertType("Hypervel\Support\Collection<'a'|'b'|int, 1|2|3>", collect(['a' => 1, 'b' => 2])->concat(['c' => 3])); + + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->flatMap(fn (int $value) => [$value, $value])); + assertType("Hypervel\Support\Collection<'k1'|'k2'|'k3', 1|2|3>", collect([1, 2, 3])->flatMap(fn (int $value) => ['k' . $value => $value])); + + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->multiply(2)); + assertType("Hypervel\Support\Collection", collect([5 => 'x', 9 => 'y'])->shuffle()); + assertType("Hypervel\Support\Collection>", collect([5 => 'x', 9 => 'y'])->split(2)); + assertType("Hypervel\Support\Collection>", collect(['a' => 1, 'b' => 2])->split(2)); + + assertType('Hypervel\Support\LazyCollection', LazyCollection::make([1, 2, 3])->merge([4, 5])); + assertType('Hypervel\Support\LazyCollection', LazyCollection::make([1, 2, 3])->concat([4, 5])); + assertType('Hypervel\Support\LazyCollection', LazyCollection::make([1, 2, 3])->flatMap(fn (int $value) => [$value, $value])); + assertType('Hypervel\Support\LazyCollection', LazyCollection::make([1, 2, 3])->multiply(2)); + + assertType('Hypervel\Database\Eloquent\Collection<0, App\User>', new EloquentCollection([$user])); + assertType('Hypervel\Database\Eloquent\Collection', (new EloquentCollection([$user]))->merge([$user])); + assertType('Hypervel\Database\Eloquent\Collection', (new EloquentCollection([$user]))->concat([$user])); + // Hypervel: the callback returns arrays, so Eloquent's map() and collapse() return a base collection. + assertType('Hypervel\Support\Collection', (new EloquentCollection([$user]))->flatMap(fn (User $user) => [$user, $user])); +} + +function mutations(): void +{ + // REMOVED: push(), add(), prepend() and unshift() value widening. Hypervel keeps Laravel's covariant + // TValue, and PHPStan 2.3.5 does not widen an already inferred covariant TValue to a new value. + + // Hypervel: prepends a value TValue already admits, so only the key widens. + $prependedWithKey = collect(['a' => 1]); + $prependedWithKey->prepend(1, 'b'); + assertType("Hypervel\Support\Collection<'a'|'b', 1>", $prependedWithKey); + + $shifted = collect([5 => 'x', 9 => 'y']); + $shifted->shift(); + assertType("Hypervel\Support\Collection", $shifted); + + $shiftedStringKeys = collect(['a' => 1, 'b' => 2]); + $shiftedStringKeys->shift(); + assertType("Hypervel\Support\Collection<'a'|'b', 1|2>", $shiftedStringKeys); + + $spliced = collect([5 => 'x', 9 => 'y']); + assertType("Hypervel\Support\Collection", $spliced->splice(1)); + assertType("Hypervel\Support\Collection", $spliced); + + $put = collect(['a' => 1, 'b' => 2]); + $put->getOrPut('c', 3); + assertType("Hypervel\Support\Collection<'a'|'b'|'c', 1|2|3>", $put); +} From c1f45ba088e2efd22d4fb110485c7653f0017796 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:47:50 +0000 Subject: [PATCH 072/303] chore: remove stale Eloquent and nested-set analysis suppressions Remove obsolete fluent-builder, relation-return and nested-set method ignores exposed by the current analyzer. Collapse the former inline-comment-split condition without changing its expression. Keep runtime behavior and generic declarations intact; future unmatched suppressions will now be reported by the root configuration. Validation: formatter, root/targeted PHPStan, types and skeleton analysis, 814 analyzer tests and the targeted Stringable runtime suite passed for the combined checkpoint. --- src/database/src/Eloquent/Collection.php | 5 ++--- src/database/src/Eloquent/Model.php | 2 +- .../src/Eloquent/Relations/BelongsTo.php | 2 -- .../src/Eloquent/Relations/BelongsToMany.php | 3 +-- .../src/Eloquent/Relations/HasOneOrMany.php | 1 - .../Eloquent/Relations/HasOneOrManyThrough.php | 4 ---- src/database/src/Eloquent/Relations/Relation.php | 2 -- .../src/Eloquent/AncestorsRelation.php | 6 +++--- .../src/Eloquent/DescendantsRelation.php | 16 +++++++--------- src/nested-set/src/Eloquent/QueryBuilder.php | 4 ++-- src/nested-set/src/Eloquent/SiblingsRelation.php | 2 +- 11 files changed, 17 insertions(+), 30 deletions(-) diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index a329979b0..d6da643cd 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -156,7 +156,7 @@ public function loadAggregate(array|string $relations, Expression|string $column $models = $model->newModelQuery() ->whereKey(array_values($modelKeys)) ->select($keyName) - ->withAggregate($relations, $column, $function) // @phpstan-ignore method.notFound (withAggregate is on Eloquent\Builder; PHPStan loses type through chain) + ->withAggregate($relations, $column, $function) ->get(); if ($models->isEmpty()) { @@ -533,7 +533,7 @@ public function fresh(array|string $with = []): static ->with(is_string($with) ? func_get_args() : $with) ->whereIn($model->getKeyName(), array_values($modelKeys)) ->get() - ->getDictionary(); // @phpstan-ignore method.notFound (getDictionary is on Eloquent\Collection; PHPStan loses type through chain) + ->getDictionary(); $freshItems = []; @@ -855,7 +855,6 @@ public function pad(int $size, mixed $value): BaseCollection #[Override] public function partition(mixed $key, mixed $operator = null, mixed $value = null): BaseCollection { - // @phpstan-ignore return.type (parent returns base Collection, we convert to Support Collection) return parent::partition(...func_get_args())->toBase(); } diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index cf543182f..d5f0c43c4 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1937,7 +1937,7 @@ public static function destroy(Collection|BaseCollection|array|int|string $ids): $count = 0; - foreach ($instance->whereIn($key, $ids)->get() as $model) { // @phpstan-ignore method.notFound (Eloquent __call forwarding) + foreach ($instance->whereIn($key, $ids)->get() as $model) { if ($model->delete()) { ++$count; } diff --git a/src/database/src/Eloquent/Relations/BelongsTo.php b/src/database/src/Eloquent/Relations/BelongsTo.php index e5c37b47c..63fd619f9 100644 --- a/src/database/src/Eloquent/Relations/BelongsTo.php +++ b/src/database/src/Eloquent/Relations/BelongsTo.php @@ -223,7 +223,6 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); } - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $this->getQualifiedForeignKeyName(), '=', @@ -246,7 +245,6 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $query->getModel()->setTable($hash); - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->whereColumn( $hash . '.' . $this->ownerKey, '=', diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 92f4268f7..08594319d 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -1026,7 +1026,7 @@ public function get(array $columns = ['*']): BaseCollection $models = $builder->addSelect( $this->shouldSelect($columns) - )->getModels(); // @phpstan-ignore method.notFound (addSelect returns Eloquent\Builder, not Query\Builder) + )->getModels(); $this->hydratePivotRelation($models); @@ -1296,7 +1296,6 @@ public function cursor(): mixed */ protected function prepareQueryBuilder(): Builder { - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $this->query->addSelect($this->shouldSelect()); } diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php index ae5b7d925..1da23e3f8 100755 --- a/src/database/src/Eloquent/Relations/HasOneOrMany.php +++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php @@ -550,7 +550,6 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $query->getModel()->setTable($hash); - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $this->getQualifiedParentKeyName(), '=', diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 0cb47c967..5ed2b2a0b 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -790,7 +790,6 @@ protected function prepareQueryBuilder(array $columns = ['*']): Builder { $builder = $this->query->applyScopes(); - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $builder->addSelect( $this->shouldSelect($builder->getQuery()->columns ? [] : $columns) ); @@ -810,7 +809,6 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $this->performJoin($query); - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $this->getQualifiedLocalKeyName(), '=', @@ -837,7 +835,6 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $query->getModel()->setTable($hash); - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $parentQuery->getQuery()->from . '.' . $this->localKey, '=', @@ -862,7 +859,6 @@ public function getRelationExistenceQueryForThroughSelfRelation(Builder $query, $query->whereNull($hash . '.' . $this->throughParent->getDeletedAtColumn()); } - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $parentQuery->getQuery()->from . '.' . $this->localKey, '=', diff --git a/src/database/src/Eloquent/Relations/Relation.php b/src/database/src/Eloquent/Relations/Relation.php index b48754526..6d2328b87 100644 --- a/src/database/src/Eloquent/Relations/Relation.php +++ b/src/database/src/Eloquent/Relations/Relation.php @@ -339,7 +339,6 @@ public function rawUpdate(array $attributes = []): int */ public function getRelationExistenceCountQuery(Builder $query, Builder $parentQuery): Builder { - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $this->getRelationExistenceQuery( $query, $parentQuery, @@ -358,7 +357,6 @@ public function getRelationExistenceCountQuery(Builder $query, Builder $parentQu */ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder { - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $this->getQualifiedParentKeyName(), '=', diff --git a/src/nested-set/src/Eloquent/AncestorsRelation.php b/src/nested-set/src/Eloquent/AncestorsRelation.php index be63ef9d0..cb704eb81 100644 --- a/src/nested-set/src/Eloquent/AncestorsRelation.php +++ b/src/nested-set/src/Eloquent/AncestorsRelation.php @@ -132,8 +132,8 @@ protected function prepareEagerModels(array $models): array $rgts = []; foreach ($group as $model) { - $lfts[] = $model->getLft(); /* @phpstan-ignore method.notFound */ - $rgts[] = $model->getRgt(); /* @phpstan-ignore method.notFound */ + $lfts[] = $model->getLft(); + $rgts[] = $model->getRgt(); } $positions = array_keys($group); @@ -168,7 +168,7 @@ protected function matchMany(array $models, Collection $results): array $scope = $this->scopeKey($model); $key = $this->matchKey($model); /** @var int $lft */ - $lft = $model->getLft(); /* @phpstan-ignore method.notFound */ + $lft = $model->getLft(); $found = []; foreach ($results as $related) { diff --git a/src/nested-set/src/Eloquent/DescendantsRelation.php b/src/nested-set/src/Eloquent/DescendantsRelation.php index 5359f618d..2835b267b 100644 --- a/src/nested-set/src/Eloquent/DescendantsRelation.php +++ b/src/nested-set/src/Eloquent/DescendantsRelation.php @@ -70,9 +70,7 @@ protected function prepareEagerModels(array $models): array foreach (parent::prepareEagerModels($models) as $model) { // Without a bound strictly between the parent's bounds, no node can be its descendant. - if ($model->getRgt() /* @phpstan-ignore method.notFound */ - <= $model->getLft() + 1 /* @phpstan-ignore method.notFound */ - ) { + if ($model->getRgt() <= $model->getLft() + 1) { continue; } @@ -86,8 +84,8 @@ protected function prepareEagerModels(array $models): array $rgts = []; foreach ($group as $model) { - $lfts[] = $model->getLft(); /* @phpstan-ignore method.notFound */ - $rgts[] = $model->getRgt(); /* @phpstan-ignore method.notFound */ + $lfts[] = $model->getLft(); + $rgts[] = $model->getRgt(); } $positions = array_keys($group); @@ -122,9 +120,9 @@ protected function matchMany(array $models, Collection $results): array $scope = $this->scopeKey($model); $key = $this->matchKey($model); /** @var int $lft */ - $lft = $model->getLft(); /* @phpstan-ignore method.notFound */ + $lft = $model->getLft(); /** @var int $rgt */ - $rgt = $model->getRgt(); /* @phpstan-ignore method.notFound */ + $rgt = $model->getRgt(); $found = []; foreach ($results as $related) { @@ -154,9 +152,9 @@ protected function matchMany(array $models, Collection $results): array $key = $this->matchKey($model); /** @var int $rgt */ - $rgt = $model->getRgt(); /* @phpstan-ignore method.notFound */ + $rgt = $model->getRgt(); $found = []; - $index = static::lowerBound($bucket['lfts'], $model->getLft() + 1); /* @phpstan-ignore method.notFound */ + $index = static::lowerBound($bucket['lfts'], $model->getLft() + 1); for ($count = count($bucket['lfts']); $index < $count && $bucket['lfts'][$index] < $rgt; ++$index) { if ($key === null || $bucket['keys'][$index] !== $key) { diff --git a/src/nested-set/src/Eloquent/QueryBuilder.php b/src/nested-set/src/Eloquent/QueryBuilder.php index 29ad583f2..4170737c1 100644 --- a/src/nested-set/src/Eloquent/QueryBuilder.php +++ b/src/nested-set/src/Eloquent/QueryBuilder.php @@ -1609,7 +1609,7 @@ public function rebuildTree(array $data, bool $delete = false, ?Model $root = nu $childrenByParent, $parentOrder, $existingModel, - $existingModel->getParentId(), /* @phpstan-ignore method.notFound */ + $existingModel->getParentId(), ); } } @@ -1692,7 +1692,7 @@ protected function buildRebuildDictionary( $model->fill($itemData); static::saveRepairNode($model); - static::addRepairNode($roots, $childrenByParent, $parentOrder, $model, $model->getParentId()); /* @phpstan-ignore method.notFound */ + static::addRepairNode($roots, $childrenByParent, $parentOrder, $model, $model->getParentId()); if ($children === null) { continue; diff --git a/src/nested-set/src/Eloquent/SiblingsRelation.php b/src/nested-set/src/Eloquent/SiblingsRelation.php index ec0a2825e..feb0c08e4 100644 --- a/src/nested-set/src/Eloquent/SiblingsRelation.php +++ b/src/nested-set/src/Eloquent/SiblingsRelation.php @@ -160,7 +160,7 @@ protected function matchMany(array $models, Collection $results): array continue; } - $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound */ + $parentId = $model->getParentId(); $siblings = $parentId === null ? ($scope['roots'] ?? []) : ($scope['parents'][$parentId] ?? []); if ($this->andSelf) { From c19554dcdd925f06d7d7fb09a5ca5ae6d85e59c3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:47:50 +0000 Subject: [PATCH 073/303] chore: remove stale authentication and request property ignores Remove obsolete guard-method and dynamic request-property suppressions in authentication, session and workbench consumers. Keep all supported authentication flows and Request property access unchanged. The shared analyzer now handles these paths without the local suppressions. Validation: formatter, root/targeted PHPStan, types and skeleton analysis, 814 analyzer tests and the targeted Stringable runtime suite passed for the combined checkpoint. --- src/auth/src/Middleware/AuthenticateWithBasicAuth.php | 2 +- src/session/src/Middleware/AuthenticateSession.php | 2 +- .../Controllers/Auth/AuthenticatedSessionController.php | 1 - .../Http/Controllers/Auth/ConfirmablePasswordController.php | 2 +- .../src/Http/Controllers/Auth/NewPasswordController.php | 2 +- .../src/Http/Controllers/Auth/RegisteredUserController.php | 6 +++--- src/workbench/src/Http/Controllers/WorkbenchController.php | 4 +--- 7 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/auth/src/Middleware/AuthenticateWithBasicAuth.php b/src/auth/src/Middleware/AuthenticateWithBasicAuth.php index 5104f0060..1fd28543e 100644 --- a/src/auth/src/Middleware/AuthenticateWithBasicAuth.php +++ b/src/auth/src/Middleware/AuthenticateWithBasicAuth.php @@ -35,7 +35,7 @@ public static function using(?string $guard = null, ?string $field = null): stri */ public function handle(Request $request, Closure $next, ?string $guard = null, ?string $field = null): Response { - $this->auth->guard($guard)->basic($field ?: 'email'); /* @phpstan-ignore method.notFound (basic() is on SupportsBasicAuth, not the Guard contract) */ + $this->auth->guard($guard)->basic($field ?: 'email'); return $next($request); } diff --git a/src/session/src/Middleware/AuthenticateSession.php b/src/session/src/Middleware/AuthenticateSession.php index 29fd6b072..f5f1582e8 100644 --- a/src/session/src/Middleware/AuthenticateSession.php +++ b/src/session/src/Middleware/AuthenticateSession.php @@ -57,7 +57,7 @@ public function handle(Request $request, Closure $next): mixed } return tap($next($request), function () use ($request) { - if (! is_null($this->guard()->user())) { // @phpstan-ignore method.notFound + if (! is_null($this->guard()->user())) { $this->storePasswordHashInSession($request); } }); diff --git a/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php b/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php index ab90658eb..6d302ecfb 100644 --- a/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -39,7 +39,6 @@ public function store(LoginRequest $request): RedirectResponse */ public function destroy(Request $request): RedirectResponse { - /* @phpstan-ignore method.notFound */ Auth::guard()->logout(); $request->session()->invalidate(); diff --git a/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php b/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php index 58532924b..89f52bd70 100644 --- a/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php +++ b/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -29,7 +29,7 @@ public function store(Request $request): RedirectResponse { if (! Auth::guard()->validate([ 'email' => $request->user()->email, - 'password' => $request->password, // @phpstan-ignore property.notFound + 'password' => $request->password, ])) { throw ValidationException::withMessages([ 'password' => __('auth.password'), diff --git a/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php b/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php index 2ec9c62e7..76fd27251 100644 --- a/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php +++ b/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php @@ -50,7 +50,7 @@ public function store(Request $request): RedirectResponse $request->only('email', 'password', 'password_confirmation', 'token'), function (Authenticatable&CanResetPassword&Model $user) use ($request): void { $user->forceFill([ - 'password' => Hash::make($request->password), // @phpstan-ignore property.notFound + 'password' => Hash::make($request->password), ]); // The model names its remember token column, or has none. diff --git a/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php b/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php index a337f94e7..c1d32d1a0 100644 --- a/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php +++ b/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php @@ -49,9 +49,9 @@ public function store(Request $request): RedirectResponse ]); $user = $userModel::forceCreate([ - 'name' => $request->name, // @phpstan-ignore property.notFound - 'email' => $request->email, // @phpstan-ignore property.notFound - 'password' => Hash::make($request->password), // @phpstan-ignore property.notFound + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), ]); if (Event::hasListeners(Registered::class)) { diff --git a/src/workbench/src/Http/Controllers/WorkbenchController.php b/src/workbench/src/Http/Controllers/WorkbenchController.php index 86439d5ff..f0d53679c 100644 --- a/src/workbench/src/Http/Controllers/WorkbenchController.php +++ b/src/workbench/src/Http/Controllers/WorkbenchController.php @@ -57,7 +57,7 @@ public function login(string $userId, ?string $guard = null): RedirectResponse $guard = $guard ?: config()->string('auth.defaults.guard'); /** @var UserProvider $provider */ - $provider = Auth::guard($guard)->getProvider(); // @phpstan-ignore method.notFound + $provider = Auth::guard($guard)->getProvider(); $user = Str::contains($userId, '@') ? $provider->retrieveByCredentials(['email' => $userId]) @@ -69,7 +69,6 @@ public function login(string $userId, ?string $guard = null): RedirectResponse // user, such as a password confirmation, carries over. Session::flush(); - /* @phpstan-ignore method.notFound */ Auth::guard($guard)->login($user); return redirect(Workbench::config('start')); @@ -82,7 +81,6 @@ public function logout(?string $guard = null): RedirectResponse { $guard = $guard ?: config()->string('auth.defaults.guard'); - /* @phpstan-ignore method.notFound */ Auth::guard($guard)->logout(); Session::invalidate(); From cbea09085abe613579e3a9fc90aa3c3590224ff8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:47:50 +0000 Subject: [PATCH 074/303] chore: remove obsolete MCP analysis ignores Remove unmatched line suppressions around container calls and response processing in the MCP package. Explain why the transport contract keeps its undeclared native return type: implementations use response and void return declarations. No transport or request handling behavior changes. Validation: formatter, root/targeted PHPStan, types and skeleton analysis, 814 analyzer tests and the targeted Stringable runtime suite passed for the combined checkpoint. --- src/mcp/src/Server/Contracts/Transport.php | 4 +++- src/mcp/src/Server/Methods/GetPrompt.php | 1 - src/mcp/src/Server/Methods/ReadResource.php | 1 - src/mcp/src/Server/Testing/TestResponse.php | 3 --- src/mcp/src/Server/ToolInvoker.php | 1 - 5 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/mcp/src/Server/Contracts/Transport.php b/src/mcp/src/Server/Contracts/Transport.php index 6de267637..0c9b130e4 100644 --- a/src/mcp/src/Server/Contracts/Transport.php +++ b/src/mcp/src/Server/Contracts/Transport.php @@ -15,8 +15,10 @@ public function onReceive(Closure $handler): void; /** * Run the transport. + * + * Implementations return a response or nothing, which no native return type can cover. */ - public function run(); // @phpstan-ignore-line + public function run(); /** * Send an encoded protocol message. diff --git a/src/mcp/src/Server/Methods/GetPrompt.php b/src/mcp/src/Server/Methods/GetPrompt.php index 1bd926caa..f5baa8c3a 100644 --- a/src/mcp/src/Server/Methods/GetPrompt.php +++ b/src/mcp/src/Server/Methods/GetPrompt.php @@ -42,7 +42,6 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat throw new JsonRpcException($invalidArgumentException->getMessage(), -32602, $request->id); } - // @phpstan-ignore-next-line $response = $this->callHandler(fn (): mixed => Container::getInstance()->call([$prompt, 'handle']), $request); return is_iterable($response) diff --git a/src/mcp/src/Server/Methods/ReadResource.php b/src/mcp/src/Server/Methods/ReadResource.php index 138ec8b75..99cdb016c 100644 --- a/src/mcp/src/Server/Methods/ReadResource.php +++ b/src/mcp/src/Server/Methods/ReadResource.php @@ -89,7 +89,6 @@ protected function invokeResource(Resource $resource, string $uri): mixed CoroutineContext::set(RequestContext::LIBRARY_SCRIPTS_CONTEXT_KEY, $resource->libraryScripts()); } - // @phpstan-ignore-next-line return Container::getInstance()->call([$resource, 'handle']); } diff --git a/src/mcp/src/Server/Testing/TestResponse.php b/src/mcp/src/Server/Testing/TestResponse.php index 3ef744d8b..b9e23c881 100644 --- a/src/mcp/src/Server/Testing/TestResponse.php +++ b/src/mcp/src/Server/Testing/TestResponse.php @@ -654,14 +654,11 @@ public function ddErrors(): void protected function content(): array { return (match (true) { - // @phpstan-ignore-next-line $this->primitive instanceof Tool => collect($this->response->toArray()['result']['content'] ?? []) ->map(fn (array $message): string => $message['text'] ?? $message['data'] ?? ''), - // @phpstan-ignore-next-line $this->primitive instanceof Prompt => collect($this->response->toArray()['result']['messages'] ?? []) ->map(fn (array $message): array => $message['content']) ->map(fn (array $content): string => $content['text'] ?? $content['data'] ?? ''), - // @phpstan-ignore-next-line $this->primitive instanceof Resource => collect($this->response->toArray()['result']['contents'] ?? []) ->map(fn (array $item): string => $item['text'] ?? $item['blob'] ?? ''), default => throw new RuntimeException('This primitive type is not supported.'), diff --git a/src/mcp/src/Server/ToolInvoker.php b/src/mcp/src/Server/ToolInvoker.php index da62b21f1..c985b2254 100644 --- a/src/mcp/src/Server/ToolInvoker.php +++ b/src/mcp/src/Server/ToolInvoker.php @@ -33,7 +33,6 @@ public function __construct(protected bool $mirrorStructuredContent = true) */ public function invoke(Tool $tool, JsonRpcRequest $request): Generator|JsonRpcResponse { - // @phpstan-ignore-next-line $response = $this->callHandler(fn (): mixed => Container::getInstance()->call([$tool, 'handle']), $request); return is_iterable($response) From 797f4c44771f26353393bab6687e22e9491d51a8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:47:50 +0000 Subject: [PATCH 075/303] chore: remove stale runtime adapter analysis suppressions Remove unmatched ignores from lock records, native scalar coercion, coroutine state, feature callbacks, Redis adapters and Telescope request options. Preserve the runtime implementations and their guards. Current analysis and dependency stubs cover these sites without the obsolete suppressions. Validation: formatter, root/targeted PHPStan, types and skeleton analysis, 814 analyzer tests and the targeted Stringable runtime suite passed for the combined checkpoint. --- src/bus/src/DispatchLockContext.php | 2 -- src/data/src/Support/Creation/NativeScalar.php | 8 ++++---- src/engine/src/Coroutine.php | 1 - src/pennant/src/Drivers/Decorator.php | 2 +- src/redis/src/PhpRedisClusterConnection.php | 2 +- src/redis/src/RedisConnection.php | 2 +- src/redis/src/RedisSentinelFactory.php | 2 +- src/telescope/src/Storage/EntryQueryOptions.php | 10 +++++----- 8 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/bus/src/DispatchLockContext.php b/src/bus/src/DispatchLockContext.php index 5896ca77d..8361964c2 100644 --- a/src/bus/src/DispatchLockContext.php +++ b/src/bus/src/DispatchLockContext.php @@ -118,7 +118,6 @@ public static function registerUnique( ?Closure $releaseContext = null, ): void { /** @var WeakMap $records */ - // @phpstan-ignore assign.propertyType (PHPStan rejects an empty WeakMap for this invariant closed-shape value.) $records = static::$records ??= new WeakMap; $record = $records[$job] ?? static::newRecord(); @@ -148,7 +147,6 @@ public static function registerDebounce( ?Closure $releaseContext = null, ): void { /** @var WeakMap $records */ - // @phpstan-ignore assign.propertyType (PHPStan rejects an empty WeakMap for this invariant closed-shape value.) $records = static::$records ??= new WeakMap; $record = $records[$job] ?? static::newRecord(); $record['debounce'] = compact('cache', 'key', 'owner'); diff --git a/src/data/src/Support/Creation/NativeScalar.php b/src/data/src/Support/Creation/NativeScalar.php index 73f36b45e..63abd6f93 100644 --- a/src/data/src/Support/Creation/NativeScalar.php +++ b/src/data/src/Support/Creation/NativeScalar.php @@ -16,7 +16,7 @@ final class NativeScalar */ public static function int(mixed $value): int { - return $value; // @phpstan-ignore return.type + return $value; } /** @@ -24,7 +24,7 @@ public static function int(mixed $value): int */ public static function float(mixed $value): float { - return $value; // @phpstan-ignore return.type + return $value; } /** @@ -32,7 +32,7 @@ public static function float(mixed $value): float */ public static function string(mixed $value): string { - return $value; // @phpstan-ignore return.type + return $value; } /** @@ -40,6 +40,6 @@ public static function string(mixed $value): string */ public static function bool(mixed $value): bool { - return $value; // @phpstan-ignore return.type + return $value; } } diff --git a/src/engine/src/Coroutine.php b/src/engine/src/Coroutine.php index 705ce7a3a..82fa7d83c 100644 --- a/src/engine/src/Coroutine.php +++ b/src/engine/src/Coroutine.php @@ -163,7 +163,6 @@ public static function isCanceled(): bool return false; } - /* @phpstan-ignore staticMethod.notFound (@TODO: Remove once PHPStan's bundled JetBrains Swoole stub includes Swoole 6.2's isCanceled() method.) */ return SwooleCo::isCanceled(); } diff --git a/src/pennant/src/Drivers/Decorator.php b/src/pennant/src/Drivers/Decorator.php index 66e63356d..606b929dd 100644 --- a/src/pennant/src/Drivers/Decorator.php +++ b/src/pennant/src/Drivers/Decorator.php @@ -197,7 +197,7 @@ public function define(UnitEnum|string $feature, mixed $resolver = null): void $this->driver->define($feature, function (mixed $scope) use ($feature, $resolver): mixed { if ($resolver instanceof LazilyResolvedFeature) { $resolver = with($this->container->make($resolver->feature), fn ($instance) => method_exists($instance, 'resolve') - ? $instance->resolve(...) // @phpstan-ignore callable.nonNativeMethod + ? $instance->resolve(...) : $instance(...)); } diff --git a/src/redis/src/PhpRedisClusterConnection.php b/src/redis/src/PhpRedisClusterConnection.php index e5f3c6a40..da9a066c1 100644 --- a/src/redis/src/PhpRedisClusterConnection.php +++ b/src/redis/src/PhpRedisClusterConnection.php @@ -280,7 +280,7 @@ protected function callFlushdb(mixed ...$arguments): mixed if ($async) { $result = $this->connection->rawCommand($master, 'flushdb', 'async'); } else { - $result = $this->connection->flushdb($master); // @phpstan-ignore argument.type (connection is always RedisCluster here) + $result = $this->connection->flushdb($master); } $successful = $result !== false && $successful; diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index 2a26d6a68..c8126ac11 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -638,7 +638,7 @@ protected function phpRedisOption(string $name): int 'compression' => Redis::OPT_COMPRESSION, 'reply_literal' => Redis::OPT_REPLY_LITERAL, 'compression_level' => Redis::OPT_COMPRESSION_LEVEL, - 'pack_ignore_numbers' => Redis::OPT_PACK_IGNORE_NUMBERS, // @phpstan-ignore classConstant.notFound (setOptions() guards this option before resolving the constant) + 'pack_ignore_numbers' => Redis::OPT_PACK_IGNORE_NUMBERS, 'max_retries' => Redis::OPT_MAX_RETRIES, 'backoff_algorithm' => Redis::OPT_BACKOFF_ALGORITHM, 'backoff_base' => Redis::OPT_BACKOFF_BASE, diff --git a/src/redis/src/RedisSentinelFactory.php b/src/redis/src/RedisSentinelFactory.php index bb8519371..5479e20ab 100644 --- a/src/redis/src/RedisSentinelFactory.php +++ b/src/redis/src/RedisSentinelFactory.php @@ -18,7 +18,7 @@ class RedisSentinelFactory public function create(array $options = []): RedisSentinel { // https://github.com/phpredis/phpredis/blob/develop/sentinel.md#examples-for-version-60-or-later - return new RedisSentinel($options); // @phpstan-ignore arguments.count, argument.type + return new RedisSentinel($options); } /** diff --git a/src/telescope/src/Storage/EntryQueryOptions.php b/src/telescope/src/Storage/EntryQueryOptions.php index c938be525..97f50e287 100644 --- a/src/telescope/src/Storage/EntryQueryOptions.php +++ b/src/telescope/src/Storage/EntryQueryOptions.php @@ -49,12 +49,12 @@ class EntryQueryOptions public static function fromRequest(Request $request): static { return (new static) - ->batchId($request->batch_id) // @phpstan-ignore property.notFound - ->uuids($request->uuids) // @phpstan-ignore property.notFound - ->beforeSequence($request->before) // @phpstan-ignore property.notFound - ->tag($request->tag) // @phpstan-ignore property.notFound + ->batchId($request->batch_id) + ->uuids($request->uuids) + ->beforeSequence($request->before) + ->tag($request->tag) ->exactTag($request->boolean('exact_tag')) - ->familyHash($request->family_hash) // @phpstan-ignore property.notFound + ->familyHash($request->family_hash) ->limit((int) ($request->take ?? 50)); } From 05ed65eaa0c60a055cebb99e8ae902db688b719f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:47:50 +0000 Subject: [PATCH 076/303] chore: restore unmatched-ignore reporting Remove the root override that hid unmatched ignores after auditing the affected source. Delete the obsolete nested-set, generic-model and void-callback global patterns. Retain the existing covariance policy with an accurate explanation of its wider-reference mutation tradeoff, rather than attributing it to missing runtime generics. Validation: formatter, root/targeted PHPStan, types and skeleton analysis, 814 analyzer tests and the targeted Stringable runtime suite passed for the combined checkpoint. --- phpstan.neon.dist | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 406803375..0206a75e8 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -15,7 +15,6 @@ parameters: minimumNumberOfJobsPerProcess: 2 inferPrivatePropertyTypeFromConstructor: true treatPhpDocTypesAsCertain: false - reportUnmatchedIgnoredErrors: false paths: - bin/sync-skeleton-config - src @@ -58,20 +57,12 @@ parameters: - '#Access to an undefined property Hypervel\\Database\\Schema\\ForeignIdColumnDefinition::\$#' - '#Call to an undefined method Hypervel\\Database\\Schema\\ForeignIdColumnDefinition::#' - # HasNode methods - mixed in at app level, not on base Model class - - message: '#Call to an undefined method Hypervel\\Database\\Eloquent\\Model::new(ScopedQuery|NestedSetQuery)\(\)#' - path: src/nested-set/* - # SoftDeletes trait methods - optionally mixed in, can't be statically verified - '#Call to an undefined method .*::(getDeletedAtColumn|getQualifiedDeletedAtColumn|withTrashed|withoutTrashed|onlyTrashed|forceDelete|restore|trashed|isForceDeleting)\(\)#' - # Generic template type limitations - PHPStan can't resolve methods through generic TModel/TRelatedModel - - '#Call to an undefined method TModel of Hypervel\\Database\\Eloquent\\Model::#' - - '#Call to an undefined method TRelatedModel of Hypervel\\Database\\Eloquent\\Model::#' - - # Covariant template types in invariant/contravariant positions - Laravel uses @template-covariant - # for semantic documentation on collection-like classes even though it violates strict variance rules. - # The code is functionally correct; this is a PHPStan limitation with PHP's lack of runtime generics. + # Collections, paginators and InvokableComponentVariable keep Laravel's covariant TValue, so a + # narrower instance can be passed where a wider one is expected. Writes through the wider reference + # are checked against the wider type, so they can break the original reference's narrower contract. - identifier: generics.variance # Eloquent Relation subclass method overrides - covariance/contravariance issues with complex generics @@ -86,9 +77,5 @@ parameters: - message: '#object\{pivot:#' path: src/database/* - # call_user_func with void callbacks - intentional pattern in Eloquent - - '#Result of function call_user_func \(void\) is used\.#' - - '#Result of callable passed to call_user_func\(\) \(void\) is used\.#' - # Laravel's fluent factory pattern (Collection::make, Model::create, etc.) relies on new static() throughout - '#Unsafe usage of new static#' From 44a623a5201151306a505a759093a135f3dacd1d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:47:50 +0000 Subject: [PATCH 077/303] docs: explain collection typing and remaining analyzer work Document preserved Laravel collection covariance, its mutation tradeoff and the explicit element-contract remedy for bleeding-edge literal inference. Record the difference from Larastan's invariant stubs. Update the plan and remaining-file checklist for completed bleeding-edge and root ignore work, preserving the model-property fixture dependency and private-consumer migration audit. Validation: formatter, root/targeted PHPStan, types and skeleton analysis, 814 analyzer tests and the targeted Stringable runtime suite passed for the combined checkpoint. --- .../2026-11-04-hypervel-phpstan-checklist.md | 5 ++--- docs/plans/2026-11-04-hypervel-phpstan.md | 7 ++++--- src/docs/static-analysis.md | 21 +++++++++++++++++++ src/phpstan/README.md | 1 + 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index 31e4cf241..d38b7be17 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -47,7 +47,6 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## stubs - copy: `common/*` except below, the ported view stubs, `Foundation/Console/Kernel.stub` (its `command()` closure type moved to source; the stub keeps only the empty declaration the `Artisan` mixin needs for stub validation) and the stubs whose types are now in source: `Collection` (except below), `Conditionable`, `Enumerable`, `EnumeratesValues`, `HigherOrderProxies` and `Support/{Arr,Benchmark,Reflector,Str,Tappable}` (60 files) -- add to source with the bleeding-edge collection tests: `common/Collection.stub`'s value-before-callable order for the `$key`/`$callback` parameter of `contains()`, `containsStrict()`, `some()`, `every()`, `partition()` and `reject()` (with the callable first, bleeding-edge PHPStan rejects an argument instead of widening a usage-inferred collection) - merge into `common/Facades.stub` and `common/Helpers.stub` with their topics: the remaining facade entries, and the `cookie()`, `logger()`, `logs()`, `redirect()`, `request()`, `session()` and `url()` helper entries, whose conditional returns the source already declares (only Artisan, View, Route and `view()` are ported; `optional()` is typed in source and `OptionalExtension`; the stub records the omitted helpers) - omit: `common/Config/Repository.stub` (only upstream's omitted `config()` helper stub references it) - add to source with the routing stubs: upstream `common/Route.stub`'s `methods()` and `middleware()` element types as `Route` PHPDoc in `src/routing`, with a `types/` fixture (the stub keeps only the empty declaration stub validation needs) @@ -60,12 +59,12 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `bootstrap.php`, `enum-definition.php`, then add the test bootstrap to `tests/PHPStan/phpstan-tests.neon`; omit `laravel-test.sh` - copy: the remaining `application/` fixture app (app, config, factories, migrations, schema dump) -- merge into `Integration/IntegrationTest.php`: the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties; copy `Integration/BleedingEdgeIntegrationTest.php` +- merge into `Integration/IntegrationTest.php`: the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties - copy: `Reflection/MacroMethodsClassReflectionExtensionTest.php` - copy: `Rules/*Test.php` except below and the ported `ArrKeyExistence`, auth, config, console, environment, enumerable `toArray()`, view, translation and useless-construct rule tests, the remaining `Rules/data/*`, `Rules/Queue/data/*` - omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` - copy: `Type/*Test.php` except below, the ported collection and config tests, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php`, with the remaining `Type/data/*` -- copy next: `Type/BleedingEdgeTypeTest.php` with `bleeding-edge/{collection-keys,collection-usage-inference}.php`; `bleeding-edge/closure-usage-inference.php` (relation callbacks) and `bleeding-edge/eloquent-collection-map.php` join it with the Eloquent ports +- merge into `Type/BleedingEdgeTypeTest.php`: `bleeding-edge/collection-usage-inference.php` with the model-properties port (its `push($user->name)` case needs migration properties), recording its `contains()`/`partition()`/`some()`/`reject()` value-widening cases as `REMOVED:` like `collection-keys.php`'s mutation cases; `bleeding-edge/closure-usage-inference.php` (relation callbacks) and `bleeding-edge/eloquent-collection-map.php` with the Eloquent ports - merge into `Type/CollectionDynamicReturnTypeExtensionsTest.php` with the Eloquent collection port, switching it to `config-with-migrations.neon` (they need model properties and the custom collections): `collection-generic-static-methods{,-l12}.php`, `eloquent-collection-map.php` - merge into `Type/CollectionDynamicReturnTypeExtensionTest.php` with the model fixtures: `collection-filter.php`, `collection-reject.php`, `collection-where.php` (they need `accounts()->active()->get()` to return `AccountCollection`) - omit: `Type/data/collection-generic-static-methods-l11.php` (Laravel 11's Eloquent `partition()` returned `static`; Hypervel follows the Laravel 12 base collection) diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index aa5ef465f..d04f8e874 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -18,8 +18,9 @@ Remaining work: - Also completed: configuration inference and diagnostics, environment-reader policy and source types, consumer config contracts, and the Foundation/Testbench nested-config loader fix, with their regression coverage. Shared result-cache tracking remains below. - Also completed: the independent audit. The owner approved its changes, which this plan now specifies: config-file `@return` types regardless of `treatPhpDocTypesAsCertain`, actionable config diagnostics, the canonical Static Analysis page (see Documentation), the targeted AGENTS.md edits (see Completion criteria) and the warm-run cache reuse check. - The approved audit source, documentation and AGENTS.md changes are applied; the warm-run reuse check lands with the shared cache work. -- Also completed: the helpers and collections flow (higher-order proxies, `Arr` types and `ArrKeyExistenceRule`, collection narrowing, helper extensions, the enumerable `toArray()` and useless-construct rules, collection stub types moved to source) except its bleeding-edge part. -- Next: the bleeding-edge collection tests with the value-before-callable parameter order (see the checklist), together with the Configuration contract's stale-ignore audit and removal of the consumer `reportUnmatchedIgnoredErrors: false` overrides. Then port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. +- Also completed: the helpers and collections flow (higher-order proxies, `Arr` types and `ArrKeyExistenceRule`, collection narrowing, helper extensions, the enumerable `toArray()` and useless-construct rules, collection stub types moved to source), with its bleeding-edge type and integration tests except the model-dependent fixtures listed in the checklist. +- Also completed: Components' stale-ignore audit, with its `reportUnmatchedIgnoredErrors: false` override removed. The two private packages' overrides go with their analyzer adoption (see Consumer migration), because their ignores must be audited against this analyzer. +- Next: port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -222,7 +223,7 @@ Port every applicable upstream inference feature, not just the rules in the next |---|---| | Models | Native declarations and explicit PHPDoc take precedence appropriately; infer schema properties, relations, accessors, casts, separate read/write types, nullability, custom collections, factories and serialization shapes via `ModelSerializationDynamicMethodReturnTypeExtension`. Honor Hypervel attributes, visibility/appends and inheritance; Hypervel instantiates `CollectedBy`, so test a named `collectionClass:` argument and a parent's attribute. Unknown schema knowledge must not erase declared properties or known relationships. | | Builder/relation forwarding | Preserve model generics, custom builders, relation-specific fluent returns, attributed scopes, SoftDeletes/Scout and macro callbacks. Merge existing Hypervel capabilities with applicable upstream cases. | -| Helpers and collections | Preserve conditional argument behavior, `Arr` shapes, `filter()` narrowing, higher-order proxy callbacks and generic collections. Compare inherited annotations before adding a stub. | +| Helpers and collections | Preserve conditional argument behavior, `Arr` shapes, `filter()` narrowing, higher-order proxy callbacks and generic collections. Compare inherited annotations before adding a stub. Keep Laravel's covariant collection `TValue` rather than upstream's invariant stubs: invariance rejects native `Collection` callback hints, `Collection` parameters given narrower collections and fresh-producer returns, and call-site `covariant` projections only cover read-only inputs. PHPStan 2.3.5's bleeding edge does not widen an already inferred covariant `TValue`, so upstream's bleeding-edge value-widening cases are recorded as `REMOVED:`; the docs give the `@var` remedy. | | Authentication | One shared guard-type helper serves `Auth::guard()`, `Factory::guard()` and `auth($guard)`, following `AuthManager::resolve()`: a booted custom creator's declared return (`ClosureTypeFactory`, never invoked), else the booted manager's `create{Driver}Driver()` declaration, else the declared return. `viaRequest()`'s creator declares `RequestGuard`, so request guards are typed; an untyped creator keeps the declared return. Never claim `StatefulGuard` for an unmodelled guard: only `SessionGuard` is stateful, and token/request/Sanctum/JWT/Passport guards are not. An omitted, null or empty name is the union of every configured guard's type, because `shouldUse()` changes the default per coroutine; so is an unknown or unpacked name, because resolving an unconfigured name throws. A known set of string or enum names gives the union of those guards. `auth()` and `auth(null)` type as the booted factory concrete; unlike `guard(null)`, a maybe-null or unpacked `auth()` argument can return the factory, so it keeps the declared conditional. Guard names come only from `guard()` on the factory or facade, the global `auth()` (resolved function identity) and `Request::user()`; enum cases map like `enum_value()` where the signature accepts enums. User models are provider-based inference: an Eloquent provider gives its model for any guard driver; a database provider gives `GenericUser` only for the stock session/token drivers (Sanctum accepts session-guard users when its provider has no `getModel()`). A guard without a modelled provider, or one whose provider driver has a custom creator, keeps the call's declaration. Preserve narrower declarations; manager and facade `user()` calls are typed only when they forward the `Guard` contract's method. Omit `AuthsMethodsExtension`: `AuthManager`'s mixins already forward guard methods, and contracts do not gain implementation methods. | | Console commands | Argument/option types and the undefined-input rule read the booted kernel's commands (`$kernel->all()`: `withCommands()` discovery, `command()` closures and providers' `starting()` registrations; a standalone application is the fallback when the kernel cannot list them). Look up the call's receiver, not the enclosing class; exactly `Command` and `ClosureCommand` receivers keep the declaration, because they name every command or unrelated per-instance signatures. Merge each matched command's application definition once at lookup, as `run()` does, so bulk shapes include `command` and the global options regardless of analysis order. A name lookup is the union over matching commands that define it (missing ones throw), else the declaration; option names follow `Input`, so negations resolve to their option and shortcuts are not names. Aliases list a command once. A definitely-null key is a bulk lookup; overridden methods keep their declarations for typing and diagnostics. The rule skips a call whose same-receiver `hasArgument()`/`hasOption()` guard is definitely true or unreachable in scope, because the call never reaches the commands without the name; Components' `GeneratorCommand` and `CreatesMatchingTest` guard `--force` this way. Scope keys the guard as written, so check its positional and `name:` forms built from the getter's value, never the getter's own argument nodes. A receiver union with an alternative that has no known definition (e.g. `FooCommand|ClosureCommand`) keeps the declaration. `@param-closure-this` for `command()` lives on the Kernel contract and Foundation Kernel. | | Views and translations | Retain `view-string` and live view-finder namespaces. Keep translation typing and the adapted missing-key rule. The optional unused-view rule reports absent statically discovered usage, not proof that a view can be deleted. | diff --git a/src/docs/static-analysis.md b/src/docs/static-analysis.md index 5225288f5..3c4a0b3fd 100644 --- a/src/docs/static-analysis.md +++ b/src/docs/static-analysis.md @@ -5,6 +5,7 @@ - [Analyzing Packages](#analyzing-packages) - [Runtime Requirements](#runtime-requirements) - [What the Analysis Knows](#what-the-analysis-knows) + - [Collection Types](#collection-types) - [Fixing and Ignoring Diagnostics](#fixing-and-ignoring-diagnostics) - [Environment Variables](#environment-variables) - [Configuration Values](#configuration-values) @@ -83,6 +84,26 @@ Some facts cannot be known statically. When the analysis cannot tell what a conf Calling `str()` without arguments returns an object that passes method calls to `Str`. The analysis does not check those calls. + +### Collection Types + +Collections keep Laravel's declared types, so a collection of users may be passed where a collection of models is expected. Writes through the wider reference are checked against its model type, so the analysis can miss a value that violates the original collection's narrower user type. + +If you enable PHPStan's [bleeding edge](https://phpstan.org/blog/what-is-bleeding-edge) mode, PHPStan keeps the value type it infers for a new collection, so adding or searching for a value of another type is reported: + +```php +$ids = collect([1, 2, 3]); + +$ids->push(4); // Reported with bleeding edge... +``` + +Declare the type the collection is meant to hold when you create it: + +```php +/** @var Collection $ids */ +$ids = collect([1, 2, 3]); +``` + ## Fixing and Ignoring Diagnostics diff --git a/src/phpstan/README.md b/src/phpstan/README.md index 092c4ae88..1a25d79ce 100644 --- a/src/phpstan/README.md +++ b/src/phpstan/README.md @@ -12,6 +12,7 @@ Documentation: https://hypervel.org/docs/static-analysis - A value typed as an interface does not gain methods from its implementations. Upstream resolves an undefined method on a contract against its container binding, and on the auth user contracts against the configured auth models. Explicit container resolution, such as `app(Repository::class)`, is still typed from the booted application's binding. - Without a guard name, `Auth::guard()` and `auth()->guard()` may return any configured guard, because `shouldUse()` changes the default guard per request. A guard whose type cannot be read from its driver keeps the declared `Guard` type rather than `StatefulGuard`. - The request-scope auth rules offer no automatic fix, because the request's user resolver can return a different user than the `Auth` facade or `auth()` helper. +- Collections keep Laravel's covariant value type, so a `Collection` is accepted where a `Collection` is expected. Upstream's stubs make it invariant. With bleeding edge, PHPStan does not widen a covariant value type it has already inferred, so a call such as `collect([1, 2, 3])->push(4)` is reported. See [Collection Types](https://hypervel.org/docs/static-analysis#collection-types). - `now()`, `today()` and the `Date` facade return `CarbonInterface`, as declared, rather than the date class configured when analysis runs. Code that needs a specific class should convert the value, for example with `toImmutable()`. - The `generalizeEnvReturnType` and `redisDriver` settings are omitted. An `env()` default does not decide the type of the value it reads, and Redis types always describe Hypervel's phpredis connection. From 4c6616d1d790c4518fc0b9029d58b71d906004ba Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:31:56 +0000 Subject: [PATCH 078/303] docs: focus editing guidance on coherent changes Replace the one-file-at-a-time restriction with the owner-approved wording for coherent changes. Require dependent edits to be sequenced and affected behavior to be reviewed across files before the change is considered finished. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f007259b6..5d773c00e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,7 +141,7 @@ Apply approved adaptations when porting or modifying code. Do not expand a chang - **Never use subagents without explicit user consent** — Do not spawn or delegate work to subagents unless the user explicitly requests or approves their use. - **Avoid bulk modification tools** — tools like `sed` and `replace_all` often have unwanted side effects. Never use bulk modification tools without explicit user approval; prefer manual edits. When approved, run them in multiple passes that each target long, exact, case-sensitive strings to avoid accidental changes. -- **One file at a time** — never work on multiple files simultaneously. This governs manual editing; package-manager and formatter runs may touch multiple files. +- **One change at a time** — Work on one coherent change at a time. Sequence dependent edits, and review the complete affected behavior across files before considering the change finished. - **Never use Write to overwrite files** — always use Edit for targeted updates. - **Always use `cp` to copy files and `mv` to move/rename** — never read → write new version → delete old version. - **Copy before splitting** — When a new file or class is primarily extracted from existing code, use `cp` to copy the primary source first and then update the copy rather than rebuilding it from individual pieces. Copy any additional blocks, including comments and docblocks, into the destination before removing them from their source. From 6b9a7d9e1dc133d0d33ce60aa0b903623d5b7a60 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:23:25 +0000 Subject: [PATCH 079/303] fix: reject manager driver names that collide with the dispatcher Centralize creator-method selection in Manager::getCreatorMethod() and reject names that normalize to createDriver itself. Preserve registered custom creators and the existing unsupported-driver diagnostics. Reuse the selector in ImageManager and SentinelManager so their prechecks and fallback behavior agree with actual creation. Cover empty and punctuation-only names and registered creators. Validation: the combined checkpoint passed formatting, root PHPStan, type fixtures, 969 analyzer tests and the affected Support, Image, Sentinel, Socialite, Hashing, Notifications, JWT, Session, Slack, Reverb and foundation provider runtime checks. --- src/image/src/ImageManager.php | 5 +-- src/sentinel/src/SentinelManager.php | 3 +- src/support/src/Manager.php | 15 ++++++- tests/Image/ImageManagerTest.php | 9 ++++ tests/Integration/Support/ManagerTest.php | 42 +++++++++++++++++++ .../Sentinel/Feature/SentinelManagerTest.php | 7 ++++ 6 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/image/src/ImageManager.php b/src/image/src/ImageManager.php index bf8d073b7..b929f8cc7 100644 --- a/src/image/src/ImageManager.php +++ b/src/image/src/ImageManager.php @@ -13,7 +13,6 @@ use Hypervel\Image\Drivers\GdDriver; use Hypervel\Image\Drivers\ImagickDriver; use Hypervel\Support\Manager; -use Hypervel\Support\Str; use InvalidArgumentException; use UnitEnum; @@ -114,9 +113,7 @@ public function fromUrl(string $url): Image */ protected function createDriver(string $driver): Driver { - $method = 'create' . Str::studly($driver) . 'Driver'; - - if (! isset($this->customCreators[$driver]) && ! method_exists($this, $method)) { + if (! isset($this->customCreators[$driver]) && $this->getCreatorMethod($driver) === null) { throw new InvalidArgumentException("Image driver [{$driver}] is not supported."); } diff --git a/src/sentinel/src/SentinelManager.php b/src/sentinel/src/SentinelManager.php index 9971ade27..48471084d 100644 --- a/src/sentinel/src/SentinelManager.php +++ b/src/sentinel/src/SentinelManager.php @@ -8,7 +8,6 @@ use Hypervel\Sentinel\Drivers\Driver; use Hypervel\Sentinel\Drivers\Hypervel; use Hypervel\Support\Manager; -use Hypervel\Support\Str; use InvalidArgumentException; class SentinelManager extends Manager @@ -42,7 +41,7 @@ public function driverOrFallback(?string $driver): Driver { if ($driver !== null && ! isset($this->customCreators[$driver]) - && ! method_exists($this, 'create' . Str::studly($driver) . 'Driver')) { + && $this->getCreatorMethod($driver) === null) { $driver = null; } diff --git a/src/support/src/Manager.php b/src/support/src/Manager.php index db3a5cc77..3d76fc465 100644 --- a/src/support/src/Manager.php +++ b/src/support/src/Manager.php @@ -95,15 +95,26 @@ protected function createDriver(string $driver): mixed return $this->callCustomCreator($driver); } - $method = 'create' . Str::studly($driver) . 'Driver'; + $method = $this->getCreatorMethod($driver); - if (method_exists($this, $method)) { + if ($method !== null) { return $this->{$method}(); } throw new InvalidArgumentException("Driver [{$driver}] not supported."); } + /** + * Get the name of the manager's creator method for the given driver. + */ + protected function getCreatorMethod(string $driver): ?string + { + $method = 'create' . Str::studly($driver) . 'Driver'; + + // Hypervel: a name that studly-cases to nothing, such as '' or '-', would resolve to createDriver() itself. + return $method !== 'createDriver' && method_exists($this, $method) ? $method : null; + } + /** * Call a custom driver creator. */ diff --git a/tests/Image/ImageManagerTest.php b/tests/Image/ImageManagerTest.php index 5297a6abf..6ecb55c95 100644 --- a/tests/Image/ImageManagerTest.php +++ b/tests/Image/ImageManagerTest.php @@ -82,6 +82,15 @@ public function testThrowsForUnsupportedDriver(): void $manager->driver('nonexistent'); } + public function testThrowsForADriverNameWithoutACreatorMethod(): void + { + $manager = new ImageManager($this->makeApp([])); + + $this->expectExceptionObject(new InvalidArgumentException('Image driver [-] is not supported.')); + + $manager->driver('-'); + } + public function testCustomDriverExceptionPropagatesUnchanged(): void { $creatorException = new InvalidArgumentException('Unable to create the custom image driver.'); diff --git a/tests/Integration/Support/ManagerTest.php b/tests/Integration/Support/ManagerTest.php index 690b74bfd..92869966f 100644 --- a/tests/Integration/Support/ManagerTest.php +++ b/tests/Integration/Support/ManagerTest.php @@ -10,6 +10,7 @@ use Hypervel\Testbench\TestCase; use Hypervel\Tests\Integration\Support\Fixtures\NullableManager; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use stdClass; class ManagerTest extends TestCase @@ -116,6 +117,36 @@ public function testNullAndEmptyStringSelectTheDefaultDriver(): void $this->assertSame('zero', $manager->driver(ManagerIntegerIdentifier::Zero)); } + #[DataProvider('namesWithoutACreatorMethodProvider')] + public function testNamesWithoutACreatorMethodAreNotSupported(?string $driver, string $message): void + { + $manager = new EmptyDefaultManager($this->app); + + $this->expectExceptionObject(new InvalidArgumentException($message)); + + $manager->driver($driver); + } + + public static function namesWithoutACreatorMethodProvider(): array + { + return [ + 'empty default' => [null, 'Driver [] not supported.'], + 'separator' => ['-', 'Driver [-] not supported.'], + 'whitespace' => [' ', 'Driver [ ] not supported.'], + ]; + } + + public function testNamesWithoutACreatorMethodCanBeRegistered(): void + { + $manager = new EmptyDefaultManager($this->app); + + $manager->extend('', fn (): string => 'empty'); + $manager->extend('-', fn (): string => 'separator'); + + $this->assertSame('empty', $manager->driver()); + $this->assertSame('separator', $manager->driver('-')); + } + public function testSetContainerRefreshesTheConfigurationRepository(): void { $manager = $this->createManager(); @@ -185,6 +216,17 @@ public function getConfigurationRepository(): Repository } } +class EmptyDefaultManager extends Manager +{ + /** + * Get the default driver name. + */ + public function getDefaultDriver(): string + { + return ''; + } +} + enum ManagerUnitIdentifier { case Primary; diff --git a/tests/Sentinel/Feature/SentinelManagerTest.php b/tests/Sentinel/Feature/SentinelManagerTest.php index 33ae417f4..59c6f3a9e 100644 --- a/tests/Sentinel/Feature/SentinelManagerTest.php +++ b/tests/Sentinel/Feature/SentinelManagerTest.php @@ -38,6 +38,13 @@ public function testItCanFallbackToDefaultDriver(): void $this->assertSame($manager->driver(), $manager->driverOrFallback(null)); } + public function testItFallsBackForANameWithoutACreatorMethod(): void + { + $manager = app(SentinelManager::class); + + $this->assertSame($manager->driver(), $manager->driverOrFallback('-')); + } + public function testItResolvesRegisteredDriversWithoutTheDefault(): void { $manager = app(SentinelManager::class); From d03dfbe59dabab05634f57f2ccce3dceec6475f7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:23:25 +0000 Subject: [PATCH 080/303] feat: preserve target signatures in analyzer forwarding Share forwarding variant construction, pass argument values rather than caller references, and bind static returns to the target class. Preserve the target method metadata through static forwarding. Port method/property tag and mixin lookup with specialization-aware caches. Cover generic mixins whose methods and properties differ between template arguments. Validation: the combined checkpoint passed formatting, root PHPStan, type fixtures, 969 analyzer tests and the affected Support, Image, Sentinel, Socialite, Hashing, Notifications, JWT, Session, Slack, Reverb and foundation provider runtime checks. --- .../Reflection/ProxiedMethodReflection.php | 32 ++-- .../Reflection/ProxiedParameterReflection.php | 20 +++ .../src/Reflection/ReflectionHelper.php | 90 +++++++++++ .../src/Reflection/StaticMethodReflection.php | 147 ++++++++++++++++++ .../PHPStan/Fixtures/Reflection/MixinHost.php | 15 ++ .../Reflection/ReflectionHelperTest.php | 37 +++++ 6 files changed, 327 insertions(+), 14 deletions(-) create mode 100644 src/phpstan/src/Reflection/ReflectionHelper.php create mode 100644 src/phpstan/src/Reflection/StaticMethodReflection.php create mode 100644 tests/PHPStan/Fixtures/Reflection/MixinHost.php create mode 100644 tests/PHPStan/Reflection/ReflectionHelperTest.php diff --git a/src/phpstan/src/Reflection/ProxiedMethodReflection.php b/src/phpstan/src/Reflection/ProxiedMethodReflection.php index 73be63502..7083f0d88 100644 --- a/src/phpstan/src/Reflection/ProxiedMethodReflection.php +++ b/src/phpstan/src/Reflection/ProxiedMethodReflection.php @@ -10,7 +10,6 @@ use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\FunctionVariant; use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\TrinaryLogic; use PHPStan\Type\StaticType; @@ -100,24 +99,29 @@ public function getPrototype(): ClassMemberReflection public function getVariants(): array { return array_map( - fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant( - $variant->getTemplateTypeMap(), - $variant->getResolvedTemplateTypeMap(), - // Other parameters stay as they are, keeping PHPStan's extended metadata such as closure-this types. - array_map( - static fn (ParameterReflection $parameter): ParameterReflection => $parameter->passedByReference()->no() - ? $parameter - : new ProxiedParameterReflection($parameter), - $variant->getParameters(), - ), - $variant->isVariadic(), - self::resolveStaticTypes(($this->returnType)($variant->getReturnType())), - $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + fn (ParametersAcceptor $variant): FunctionVariant => self::proxyVariant( + $variant, + ($this->returnType)($variant->getReturnType()), ), $this->targetMethod->getVariants(), ); } + /** + * Describe a target method's variant as a proxy receives it, returning the given type. + */ + public static function proxyVariant(ParametersAcceptor $variant, Type $returnType): FunctionVariant + { + return new FunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + ProxiedParameterReflection::fromTargetParameters($variant->getParameters()), + $variant->isVariadic(), + self::resolveStaticTypes($returnType), + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + ); + } + /** * Resolve the static types in a type the proxy returns to their classes. PHPStan binds the * static types of a proxy's members to the proxy, while they describe the proxy's target. diff --git a/src/phpstan/src/Reflection/ProxiedParameterReflection.php b/src/phpstan/src/Reflection/ProxiedParameterReflection.php index 57432b22b..0ebab4859 100644 --- a/src/phpstan/src/Reflection/ProxiedParameterReflection.php +++ b/src/phpstan/src/Reflection/ProxiedParameterReflection.php @@ -8,6 +8,8 @@ use PHPStan\Reflection\PassedByReference; use PHPStan\Type\Type; +use function array_map; + /** * Describe a target method's by-reference parameter as a proxy receives it. A proxy's __call() * gets the argument values, so the caller's variable is never passed by reference. @@ -22,6 +24,24 @@ public function __construct( ) { } + /** + * Describe a target's parameters as a proxy receives them. + * + * Other parameters stay as they are, keeping PHPStan's extended metadata such as closure-this types. + * + * @param list $parameters + * @return list + */ + public static function fromTargetParameters(array $parameters): array + { + return array_map( + static fn (ParameterReflection $parameter): ParameterReflection => $parameter->passedByReference()->no() + ? $parameter + : new self($parameter), + $parameters, + ); + } + /** * Return the parameter name. */ diff --git a/src/phpstan/src/Reflection/ReflectionHelper.php b/src/phpstan/src/Reflection/ReflectionHelper.php new file mode 100644 index 000000000..2453432cb --- /dev/null +++ b/src/phpstan/src/Reflection/ReflectionHelper.php @@ -0,0 +1,90 @@ + */ + private static array $propertyTagCache = []; + + /** @var array */ + private static array $methodTagCache = []; + + /** + * Determine whether the given class or its ancestors declare the property with an `@property*` tag, or a mixin has it. + */ + public static function hasPropertyTag(ClassReflection $classReflection, string $propertyName): bool + { + // Hypervel: keyed by the specialization, because a generic class's mixin can depend on its template types. + $cacheKey = $classReflection->getCacheKey() . '-' . $propertyName; + + if (array_key_exists($cacheKey, self::$propertyTagCache)) { + return self::$propertyTagCache[$cacheKey]; + } + + return self::$propertyTagCache[$cacheKey] = self::resolvePropertyTag($classReflection, $propertyName); + } + + /** + * Resolve whether the class, its ancestors or its mixins declare the property. + */ + private static function resolvePropertyTag(ClassReflection $classReflection, string $propertyName): bool + { + if (array_key_exists($propertyName, $classReflection->getPropertyTags())) { + return true; + } + + foreach ($classReflection->getAncestors() as $ancestor) { + if (array_key_exists($propertyName, $ancestor->getPropertyTags())) { + return true; + } + } + + // @phpstan-ignore phpstanApi.constructor (Reuses PHPStan's mixin lookup, which also checks traits and parents.), phpstanApi.method (Same mixin lookup.) + return (new MixinPropertiesClassReflectionExtension([$classReflection->getName()])) + ->hasProperty($classReflection, $propertyName); + } + + /** + * Determine whether the given class or its ancestors declare the method with an `@method` tag, or a mixin has it. + */ + public static function hasMethodTag(ClassReflection $classReflection, string $methodName): bool + { + // Hypervel: keyed by the specialization, because a generic class's mixin can depend on its template types. + $cacheKey = $classReflection->getCacheKey() . '-' . $methodName; + + if (array_key_exists($cacheKey, self::$methodTagCache)) { + return self::$methodTagCache[$cacheKey]; + } + + return self::$methodTagCache[$cacheKey] = self::resolveMethodTag($classReflection, $methodName); + } + + /** + * Resolve whether the class, its ancestors or its mixins declare the method. + */ + private static function resolveMethodTag(ClassReflection $classReflection, string $methodName): bool + { + if (array_key_exists($methodName, $classReflection->getMethodTags())) { + return true; + } + + foreach ($classReflection->getAncestors() as $ancestor) { + if (array_key_exists($methodName, $ancestor->getMethodTags())) { + return true; + } + } + + // @phpstan-ignore phpstanApi.constructor (Reuses PHPStan's mixin lookup, which also checks traits and parents.), phpstanApi.method (Same mixin lookup.) + return (new MixinMethodsClassReflectionExtension([$classReflection->getName()])) + ->hasMethod($classReflection, $methodName); + } +} diff --git a/src/phpstan/src/Reflection/StaticMethodReflection.php b/src/phpstan/src/Reflection/StaticMethodReflection.php new file mode 100644 index 000000000..d1d426901 --- /dev/null +++ b/src/phpstan/src/Reflection/StaticMethodReflection.php @@ -0,0 +1,147 @@ +methodReflection->getDeclaringClass(); + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return true; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return $this->methodReflection->isPrivate(); + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return $this->methodReflection->isPublic(); + } + + /** + * Return the method docblock. + */ + public function getDocComment(): ?string + { + return $this->methodReflection->getDocComment(); + } + + /** + * Return the method name. + */ + public function getName(): string + { + return $this->methodReflection->getName(); + } + + /** + * Return the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this->methodReflection->getPrototype(); + } + + /** + * Return the method variants as the forwarding call receives them. + * + * @return list + */ + public function getVariants(): array + { + // Hypervel: facades and macros pass the target its argument values and return the target's own result, + // so by-reference parameters never receive the caller's variable and static returns describe the target. + return array_map( + static fn (ParametersAcceptor $variant): FunctionVariant => ProxiedMethodReflection::proxyVariant( + $variant, + $variant->getReturnType(), + ), + $this->methodReflection->getVariants(), + ); + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return $this->methodReflection->isDeprecated(); + } + + /** + * Return the method's deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return $this->methodReflection->getDeprecatedDescription(); + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return $this->methodReflection->isFinal(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return $this->methodReflection->isInternal(); + } + + /** + * Return the method's throw type. + */ + public function getThrowType(): ?Type + { + return $this->methodReflection->getThrowType(); + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return $this->methodReflection->hasSideEffects(); + } +} diff --git a/tests/PHPStan/Fixtures/Reflection/MixinHost.php b/tests/PHPStan/Fixtures/Reflection/MixinHost.php new file mode 100644 index 000000000..8dda7f5dc --- /dev/null +++ b/tests/PHPStan/Fixtures/Reflection/MixinHost.php @@ -0,0 +1,15 @@ +createReflectionProvider()->getClass(MixinHost::class); + + // DateInterval declares format() and the public $y; ArrayObject declares neither. + $dateInterval = $host->withTypes([new ObjectType(DateInterval::class)]); + $arrayObject = $host->withTypes([new ObjectType(ArrayObject::class)]); + + $this->assertTrue(ReflectionHelper::hasMethodTag($dateInterval, 'format')); + $this->assertTrue(ReflectionHelper::hasPropertyTag($dateInterval, 'y')); + $this->assertFalse(ReflectionHelper::hasMethodTag($arrayObject, 'format')); + $this->assertFalse(ReflectionHelper::hasPropertyTag($arrayObject, 'y')); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} From 5f45e157ca15bc0e9183cb5f25249b9fdba62af8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:23:25 +0000 Subject: [PATCH 081/303] feat: infer manager forwarding without constructing drivers Read the selected driver's declared type from the bound manager and registered creators instead of constructing backend drivers. Respect overridden dispatch methods, custom creators, inaccessible methods and incompatible already-created drivers. Cache by generic specialization and use the effective bound subclass for discovery. Keep forwarding instance-only and give maintenance-mode drivers their shared contract mixin. Cover these boundaries with focused manager fixtures. Validation: the combined checkpoint passed formatting, root PHPStan, type fixtures, 969 analyzer tests and the affected Support, Image, Sentinel, Socialite, Hashing, Notifications, JWT, Session, Slack, Reverb and foundation provider runtime checks. --- src/foundation/src/MaintenanceModeManager.php | 4 + .../src/Methods/ManagersMethodsExtension.php | 207 ++++++++++++++++++ src/support/src/Facades/MaintenanceMode.php | 4 + .../Fixtures/Managers/BoundExportManager.php | 29 +++ .../Fixtures/Managers/ChildExportManager.php | 27 +++ tests/PHPStan/Fixtures/Managers/CsvDriver.php | 61 ++++++ .../Fixtures/Managers/ExportManager.php | 34 +++ .../Managers/ExtendedExportManager.php | 29 +++ .../Fixtures/Managers/GenericManager.php | 34 +++ .../PHPStan/Fixtures/Managers/JsonDriver.php | 19 ++ .../Managers/MacroableExportManager.php | 32 +++ .../Fixtures/Managers/RenamingManager.php | 40 ++++ .../Managers/ResolvedExportManager.php | 29 +++ .../Fixtures/Managers/SelectingManager.php | 31 +++ .../Fixtures/Managers/UnconfiguredManager.php | 29 +++ .../Managers/WrappedCreatorManager.php | 30 +++ .../Fixtures/Managers/WrappingManager.php | 37 ++++ .../ManagersMethodsExtensionTest.php | 28 +++ tests/PHPStan/Type/data/abstract-manager.php | 23 ++ tests/PHPStan/Type/data/managers.php | 102 +++++++++ 20 files changed, 829 insertions(+) create mode 100644 src/phpstan/src/Methods/ManagersMethodsExtension.php create mode 100644 tests/PHPStan/Fixtures/Managers/BoundExportManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/ChildExportManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/CsvDriver.php create mode 100644 tests/PHPStan/Fixtures/Managers/ExportManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/ExtendedExportManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/GenericManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/JsonDriver.php create mode 100644 tests/PHPStan/Fixtures/Managers/MacroableExportManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/RenamingManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/ResolvedExportManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/SelectingManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/UnconfiguredManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/WrappedCreatorManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/WrappingManager.php create mode 100644 tests/PHPStan/Reflection/ManagersMethodsExtensionTest.php create mode 100644 tests/PHPStan/Type/data/abstract-manager.php create mode 100644 tests/PHPStan/Type/data/managers.php diff --git a/src/foundation/src/MaintenanceModeManager.php b/src/foundation/src/MaintenanceModeManager.php index 62d46443a..2b4001905 100644 --- a/src/foundation/src/MaintenanceModeManager.php +++ b/src/foundation/src/MaintenanceModeManager.php @@ -5,8 +5,12 @@ namespace Hypervel\Foundation; use Hypervel\Contracts\Container\BindingResolutionException; +use Hypervel\Contracts\Foundation\MaintenanceMode; use Hypervel\Support\Manager; +/** + * @mixin MaintenanceMode + */ class MaintenanceModeManager extends Manager { /** diff --git a/src/phpstan/src/Methods/ManagersMethodsExtension.php b/src/phpstan/src/Methods/ManagersMethodsExtension.php new file mode 100644 index 000000000..0cce8c5a7 --- /dev/null +++ b/src/phpstan/src/Methods/ManagersMethodsExtension.php @@ -0,0 +1,207 @@ + */ + private array $cache = []; + + /** @var array */ + private array $driverTypes = []; + + /** + * Create a managers methods extension. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClosureTypeFactory $closureTypeFactory, + ) { + } + + /** + * Determine whether the manager's default driver has the public method the manager forwards. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + if (! $classReflection->is(Manager::class) || $classReflection->isAbstract()) { + return false; + } + + // Hypervel: keyed by the specialization, because a generic manager's declared driver can depend on its template types. + $key = $classReflection->getCacheKey() . '-' . $methodName; + + if (isset($this->cache[$key])) { + return true; + } + + // Hypervel: @method tags and mixins declare the forwarded API, so they win over the booted driver. + if (ReflectionHelper::hasMethodTag($classReflection, $methodName)) { + return false; + } + + // Hypervel: typed from the driver's declared type, while upstream builds the driver. + $driverType = $this->getDriverType($classReflection); + + if ($driverType === null || ! $driverType->hasMethod($methodName)->yes()) { + return false; + } + + $method = $driverType->getMethod($methodName, new OutOfClassScope); + + // Hypervel: __call() runs in the manager, so it reaches only the driver's public methods. + if (! $method->isPublic()) { + return false; + } + + // Hypervel: an instance call with argument values, unlike upstream's StaticMethodReflection, because + // Manager has no __callStatic(). The driver's own static returns stay the driver's. + $this->cache[$key] = new ProxiedMethodReflection( + $classReflection, + $method, + static fn (Type $returnType): Type => $returnType, + ); + + return true; + } + + /** + * Get the driver method the manager forwards. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->cache[$classReflection->getCacheKey() . '-' . $methodName]; + } + + /** + * Get the declared type of the booted manager's default driver, resolving it once per manager. + */ + private function getDriverType(ClassReflection $classReflection): ?Type + { + $key = $classReflection->getCacheKey(); + + if (! array_key_exists($key, $this->driverTypes)) { + $this->driverTypes[$key] = $this->resolveDriverType($classReflection); + } + + return $this->driverTypes[$key]; + } + + /** + * Resolve the declared type of the booted manager's default driver, following Manager::driver(). + */ + private function resolveDriverType(ClassReflection $classReflection): ?Type + { + $manager = $this->resolve($classReflection->getName()); + + // The container can bind the manager to a subclass, whose methods then do the forwarding. A manager + // that does not resolve still types an overriding driver() from its declaration. + $managerReflection = $manager instanceof Manager && $manager::class !== $classReflection->getName() + ? $this->reflectionProvider->getClass($manager::class) + : $classReflection; + + // A Macroable or custom __call() keeps its own API instead of forwarding to the driver. + if ($this->overrides($managerReflection, '__call')) { + return null; + } + + // An override's declared return types what it selects or creates. Its body is never interpreted. + if ($this->overrides($managerReflection, 'driver')) { + return $this->getReturnType($managerReflection, 'driver'); + } + + if (! $manager instanceof Manager) { + return null; + } + + try { + $driver = $manager->getDefaultDriver(); + } catch (InvalidArgumentException) { + // The manager has no default driver, so nothing is forwarded. + return null; + } + + if ($driver === null) { + return null; + } + + /** @var null|Closure $creator */ + $creator = (fn (): ?Closure => $this->customCreators[$driver] ?? null)->call($manager); + + if ($this->overrides($managerReflection, 'createDriver')) { + $type = $this->getReturnType($managerReflection, 'createDriver'); + } elseif ($creator !== null) { + $type = $this->overrides($managerReflection, 'callCustomCreator') + ? $this->getReturnType($managerReflection, 'callCustomCreator') + : $this->closureTypeFactory->fromClosureObject($creator)->getReturnType(); + } elseif ($this->overrides($managerReflection, 'getCreatorMethod')) { + return null; + } else { + $method = (fn (): ?string => $this->getCreatorMethod($driver))->call($manager); + + if ($method === null) { + return null; + } + + $type = $this->getReturnType($managerReflection, $method); + } + + // Manager::driver() keeps returning a driver it already created, even after extend() registers another creator. + $cached = $manager->getDrivers()[$driver] ?? null; + + if ($cached !== null && $type->isSuperTypeOf(ConstantTypeHelper::getTypeFromValue($cached))->no()) { + return null; + } + + return $type; + } + + /** + * Determine whether the manager overrides the given Manager method. + */ + private function overrides(ClassReflection $classReflection, string $methodName): bool + { + return $classReflection->getNativeMethod($methodName)->getDeclaringClass()->getName() !== Manager::class; + } + + /** + * Get the declared return type of the manager's method. + */ + private function getReturnType(ClassReflection $classReflection, string $methodName): Type + { + return $classReflection->getNativeMethod($methodName)->getOnlyVariant()->getReturnType(); + } +} diff --git a/src/support/src/Facades/MaintenanceMode.php b/src/support/src/Facades/MaintenanceMode.php index b488c18ec..cc70b1c4e 100644 --- a/src/support/src/Facades/MaintenanceMode.php +++ b/src/support/src/Facades/MaintenanceMode.php @@ -14,6 +14,10 @@ * @method static string getDefaultDriver() * @method static array getDrivers() * @method static \Hypervel\Foundation\MaintenanceModeManager setContainer(\Hypervel\Contracts\Container\Container $container) + * @method static void activate(array $payload) + * @method static bool active() + * @method static array data() + * @method static void deactivate() * * @see \Hypervel\Foundation\MaintenanceModeManager */ diff --git a/tests/PHPStan/Fixtures/Managers/BoundExportManager.php b/tests/PHPStan/Fixtures/Managers/BoundExportManager.php new file mode 100644 index 000000000..84e7319ef --- /dev/null +++ b/tests/PHPStan/Fixtures/Managers/BoundExportManager.php @@ -0,0 +1,29 @@ +> $rows + */ + public function export(array $rows): string + { + return ''; + } + + /** + * Include a header row. + */ + public function withHeader(): static + { + return $this; + } + + /** + * Export into the given buffer. + */ + public function exportInto(string &$buffer): void + { + $buffer .= ''; + } + + /** + * Count the exported rows, which the manager documents differently. + */ + public function count(): string + { + return ''; + } + + /** + * Create a driver. + */ + public static function make(): self + { + return new self; + } + + /** + * Format a row. + */ + protected function format(): string + { + return ''; + } +} diff --git a/tests/PHPStan/Fixtures/Managers/ExportManager.php b/tests/PHPStan/Fixtures/Managers/ExportManager.php new file mode 100644 index 000000000..b270e297c --- /dev/null +++ b/tests/PHPStan/Fixtures/Managers/ExportManager.php @@ -0,0 +1,34 @@ +config->string('exports.default'); + } +} diff --git a/tests/PHPStan/Fixtures/Managers/WrappedCreatorManager.php b/tests/PHPStan/Fixtures/Managers/WrappedCreatorManager.php new file mode 100644 index 000000000..8e233d579 --- /dev/null +++ b/tests/PHPStan/Fixtures/Managers/WrappedCreatorManager.php @@ -0,0 +1,30 @@ +createReflectionProvider()->getClass(ExportManager::class); + + // Manager has no __callStatic(), so even the driver's static method is called on a manager instance. + $this->assertFalse($manager->getMethod('make', new OutOfClassScope)->isStatic()); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Type/data/abstract-manager.php b/tests/PHPStan/Type/data/abstract-manager.php new file mode 100644 index 000000000..20938b6b9 --- /dev/null +++ b/tests/PHPStan/Type/data/abstract-manager.php @@ -0,0 +1,23 @@ +export([])); + assertType('Hypervel\Tests\PHPStan\Fixtures\Managers\CsvDriver', $manager->withHeader()); + + // The manager's @method tag wins over the driver's signature. + assertType('int', $manager->count()); + + // __call() runs in the manager, so it does not reach the driver's protected methods. + assertType('*ERROR*', $manager->format()); + + // The driver receives the argument's value, not the caller's variable. + $buffer = 'rows'; + $manager->exportInto($buffer); + assertType("'rows'", $buffer); +} + +function creatorsAndOverrides( + ExtendedExportManager $extended, + WrappedCreatorManager $wrappedCreator, + WrappingManager $wrapping, + SelectingManager $selecting, +): void { + // A registered creator replaces the creator method for the same driver. + assertType('string', $extended->toJson()); + assertType('*ERROR*', $extended->export([])); + + // An override's declared return types the driver it selects or creates. + assertType('string', $wrappedCreator->toJson()); + assertType('string', $wrapping->toJson()); + assertType('*ERROR*', $wrapping->export([])); + assertType('string', $selecting->toJson()); +} + +function unforwardedCalls( + MacroableExportManager $macroable, + UnconfiguredManager $unconfigured, + ResolvedExportManager $resolved, + RenamingManager $renaming, +): void { + // Macroable's __call() only runs macros. + assertType('*ERROR*', $macroable->export([])); + + // Without a configured default driver, nothing is forwarded. + assertType('*ERROR*', $unconfigured->export([])); + + // Manager::driver() keeps returning the CSV driver it created before the JSON creator was registered. + assertType('*ERROR*', $resolved->toJson()); + + // Analysis never calls an overriding getCreatorMethod(), so it cannot tell which creator runs. + assertType('*ERROR*', $renaming->export([])); +} + +function boundManagers(BoundExportManager $bound): void +{ + // The container binds the manager to a subclass, whose own selection and creator decide the driver. + assertType('string', $bound->toJson()); + assertType('*ERROR*', $bound->export([])); +} + +/** + * @param GenericManager $csv + * @param GenericManager $json + */ +function genericManagers(GenericManager $csv, GenericManager $json): void +{ + // Each specialization has its own declared driver. + assertType('string', $csv->export([])); + assertType('string', $json->toJson()); + assertType('*ERROR*', $json->export([])); +} + +function maintenanceMode(MaintenanceModeManager $manager): void +{ + // Every maintenance driver implements the contract the manager declares as its mixin. + assertType('bool', $manager->active()); + assertType('array', MaintenanceMode::data()); +} From 93b2b659b78c8fdea1353f16a4ca35a0c80b2b14 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:23:25 +0000 Subject: [PATCH 082/303] feat: discover macros and configured guard forwarding Port callable macro reflection with explicit tag precedence, invokable support and Optional macro precedence. Keep interface declarations honest and preserve Request validation exceptions separately from tag-based signatures. Infer Auth macro forwarding from configured guard types, requiring a public callable method across possible guards. Preserve native/macro unions, instance-only manager calls and overridden call handlers. Register fixture macros and remove the obsolete validation-catch suppression. Validation: the combined checkpoint passed formatting, root PHPStan, type fixtures, 969 analyzer tests and the affected Support, Image, Sentinel, Socialite, Hashing, Notifications, JWT, Session, Slack, Reverb and foundation provider runtime checks. --- .../Providers/FoundationServiceProvider.php | 2 +- src/phpstan/src/Methods/Macro.php | 176 +++++++++ .../MacroMethodsClassReflectionExtension.php | 364 ++++++++++++++++++ .../src/Methods/MacroThrowTypeExtension.php | 125 ++++++ src/phpstan/src/Methods/OptionalExtension.php | 4 +- .../src/ReturnTypes/AuthGuardHelper.php | 40 +- .../Fixtures/Macros/InvokableMacro.php | 19 + tests/PHPStan/Fixtures/Macros/MacroHost.php | 15 + .../Fixtures/Macros/MacroableAuthManager.php | 16 + .../Fixtures/Macros/NativeCallGuard.php | 57 +++ ...croMethodsClassReflectionExtensionTest.php | 170 ++++++++ .../MethodsClassReflectionExtensionTest.php | 2 + tests/PHPStan/Type/data/auth-guards.php | 6 + tests/PHPStan/Type/data/macros-php-81.php | 12 + tests/PHPStan/Type/data/macros.php | 165 ++++++++ tests/PHPStan/Type/data/optional-helper.php | 9 + tests/PHPStan/bootstrap.php | 118 ++++++ tests/PHPStan/enum-definition.php | 14 + tests/PHPStan/phpstan-tests.neon | 2 + 19 files changed, 1304 insertions(+), 12 deletions(-) create mode 100644 src/phpstan/src/Methods/Macro.php create mode 100644 src/phpstan/src/Methods/MacroMethodsClassReflectionExtension.php create mode 100644 src/phpstan/src/Methods/MacroThrowTypeExtension.php create mode 100644 tests/PHPStan/Fixtures/Macros/InvokableMacro.php create mode 100644 tests/PHPStan/Fixtures/Macros/MacroHost.php create mode 100644 tests/PHPStan/Fixtures/Macros/MacroableAuthManager.php create mode 100644 tests/PHPStan/Fixtures/Macros/NativeCallGuard.php create mode 100644 tests/PHPStan/Reflection/MacroMethodsClassReflectionExtensionTest.php create mode 100644 tests/PHPStan/Type/data/macros-php-81.php create mode 100644 tests/PHPStan/Type/data/macros.php create mode 100644 tests/PHPStan/bootstrap.php create mode 100644 tests/PHPStan/enum-definition.php diff --git a/src/foundation/src/Providers/FoundationServiceProvider.php b/src/foundation/src/Providers/FoundationServiceProvider.php index c9dc17985..3b99036e1 100644 --- a/src/foundation/src/Providers/FoundationServiceProvider.php +++ b/src/foundation/src/Providers/FoundationServiceProvider.php @@ -338,7 +338,7 @@ public function registerRequestValidation(): void Request::macro('validateWithBag', function (string $errorBag, array $rules, ...$params) { try { return $this->validate($rules, ...$params); - } catch (ValidationException $e) { // @phpstan-ignore catch.neverThrown ($this->validate() is a macro that throws ValidationException) + } catch (ValidationException $e) { $e->errorBag = $errorBag; throw $e; diff --git a/src/phpstan/src/Methods/Macro.php b/src/phpstan/src/Methods/Macro.php new file mode 100644 index 000000000..e220c7cdb --- /dev/null +++ b/src/phpstan/src/Methods/Macro.php @@ -0,0 +1,176 @@ + ValidationException::class, + 'validateWithBag' => ValidationException::class, + ]; + + /** + * Create a macro method reflection. + */ + public function __construct(private ClassReflection $classReflection, private string $methodName, private ClosureType $closureType) + { + } + + /** + * Return the class the macro is registered on. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->classReflection; + } + + /** + * Determine whether the macro is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the macro is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Determine whether the macro is final. + */ + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the macro is internal. + */ + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the macro is called statically. + */ + public function isStatic(): bool + { + return $this->isStatic; + } + + /** + * Set the is static value. + */ + public function setIsStatic(bool $isStatic): void + { + $this->isStatic = $isStatic; + } + + /** + * Return the macro docblock. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Return the macro name. + */ + public function getName(): string + { + return $this->methodName; + } + + /** + * Determine whether the macro is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Return the macro prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Return the macro's variant, built from its callable. + */ + public function getVariants(): array + { + // Hypervel: Macroable passes the macro its argument values, so a by-reference + // parameter never receives the caller's variable. + $parameters = ProxiedParameterReflection::fromTargetParameters($this->closureType->getParameters()); + + return [ + new FunctionVariant(TemplateTypeMap::createEmpty(), null, $parameters, $this->closureType->isVariadic(), $this->closureType->getReturnType()), + ]; + } + + /** + * Return the macro's deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return null; + } + + /** + * Return the exception type a known framework macro throws. + */ + public function getThrowType(): ?Type + { + // Hypervel: the map describes Request's validation macros; other classes may register macros with the same names. + if ($this->classReflection->is(Request::class) && array_key_exists($this->methodName, $this->methodThrowTypeMap)) { + return new ObjectType($this->methodThrowTypeMap[$this->methodName]); + } + + return null; + } + + /** + * Determine whether the macro has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } +} diff --git a/src/phpstan/src/Methods/MacroMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/MacroMethodsClassReflectionExtension.php new file mode 100644 index 000000000..94f2fa096 --- /dev/null +++ b/src/phpstan/src/Methods/MacroMethodsClassReflectionExtension.php @@ -0,0 +1,364 @@ + */ + private array $methods = []; + + /** @var array> */ + private array $traitCache = []; + + /** @var array */ + private array $macroSources = []; + + /** + * Create a macro methods extension. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClosureTypeFactory $closureTypeFactory, + private WithHeadExtension $withHeadExtension, + private AuthGuardHelper $authGuardHelper, + ) { + } + + /** + * Determine whether a macro registered for the class types the method. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + if ($this->findMacro($classReflection, $methodName) === null) { + return false; + } + + // Hypervel: Head's withHead() signature and an @method tag on the class or the macro's host type the + // macro more precisely than its callable, whose declared signature is all the analysis reads, such as + // Request::validate()'s array and the Eloquent builder's $this returns. MacroThrowTypeExtension adds the + // macro's exceptions. A mixin does not count, because the macro runs before a call reaches the mixin. + return ! $this->withHeadExtension->hasMethod($classReflection, $methodName) + && ! $this->hasMethodTag($classReflection, $methodName); + } + + /** + * Get the macro found for the class. + */ + public function getMethod( + ClassReflection $classReflection, + string $methodName, + ): MethodReflection { + // hasMethod() returns true only for a found macro. + /** @var MethodReflection $method */ + $method = $this->findMacro($classReflection, $methodName); + + return $method; + } + + /** + * Get the exception type of the macro registered for the class's method. + */ + public function getThrowType(ClassReflection $classReflection, string $methodName): ?Type + { + return $this->findMacro($classReflection, $methodName)?->getThrowType(); + } + + /** + * Find the macro registered for the class's method. + */ + private function findMacro(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + $cacheKey = $classReflection->getName() . '-' . $methodName; + + if (array_key_exists($cacheKey, $this->methods)) { + return $this->methods[$cacheKey] === false ? null : $this->methods[$cacheKey]; + } + + $methodReflection = $this->forwardsToGuard($classReflection) + ? $this->findGuardMacro($classReflection, $methodName) + : $this->findHostMacro($classReflection, $methodName); + + $this->methods[$cacheKey] = $methodReflection ?? false; + + return $methodReflection; + } + + /** + * Find the macro registered on the classes whose macros apply to the given class. + * + * @throws ReflectionException + * @throws ShouldNotHappenException + * @throws MissingMethodFromReflectionException + */ + private function findHostMacro(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + [$classNames, $macroTraitProperty] = $this->getMacroSources($classReflection); + + $methodReflection = null; + + if ($classNames !== [] && $macroTraitProperty) { + foreach ($classNames as $className) { + $macroClassReflection = $this->reflectionProvider->getClass($className); + + if (! $macroClassReflection->getNativeReflection()->hasProperty($macroTraitProperty)) { + continue; + } + + $refProperty = $macroClassReflection->getNativeReflection()->getProperty($macroTraitProperty); + $macros = $refProperty->getValue(); + + if (! array_key_exists($methodName, $macros)) { + continue; + } + + $macroDefinition = $macros[$methodName]; + + // Hypervel: Macroable also accepts an invokable object, which it calls directly. + if (is_object($macroDefinition) && ! $macroDefinition instanceof Closure) { + $macroDefinition = [$macroDefinition, '__invoke']; + } + + // Hypervel: Macroable dispatches every macro through both __call() and __callStatic() with its + // argument values, so each form is callable both ways (StaticMethodReflection for methods). + if (is_string($macroDefinition)) { + if (str_contains($macroDefinition, '::')) { + $macroDefinition = explode('::', $macroDefinition, 2); + $macroClassName = $macroDefinition[0]; + + if (! $this->reflectionProvider->hasClass($macroClassName) || ! $this->reflectionProvider->getClass($macroClassName)->hasNativeMethod($macroDefinition[1])) { + throw new ShouldNotHappenException('Class ' . $macroClassName . ' does not exist'); + } + + $methodReflection = new StaticMethodReflection( + $this->reflectionProvider->getClass($macroClassName)->getNativeMethod($macroDefinition[1]), + ); + } elseif (is_callable($macroDefinition)) { + $methodReflection = new Macro( + $macroClassReflection, + $methodName, + $this->closureTypeFactory->fromClosureObject(Closure::fromCallable($macroDefinition)), + ); + + $methodReflection->setIsStatic(true); + } else { + throw new ShouldNotHappenException('Function ' . $macroDefinition . ' does not exist'); + } + } elseif (is_array($macroDefinition)) { + if (is_string($macroDefinition[0])) { + $macroClassName = $macroDefinition[0]; + } else { + $macroClassName = $macroDefinition[0]::class; + } + + if (! $this->reflectionProvider->hasClass($macroClassName) || ! $this->reflectionProvider->getClass($macroClassName)->hasNativeMethod($macroDefinition[1])) { + throw new ShouldNotHappenException('Class ' . $macroClassName . ' does not exist'); + } + + $methodReflection = new StaticMethodReflection( + $this->reflectionProvider->getClass($macroClassName)->getNativeMethod($macroDefinition[1]), + ); + } else { + $methodReflection = new Macro( + $macroClassReflection, + $methodName, + $this->closureTypeFactory->fromClosureObject($macroDefinition), + ); + + $methodReflection->setIsStatic(true); + } + + break; + } + } + + return $methodReflection; + } + + /** + * Determine whether the class's calls reach AuthManager's own __call(), which forwards them to the current guard. + */ + private function forwardsToGuard(ClassReflection $classReflection): bool + { + // The Auth facade forwards to its booted root, which getMacroSources() resolves. + if ($classReflection->getName() === Auth::class) { + [$classNames] = $this->getMacroSources($classReflection); + + if ($classNames === []) { + return false; + } + + $classReflection = $this->reflectionProvider->getClass($classNames[0]); + } + + // A subclass's Macroable or custom __call() keeps its own API. + return $classReflection->is(AuthManager::class) + && $classReflection->getNativeMethod('__call')->getDeclaringClass()->getName() === AuthManager::class; + } + + /** + * Find a guard macro that the class forwards to the current guard. + * + * Hypervel: replaces upstream's fixed SessionGuard and RequestGuard hosts, which claimed a macro the current + * guard may lack. shouldUse() can make any configured guard the current one, so each must have a public + * method of that name, natively or as a macro. At least one must supply it as a macro; the guards' other + * methods are not this extension's. + */ + private function findGuardMacro(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + $guardType = $this->authGuardHelper->getDefaultGuardType(); + + if ($guardType === null || ! $guardType->hasMethod($methodName)->yes()) { + return null; + } + + $suppliesMacro = array_any( + $guardType->getObjectClassReflections(), + fn (ClassReflection $guard): bool => $this->findMacro($guard, $methodName) !== null, + ); + + if (! $suppliesMacro) { + return null; + } + + // Each guard's own lookup keeps its signature, including a native method or an @method tag. + $method = $guardType->getMethod($methodName, new OutOfClassScope); + + // AuthManager::__call() runs outside the guard, so it reaches only public methods. + if (! $method->isPublic()) { + return null; + } + + // The facade's call is static, while AuthManager has no __callStatic(), so its own calls are instance calls. + return $classReflection->is(Facade::class) + ? new StaticMethodReflection($method) + : new ProxiedMethodReflection($classReflection, $method, static fn (Type $returnType): Type => $returnType); + } + + /** + * Determine whether the class or the macro's host declares an @method tag for the method. + */ + private function hasMethodTag(ClassReflection $classReflection, string $methodName): bool + { + [$classNames] = $this->getMacroSources($classReflection); + + foreach ([$classReflection->getName(), ...$classNames] as $className) { + foreach ($this->reflectionProvider->getClass($className)->getAncestors() as $ancestor) { + if (array_key_exists($methodName, $ancestor->getMethodTags())) { + return true; + } + } + } + + return false; + } + + /** + * Determine which classes' macro definitions apply to the given class. + * + * This only depends on the class itself, so it is memoized — the trait + * scans and facade root lookups involved are too expensive to repeat + * for every method lookup. + * + * @return array{class-string[], null|string} + */ + private function getMacroSources(ClassReflection $classReflection): array + { + $cacheKey = $classReflection->getName(); + + if (array_key_exists($cacheKey, $this->macroSources)) { + return $this->macroSources[$cacheKey]; + } + + /** @var class-string[] $classNames */ + $classNames = []; + $macroTraitProperty = null; + + // REMOVED: upstream's contract branch, which gives a Hypervel\Contracts interface the macros of its bound + // implementation, as ContractsMethodsExtension gives it the implementation's methods. Another implementation + // need not have them; explicit resolution such as app() or response() is typed from the binding instead. + if ( + $this->hasIndirectTraitUse($classReflection, Macroable::class) + || $classReflection->is(Builder::class) + || $classReflection->is(QueryBuilder::class) + ) { + $classNames = [$classReflection->getName()]; + $macroTraitProperty = 'macros'; + + if ($classReflection->is(Builder::class)) { + $classNames[] = Builder::class; + } + } elseif ($classReflection->is(Facade::class)) { + // REMOVED: upstream's fixed hosts for the Auth and Cache facades. findGuardMacro() handles Auth. The + // Cache facade's root, CacheManager, is not Macroable, and the facade's mixins already reach the + // store Repository's macros. + $facadeClass = $classReflection->getName(); + + $concrete = null; + + try { + $concrete = $facadeClass::getFacadeRoot(); + } catch (Throwable) { + } + + if (is_object($concrete)) { + $classNames = [$concrete::class]; + $macroTraitProperty = 'macros'; + } + } + + return $this->macroSources[$cacheKey] = [$classNames, $macroTraitProperty]; + } + + /** + * Determine whether the class or one of its ancestors uses the trait. + */ + private function hasIndirectTraitUse(ClassReflection $class, string $traitName): bool + { + $className = $class->getName(); + + if (array_key_exists($className, $this->traitCache) && array_key_exists($traitName, $this->traitCache[$className])) { + return $this->traitCache[$className][$traitName]; + } + + $this->traitCache[$className][$traitName] = in_array($traitName, array_keys($class->getTraits(true)), true); + + return $this->traitCache[$className][$traitName]; + } +} diff --git a/src/phpstan/src/Methods/MacroThrowTypeExtension.php b/src/phpstan/src/Methods/MacroThrowTypeExtension.php new file mode 100644 index 000000000..058cd6775 --- /dev/null +++ b/src/phpstan/src/Methods/MacroThrowTypeExtension.php @@ -0,0 +1,125 @@ +isTaggedMacroWithThrowType($methodReflection); + } + + /** + * Get the exception of the macro registered on the call's receiver. + */ + public function getThrowTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + return $this->resolveThrowType( + $methodReflection, + $scope->getType($methodCall->var)->getObjectClassReflections(), + ); + } + + /** + * Determine whether the static method is a tag-typed macro with a known exception. + */ + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return $this->isTaggedMacroWithThrowType($methodReflection); + } + + /** + * Get the exception of the macro registered on the called class, or on a facade's root. + */ + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (! $methodCall->class instanceof Name) { + return $this->resolveThrowType( + $methodReflection, + $scope->getType($methodCall->class)->getObjectClassReflections(), + ); + } + + $className = $scope->resolveName($methodCall->class); + + return $this->resolveThrowType( + $methodReflection, + $this->reflectionProvider->hasClass($className) ? [$this->reflectionProvider->getClass($className)] : [], + ); + } + + /** + * Determine whether an @method tag types the method, its class registers a macro of that name + * with a known exception, and its magic handler declares an exception the extension may replace. + */ + private function isTaggedMacroWithThrowType(MethodReflection $methodReflection): bool + { + $classReflection = $methodReflection->getDeclaringClass(); + $methodName = $methodReflection->getName(); + + return $methodReflection->getThrowType() !== null + && ! $classReflection->hasNativeMethod($methodName) + && array_key_exists($methodName, $classReflection->getMethodTags()) + && $this->macros->getThrowType($classReflection, $methodName) !== null; + } + + /** + * Resolve the exception of the macros registered on the given hosts. + * + * A host without a macro exception keeps the method's own exception. + * + * @param list $hosts + */ + private function resolveThrowType(MethodReflection $methodReflection, array $hosts): Type + { + // isTaggedMacroWithThrowType() requires the method's own exception. + /** @var Type $throwType */ + $throwType = $methodReflection->getThrowType(); + + $types = []; + + foreach ($hosts as $host) { + $types[] = $this->macros->getThrowType($host, $methodReflection->getName()) ?? $throwType; + } + + return $types === [] ? $throwType : TypeCombinator::union(...$types); + } +} diff --git a/src/phpstan/src/Methods/OptionalExtension.php b/src/phpstan/src/Methods/OptionalExtension.php index 9f8fdf160..64283941f 100644 --- a/src/phpstan/src/Methods/OptionalExtension.php +++ b/src/phpstan/src/Methods/OptionalExtension.php @@ -31,8 +31,10 @@ public function hasMethod(ClassReflection $classReflection, string $methodName): { $targetType = $this->getTargetType($classReflection); - // The Optional calls the method from outside the object's class. + // Optional::__call() runs a macro before the held object's method, so the macro extension + // resolves it. The Optional calls the held object's method from outside the object's class. return $targetType !== null + && ! Optional::hasMacro($methodName) && $targetType->hasMethod($methodName)->yes() && $targetType->getMethod($methodName, new OutOfClassScope)->isPublic(); } diff --git a/src/phpstan/src/ReturnTypes/AuthGuardHelper.php b/src/phpstan/src/ReturnTypes/AuthGuardHelper.php index 65cca2979..36272fd6f 100644 --- a/src/phpstan/src/ReturnTypes/AuthGuardHelper.php +++ b/src/phpstan/src/ReturnTypes/AuthGuardHelper.php @@ -58,11 +58,38 @@ public function __construct( * Returns null when the call's declared return already describes the guard. */ public function getGuardType(?Arg $name, Scope $scope, Type $declaredType, bool $acceptsEnums = true): ?Type + { + $guardType = $this->getGuardsType($this->getGuardNames($name, $scope, $acceptsEnums)); + + if ($guardType === null) { + return null; + } + + // A factory or facade declaring a narrower guard keeps its declaration. + return $declaredType->isSuperTypeOf($guardType)->yes() ? $guardType : null; + } + + /** + * Get the type of the current default guard, which shouldUse() can set to any configured guard. + * + * Returns null when a configured guard's type is unknown. + */ + public function getDefaultGuardType(): ?Type + { + return $this->getGuardsType($this->getConfiguredGuardNames()); + } + + /** + * Get the union of the given configured guards' types, or null when one of them is unknown. + * + * @param list $names + */ + private function getGuardsType(array $names): ?Type { $types = []; - foreach ($this->getGuardNames($name, $scope, $acceptsEnums) as $guardName) { - $type = $this->getConfiguredGuardType($guardName); + foreach ($names as $name) { + $type = $this->getConfiguredGuardType($name); if ($type === null) { return null; @@ -71,14 +98,7 @@ public function getGuardType(?Arg $name, Scope $scope, Type $declaredType, bool $types[] = $type; } - if ($types === []) { - return null; - } - - $guardType = TypeCombinator::union(...$types); - - // A factory or facade declaring a narrower guard keeps its declaration. - return $declaredType->isSuperTypeOf($guardType)->yes() ? $guardType : null; + return $types === [] ? null : TypeCombinator::union(...$types); } /** diff --git a/tests/PHPStan/Fixtures/Macros/InvokableMacro.php b/tests/PHPStan/Fixtures/Macros/InvokableMacro.php new file mode 100644 index 000000000..cbfd859f4 --- /dev/null +++ b/tests/PHPStan/Fixtures/Macros/InvokableMacro.php @@ -0,0 +1,19 @@ +reflectionProvider = $this->createReflectionProvider(); + $this->reflectionExtension = new MacroMethodsClassReflectionExtension( + $this->reflectionProvider, + self::getContainer()->getByType(ClosureTypeFactory::class), + self::getContainer()->getByType(WithHeadExtension::class), + self::getContainer()->getByType(AuthGuardHelper::class), + ); + } + + #[Test] + #[DataProvider('methodAndClassProvider')] + public function itCanFindMacrosOnAClass(string $class, string $methodName): void + { + $requestClass = $this->reflectionProvider->getClass($class); + + $this->assertTrue($this->reflectionExtension->hasMethod($requestClass, $methodName)); + } + + public static function methodAndClassProvider(): Generator + { + // Hypervel: Request's @method tags type its validation and signature macros; see taggedMethodProvider(). + yield [Collection::class, 'customCollectionMacro']; + yield [MacroHost::class, 'invokableMacro']; + } + + #[Test] + #[DataProvider('taggedMethodProvider')] + public function itLeavesTaggedMacrosToTheirTags(string $class, string $methodName): void + { + $this->assertFalse($this->reflectionExtension->hasMethod($this->reflectionProvider->getClass($class), $methodName)); + } + + public static function taggedMethodProvider(): Generator + { + yield [Request::class, 'validate']; + yield [Request::class, 'validateWithBag']; + yield [Request::class, 'hasValidSignature']; + yield [Request::class, 'hasValidRelativeSignature']; + } + + #[Test] + #[DataProvider('methodAndThrowTypeProvider')] + public function itCanSetThrowTypeForMacros(string $class, string $methodName, string $exceptionClass): void + { + // Hypervel: the @method tag types Request's macros, so MacroThrowTypeExtension reads their exceptions here. + $throwType = $this->reflectionExtension->getThrowType($this->reflectionProvider->getClass($class), $methodName); + + $this->assertInstanceOf(ObjectType::class, $throwType); + $this->assertSame($exceptionClass, $throwType->getClassName()); + } + + public static function methodAndThrowTypeProvider(): Generator + { + yield [Request::class, 'validate', ValidationException::class]; + yield [Request::class, 'validateWithBag', ValidationException::class]; + } + + #[Test] + public function itOnlyGivesRequestMacrosTheValidationException(): void + { + $this->assertNull($this->reflectionExtension->getThrowType($this->reflectionProvider->getClass(MacroHost::class), 'validate')); + } + + #[Test] + public function itForwardsAGuardMacroThatEveryConfiguredGuardCanCall(): void + { + $extension = $this->createGuardMacroExtension(); + $manager = $this->reflectionProvider->getClass(AuthManager::class); + $facade = $this->reflectionProvider->getClass(Auth::class); + + // RequestGuard registers guardMacro() and NativeCallGuard declares it, so either current guard has it. + $this->assertTrue($extension->hasMethod($manager, 'guardMacro')); + $this->assertTrue($extension->hasMethod($facade, 'guardMacro')); + + $method = $extension->getMethod($manager, 'guardMacro'); + + // AuthManager has no __callStatic(), unlike the facade. + $this->assertFalse($method->isStatic()); + $this->assertTrue($extension->getMethod($facade, 'guardMacro')->isStatic()); + $this->assertSame('int|string', $method->getVariants()[0]->getReturnType()->describe(VerbosityLevel::precise())); + + // NativeCallGuard has no requestGuardMacro(). + $this->assertFalse($extension->hasMethod($manager, 'requestGuardMacro')); + } + + #[Test] + public function itKeepsTheCallHandlerOfAnAuthManagerSubclass(): void + { + $extension = $this->createGuardMacroExtension(); + $manager = $this->reflectionProvider->getClass(MacroableAuthManager::class); + + MacroableAuthManager::macro('ownMacro', static fn (): string => ''); + + $this->assertTrue($extension->hasMethod($manager, 'ownMacro')); + $this->assertFalse($extension->hasMethod($manager, 'guardMacro')); + } + + /** + * Create the extension for an auth manager with a request guard and a custom guard. + */ + private function createGuardMacroExtension(): MacroMethodsClassReflectionExtension + { + $app = new Container; + $app->instance('config', new Repository(['auth' => ['guards' => [ + 'request' => ['driver' => 'request'], + 'native' => ['driver' => 'native'], + ]]])); + + $auth = new AuthManager($app); + $auth->viaRequest('request', static fn () => null); + $auth->extend('native', static fn (): NativeCallGuard => new NativeCallGuard); + $app->instance('auth', $auth); + + $closureTypeFactory = self::getContainer()->getByType(ClosureTypeFactory::class); + $guards = new AuthGuardHelper($this->reflectionProvider, $closureTypeFactory); + $guards->setContainer($app); + + return new MacroMethodsClassReflectionExtension( + $this->reflectionProvider, + $closureTypeFactory, + self::getContainer()->getByType(WithHeadExtension::class), + $guards, + ); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + // Hypervel: the test bootstrap registers the untagged fixture macros. + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Type/MethodsClassReflectionExtensionTest.php b/tests/PHPStan/Type/MethodsClassReflectionExtensionTest.php index 167b53cd1..26888ed9e 100644 --- a/tests/PHPStan/Type/MethodsClassReflectionExtensionTest.php +++ b/tests/PHPStan/Type/MethodsClassReflectionExtensionTest.php @@ -21,7 +21,9 @@ public function testFileAsserts( /** @return iterable */ public static function dataFileAsserts(): iterable { + yield from self::gatherAssertTypes(__DIR__ . '/data/macros.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/redirect-response.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/macros-php-81.php'); } /** @return string[] */ diff --git a/tests/PHPStan/Type/data/auth-guards.php b/tests/PHPStan/Type/data/auth-guards.php index d1d7521bf..df141d0a6 100644 --- a/tests/PHPStan/Type/data/auth-guards.php +++ b/tests/PHPStan/Type/data/auth-guards.php @@ -58,4 +58,10 @@ function test(string $name, ?string $maybeName, array $names): void assertType(Authenticatable::class . '|null', Auth::guard('providerless')->user()); assertType(Authenticatable::class . '|null', Auth::guard()->user()); assertType(Authenticatable::class . '|null', Auth::user()); + + // A guard macro is only callable through AuthManager when every configured guard registers it. + assertType('int', Auth::guardMacro()); + assertType('int', auth()->guardMacro()); + assertType('*ERROR*', Auth::sessionGuardMacro()); + assertType('*ERROR*', auth()->sessionGuardMacro()); } diff --git a/tests/PHPStan/Type/data/macros-php-81.php b/tests/PHPStan/Type/data/macros-php-81.php new file mode 100644 index 000000000..8c2dd34b3 --- /dev/null +++ b/tests/PHPStan/Type/data/macros-php-81.php @@ -0,0 +1,12 @@ +accounts()->globalCustomMacro(b: 99)); + + assertType('string', QueryBuilder::globalCustomDatabaseQueryMacro(b: 99)); + assertType('string', Post::globalCustomDatabaseQueryMacro(b: 99)); + assertType('string', PostBuilder::globalCustomDatabaseQueryMacro(b: 99)); + assertType('string', User::first()->accounts()->globalCustomDatabaseQueryMacro(b: 99)); + + assertType('int', Route::facadeMacro()); + // Hypervel: this app's untyped passport guard leaves the current guard unknown, so no guard macro is + // available through Auth. auth-guards.php covers typed guards. + assertType('*ERROR*', Auth::sessionGuardMacro()); + assertType('*ERROR*', Auth::requestGuardMacro()); + + assertType('string', collect([])->customCollectionMacro()); + assertType('string', Collection::customCollectionMacro()); + + assertType('string', collect([])->customCollectionMacroString()); + assertType('string', Collection::customCollectionMacroString()); + + assertType('string', Str::trimMacro('')); + assertType('string', Str::asciiAliasMacro('')); + + assertType('mixed', Cache::rememberIf(true, 'key', 60, fn () => 'value')); + + assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); + + Request::macro('foo', function () { + assertType(Request::class, $this); + }); + + Foo::macro('foo', function () { + assertType(Foo::class, $this); + }); +} + +class Foo +{ + use Macroable; +} + +// Hypervel: @method tags type Request's macros, and MacroThrowTypeExtension adds the macros' exceptions. +function taggedMacros(Request $request, NativeValidateRequest $nativeRequest, CustomCallHandler $handler): void +{ + assertType('array', $request->validate([])); + assertType('array', $request->validateWithBag('bag', [])); + assertType('array', RequestFacade::validate([])); + assertType('bool', $request->hasValidSignature()); + assertType('bool', $request->hasValidRelativeSignature()); + + try { + $request->validate([]); + } catch (ValidationException $e) { + $instance = 'instance'; + } + + assertVariableCertainty(TrinaryLogic::createMaybe(), $instance); + + try { + RequestFacade::validateWithBag('bag', []); + } catch (ValidationException $e) { + $facade = 'facade'; + } + + assertVariableCertainty(TrinaryLogic::createMaybe(), $facade); + + // A native method overriding a macro keeps its own exceptions. + try { + $nativeRequest->validate([]); + } catch (ValidationException $e) { + $native = 'native'; + } + + assertVariableCertainty(TrinaryLogic::createNo(), $native); + + // A magic handler keeps its declared exception when no macro replaces it. + try { + $handler->handled(); + } catch (CustomCallException $e) { + $handled = 'handled'; + } + + assertVariableCertainty(TrinaryLogic::createMaybe(), $handled); +} + +// Hypervel: Macroable calls invokable macros directly, both ways, and passes every macro its argument values. +function callableMacros(MacroHost $host): void +{ + $value = 1; + + assertType('int', $host->invokableMacro($value)); + assertType('int', MacroHost::invokableMacro($value)); + assertType('1', $value); + + $host->referenceMacro($value); + assertType('1', $value); +} + +class NativeValidateRequest extends Request +{ + /** + * Validate the request natively instead of through the macro. + * + * @throws RuntimeException + */ + public function validate(array $rules): array + { + throw new RuntimeException; + } +} + +/** + * @method int handled() + */ +class CustomCallHandler +{ + /** + * Handle a documented magic method. + * + * @throws CustomCallException + */ + public function __call(string $method, array $parameters): mixed + { + throw new CustomCallException; + } +} + +class CustomCallException extends RuntimeException +{ +} diff --git a/tests/PHPStan/Type/data/optional-helper.php b/tests/PHPStan/Type/data/optional-helper.php index 530ebf6ab..ffba337cc 100644 --- a/tests/PHPStan/Type/data/optional-helper.php +++ b/tests/PHPStan/Type/data/optional-helper.php @@ -28,6 +28,11 @@ private function secret(): string { return ''; } + + public function optionalMacro(): string + { + return ''; + } } function test(?Bar $maybeBar, Bar|string $barOrString, mixed $mixed): void @@ -56,6 +61,10 @@ function test(?Bar $maybeBar, Bar|string $barOrString, mixed $mixed): void $value = 1; optional(new Bar)->fill($value); assertType('1', $value); + + // Hypervel: an Optional macro runs before the held object's method of the same name, whatever the value. + assertType('int', optional(new Bar)->optionalMacro()); + assertType('int', optional(null)->optionalMacro()); } /** diff --git a/tests/PHPStan/bootstrap.php b/tests/PHPStan/bootstrap.php new file mode 100644 index 000000000..8c01c538c --- /dev/null +++ b/tests/PHPStan/bootstrap.php @@ -0,0 +1,118 @@ + 5); +RequestGuard::macro('guardMacro', static fn (): int => 5); + +Str::macro('trimMacro', 'trim'); +Str::macro('asciiAliasMacro', Str::class . '::ascii'); + +Cache::macro('rememberIf', static fn ($cond, $key, $ttl, $callback): mixed => $cond ? Cache::remember($key, $ttl, $callback) : $callback()); + +// Hypervel: shares a held object's method name in the Optional fixture, where the macro must win. +Optional::macro('optionalMacro', static fn (): int => 1); + +// Hypervel: Macroable also accepts invokable objects, and passes every macro its argument values. +MacroHost::macro('invokableMacro', new InvokableMacro); +MacroHost::macro('referenceMacro', static function (int &$value): void { +}); + +// Hypervel: only Request's validation macros throw ValidationException. +MacroHost::macro('validate', static fn (): bool => true); + +// Hypervel: the documented response()->caps() macro, registered on the bound ResponseFactory. +ResponseFactory::macro('caps', static fn (string $value): string => strtoupper($value)); + +// Hypervel: manager creators registered at boot, and a driver created before its creator was replaced. +app()->singleton(ExtendedExportManager::class); +app(ExtendedExportManager::class)->extend('csv', static fn (): JsonDriver => new JsonDriver); + +app()->singleton(WrappedCreatorManager::class); +app(WrappedCreatorManager::class)->extend('csv', static fn (): CsvDriver => new CsvDriver); + +app()->singleton(ResolvedExportManager::class); +app(ResolvedExportManager::class)->driver(); +app(ResolvedExportManager::class)->extend('csv', static fn (): JsonDriver => new JsonDriver); + +app()->singleton(BoundExportManager::class, ChildExportManager::class); + +class CustomCollectionMacro +{ + /** + * Register the collection macros. + */ + public function registerMacro(): void + { + Collection::macro('customCollectionMacro', [$this, 'customMacro']); + Collection::macro('customCollectionMacroString', [self::class, 'customMacroString']); + } + + /** + * Run the object-callable macro. + */ + public function customMacro(): string + { + return 'customMacro'; + } + + /** + * Run the class-string-callable macro. + * + * Static, unlike upstream: Macroable::macro() natively requires a callable, and a + * class-string callable can only name a static method. + */ + public static function customMacroString(): string + { + return 'customMacroString'; + } +} + +(new CustomCollectionMacro)->registerMacro(); + +require_once __DIR__ . '/enum-definition.php'; diff --git a/tests/PHPStan/enum-definition.php b/tests/PHPStan/enum-definition.php new file mode 100644 index 000000000..bbc9721c4 --- /dev/null +++ b/tests/PHPStan/enum-definition.php @@ -0,0 +1,14 @@ + Date: Thu, 5 Nov 2026 17:23:26 +0000 Subject: [PATCH 083/303] feat: type facade and storage calls through their targets Port facade root and Storage method discovery with preserved visibility and target-bound signatures. Replace generated facade method tags with mixins where underlying generic and conditional signatures carry more information. Add the declarations needed for stub validation and applicable facade/fake fixtures. Describe the HTTP factory's synchronous PendingRequest mixin accurately and remove cache-facade suppressions made obsolete by forwarding. Validation: the combined checkpoint passed formatting, root PHPStan, type fixtures, 969 analyzer tests and the affected Support, Image, Sentinel, Socialite, Hashing, Notifications, JWT, Session, Slack, Reverb and foundation provider runtime checks. --- src/http/src/Client/Factory.php | 2 +- .../src/Methods/FacadesMethodsExtension.php | 123 ++++++++++++++++++ ...StorageMethodsClassReflectionExtension.php | 65 +++++++++ .../stubs/common/Cache/CacheManager.stub | 6 + .../stubs/common/Cache/Repository.stub | 6 + .../stubs/common/Config/Repository.stub | 6 + .../common/Contracts/Events/Dispatcher.stub | 6 + .../stubs/common/Cookie/CookieJar.stub | 6 + .../stubs/common/Database/Database.stub | 10 ++ .../stubs/common/Database/Schema/Builder.stub | 6 + src/phpstan/stubs/common/Facades.stub | 75 +++++++++++ .../stubs/common/Http/PendingRequest.stub | 8 ++ src/phpstan/stubs/common/Http/Request.stub | 6 + src/phpstan/stubs/common/Log/Logger.stub | 6 + src/phpstan/stubs/common/Queue/Queue.stub | 7 + .../Support/Testing/Fakes/EventFake.stub | 6 + .../Support/Testing/Fakes/QueueFake.stub | 6 + .../Fixtures/Facades/UndocumentedFacade.php | 21 +++ .../Facades/UndocumentedFacadeRoot.php | 16 +++ tests/PHPStan/Type/data/facade-fakes.php | 24 ++++ tests/PHPStan/Type/data/facades-l12-20.php | 33 +++++ tests/PHPStan/Type/data/facades-l12-41.php | 42 ++++++ tests/PHPStan/Type/data/facades.php | 86 ++++++++++++ tests/PHPStan/application/app/DummyFacade.php | 17 +++ types/Http/Client/PendingRequest.php | 2 +- types/Support/Facades/Cache.php | 8 +- 26 files changed, 593 insertions(+), 6 deletions(-) create mode 100644 src/phpstan/src/Methods/FacadesMethodsExtension.php create mode 100644 src/phpstan/src/Methods/StorageMethodsClassReflectionExtension.php create mode 100644 src/phpstan/stubs/common/Cache/CacheManager.stub create mode 100644 src/phpstan/stubs/common/Cache/Repository.stub create mode 100644 src/phpstan/stubs/common/Config/Repository.stub create mode 100644 src/phpstan/stubs/common/Contracts/Events/Dispatcher.stub create mode 100644 src/phpstan/stubs/common/Cookie/CookieJar.stub create mode 100644 src/phpstan/stubs/common/Database/Database.stub create mode 100644 src/phpstan/stubs/common/Database/Schema/Builder.stub create mode 100644 src/phpstan/stubs/common/Http/PendingRequest.stub create mode 100644 src/phpstan/stubs/common/Http/Request.stub create mode 100644 src/phpstan/stubs/common/Log/Logger.stub create mode 100644 src/phpstan/stubs/common/Queue/Queue.stub create mode 100644 src/phpstan/stubs/common/Support/Testing/Fakes/EventFake.stub create mode 100644 src/phpstan/stubs/common/Support/Testing/Fakes/QueueFake.stub create mode 100644 tests/PHPStan/Fixtures/Facades/UndocumentedFacade.php create mode 100644 tests/PHPStan/Fixtures/Facades/UndocumentedFacadeRoot.php create mode 100644 tests/PHPStan/Type/data/facade-fakes.php create mode 100644 tests/PHPStan/Type/data/facades-l12-20.php create mode 100644 tests/PHPStan/Type/data/facades-l12-41.php create mode 100644 tests/PHPStan/Type/data/facades.php create mode 100644 tests/PHPStan/application/app/DummyFacade.php diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php index 195a5d64a..7b7e96665 100644 --- a/src/http/src/Client/Factory.php +++ b/src/http/src/Client/Factory.php @@ -32,7 +32,7 @@ use Psr\Http\Message\StreamInterface; /** - * @mixin PendingRequest + * @mixin PendingRequest */ class Factory { diff --git a/src/phpstan/src/Methods/FacadesMethodsExtension.php b/src/phpstan/src/Methods/FacadesMethodsExtension.php new file mode 100644 index 000000000..9c491853f --- /dev/null +++ b/src/phpstan/src/Methods/FacadesMethodsExtension.php @@ -0,0 +1,123 @@ + */ + private array $cache = []; + + /** @var array */ + private array $facadeRoots = []; + + /** + * Create a new facade methods extension. + */ + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + /** + * Determine whether the facade's booted root declares the method its PHPDoc omits. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + if (! $classReflection->is(Facade::class)) { + return false; + } + + $key = $classReflection->getName() . '-' . $methodName; + + if (isset($this->cache[$key])) { + return $this->cache[$key] !== false; + } + + $result = RecursionGuard::run($key, function () use ($classReflection, $methodName, $key) { + if (ReflectionHelper::hasMethodTag($classReflection, $methodName)) { + return false; + } + + $facadeClass = $classReflection->getName(); + + $concrete = $this->getFacadeRoot($facadeClass); + + if ($concrete !== null) { + $concreteClass = $concrete::class; + + if ($this->reflectionProvider->hasClass($concreteClass)) { + $concreteReflection = $this->reflectionProvider->getClass($concreteClass); + + // Use hasNativeMethod() instead of hasMethod() to avoid + // re-entering registered MethodsClassReflectionExtensions + // (including this one), which would cause infinite recursion. + if ($concreteReflection->hasNativeMethod($methodName)) { + $this->cache[$key] = new StaticMethodReflection( + $concreteReflection->getNativeMethod($methodName), + ); + + return true; + } + } + } + + return false; + }); + + if ($result === false) { + $this->cache[$key] = false; + } + + return $result ?? false; + } + + /** + * Get the booted root object behind the facade. + * + * @param class-string $facadeClass + */ + private function getFacadeRoot(string $facadeClass): ?object + { + if (array_key_exists($facadeClass, $this->facadeRoots)) { + return $this->facadeRoots[$facadeClass]; + } + + $concrete = null; + + try { + $concrete = $facadeClass::getFacadeRoot(); + } catch (Throwable) { + } + + return $this->facadeRoots[$facadeClass] = is_object($concrete) ? $concrete : null; + } + + /** + * Get the root's method as a static facade method. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + // hasMethod() returns true only after caching the method. + /** @var MethodReflection $method */ + $method = $this->cache[$classReflection->getName() . '-' . $methodName]; + + return $method; + } +} diff --git a/src/phpstan/src/Methods/StorageMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/StorageMethodsClassReflectionExtension.php new file mode 100644 index 000000000..71dcf54fe --- /dev/null +++ b/src/phpstan/src/Methods/StorageMethodsClassReflectionExtension.php @@ -0,0 +1,65 @@ +getName() !== Storage::class) { + return false; + } + + if ($this->reflectionProvider->getClass(FilesystemManager::class)->hasMethod($methodName)) { + return true; + } + + if ($this->reflectionProvider->getClass(FilesystemAdapter::class)->hasMethod($methodName)) { + return true; + } + + return false; + } + + /** + * Get the forwarded method as a static facade method. + */ + public function getMethod( + ClassReflection $classReflection, + string $methodName, + ): MethodReflection { + if ($this->reflectionProvider->getClass(FilesystemManager::class)->hasMethod($methodName)) { + return new StaticMethodReflection( + $this->reflectionProvider->getClass(FilesystemManager::class)->getMethod($methodName, new OutOfClassScope), + ); + } + + return new StaticMethodReflection( + $this->reflectionProvider->getClass(FilesystemAdapter::class)->getMethod($methodName, new OutOfClassScope), + ); + } +} diff --git a/src/phpstan/stubs/common/Cache/CacheManager.stub b/src/phpstan/stubs/common/Cache/CacheManager.stub new file mode 100644 index 000000000..e67c7d7b2 --- /dev/null +++ b/src/phpstan/stubs/common/Cache/CacheManager.stub @@ -0,0 +1,6 @@ + '123', + )); + assertType('123', Cache::rememberForever( + key: 'cache-key', + callback: static fn (): int => 123, + )); +} diff --git a/tests/PHPStan/Type/data/facades-l12-41.php b/tests/PHPStan/Type/data/facades-l12-41.php new file mode 100644 index 000000000..cf83e92e2 --- /dev/null +++ b/tests/PHPStan/Type/data/facades-l12-41.php @@ -0,0 +1,42 @@ +get('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::withHeaders(['X-Foo' => 'bar'])->post('https://example.test')); + + assertType('int', Http::get('https://example.test')->getStatusCode()); + + // Hypervel: async requests return Guzzle's promise. Laravel's LazyPromise serves only the + // omitted pool() and batch() concurrency. + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->get('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->post('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->send('GET', 'https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::timeout(30)->async()->get('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::withHeaders(['X-Foo' => 'bar'])->async()->post('https://example.test')); + + assertType('Hypervel\Contracts\Cache\Repository', Cache::driver()); + assertType('\'123\'', Cache::remember( + key: 'cache-key', + ttl: 60, + callback: static fn (): string => '123', + )); + assertType('123', Cache::rememberForever( + key: 'cache-key', + callback: static fn (): int => 123, + )); +} diff --git a/tests/PHPStan/Type/data/facades.php b/tests/PHPStan/Type/data/facades.php new file mode 100644 index 000000000..dcae39676 --- /dev/null +++ b/tests/PHPStan/Type/data/facades.php @@ -0,0 +1,86 @@ +lrange('some-key', 0, -1)); + assertType('array|Redis|RedisCluster|false', Redis::lrange('some-key', 0, -1)); + assertType('bool|Redis|RedisCluster', Redis::expire('foo', 3)); + + assertType('Hypervel\Database\Query\Builder', DB::query()); + assertType('int', DB::transactionLevel()); + + assertType('null', Queue::createPayloadUsing(function () { + })); + + assertType('Psr\Log\LoggerInterface', Log::getLogger()); + + // Hypervel: disk() declares the filesystem contract, which a custom driver may implement. + assertType('Hypervel\Contracts\Filesystem\Filesystem', Storage::disk()); + assertType('Hypervel\Contracts\Filesystem\Filesystem', Storage::drive()); + // REMOVED: Storage::cloud() cases; Hypervel omits the default-cloud shortcut. + assertType('bool', Storage::disk()->deleteDirectory('foo')); + assertType('bool', Storage::drive()->deleteDirectory('foo')); + assertType('string|false', Storage::putFile('foo', 'foo/bar')); + // Hypervel: a static return forwarded through the facade describes its root, not the facade. + assertType('Hypervel\Filesystem\FilesystemManager', Storage::extend('custom', static fn (): null => null)); + // REMOVED: Redis::client(); pooled Redis requires a held connection, through Redis::withConnection(). + + assertType('string', DummyFacade::foo()); + assertType('int', DummyFacade::bar()); + // Hypervel: a facade without @method annotations takes its root's method from the booted application. + assertType('string', UndocumentedFacade::greet()); + + assertType('Hypervel\Http\Client\Response', Http::get('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::post('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::put('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::patch('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::delete('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::head('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::send('GET', 'https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::timeout(30)->get('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::withHeaders(['X-Foo' => 'bar'])->post('https://example.test')); + + assertType('int', Http::get('https://example.test')->getStatusCode()); + + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->get('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->post('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->send('GET', 'https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::timeout(30)->async()->get('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::withHeaders(['X-Foo' => 'bar'])->async()->post('https://example.test')); + + assertType('Hypervel\Contracts\Cache\Repository', Cache::driver()); + assertType('\'123\'', Cache::remember( + key: 'cache-key', + ttl: 60, + callback: static fn (): string => '123', + )); + assertType('123', Cache::rememberForever( + key: 'cache-key', + callback: static fn (): int => 123, + )); +} diff --git a/tests/PHPStan/application/app/DummyFacade.php b/tests/PHPStan/application/app/DummyFacade.php new file mode 100644 index 000000000..e748b83ab --- /dev/null +++ b/tests/PHPStan/application/app/DummyFacade.php @@ -0,0 +1,17 @@ +withHeaders([])->get('/foo')); - assertType('GuzzleHttp\Promise\PromiseInterface|Hypervel\Http\Client\Response', Http::async()->get('/foo')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->get('/foo')); $request = Http::createPendingRequest(); assertType('Hypervel\Http\Client\Response', $request->send('GET', '/foo')); diff --git a/types/Support/Facades/Cache.php b/types/Support/Facades/Cache.php index 9070de528..602dbb2a3 100644 --- a/types/Support/Facades/Cache.php +++ b/types/Support/Facades/Cache.php @@ -18,15 +18,15 @@ assertType('mixed', Cache::pull('cache', function (): int { return 30; })); -assertType('mixed', Cache::sear('cache', function (): int { +assertType('33', Cache::sear('cache', function (): int { return 33; })); -assertType('mixed', Cache::remember('cache', CarbonImmutable::now(), function (): int { +assertType('36', Cache::remember('cache', CarbonImmutable::now(), function (): int { return 36; })); -assertType('array', Cache::rememberWithWarmth('cache', CarbonImmutable::now(), function (): int { +assertType('array{36, bool}', Cache::rememberWithWarmth('cache', CarbonImmutable::now(), function (): int { return 36; })); -assertType('mixed', Cache::rememberForever('cache', function (): int { +assertType('36', Cache::rememberForever('cache', function (): int { return 36; })); From 3bbb4222a7c1f8ad7615ca0b9c71713734b70b2b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:23:26 +0000 Subject: [PATCH 084/303] feat: infer factory helper types from container bindings Reuse container binding resolution for factory-returning helper branches, including response, view, validator, URL and logger. Preserve ordinary return branches and conditional unions for nullable or unpacked calls. Memoize resolved factories in their extensions without granting implementation methods to arbitrary interface values. Cover concrete factory macros, named arguments and nullable helper inputs. Validation: the combined checkpoint passed formatting, root PHPStan, type fixtures, 969 analyzer tests and the affected Support, Image, Sentinel, Socialite, Hashing, Notifications, JWT, Session, Slack, Reverb and foundation provider runtime checks. --- .../src/Concerns/InfersBoundHelperCalls.php | 47 ++++++++++++++ src/phpstan/src/ReturnTypes/AppMakeHelper.php | 35 ++++++----- .../ReturnTypes/Helpers/LoggerExtension.php | 61 ++++++++++++++++++ .../ReturnTypes/Helpers/ResponseExtension.php | 14 ++++- .../src/ReturnTypes/Helpers/UrlExtension.php | 62 +++++++++++++++++++ .../Helpers/ValidatorExtension.php | 14 ++++- .../src/ReturnTypes/Helpers/ViewExtension.php | 14 ++++- tests/PHPStan/Type/data/helpers.php | 37 +++++++++-- tests/PHPStan/Type/data/view.php | 3 +- types/Foundation/Helpers.php | 15 ++--- 10 files changed, 268 insertions(+), 34 deletions(-) create mode 100644 src/phpstan/src/Concerns/InfersBoundHelperCalls.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php create mode 100644 src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php diff --git a/src/phpstan/src/Concerns/InfersBoundHelperCalls.php b/src/phpstan/src/Concerns/InfersBoundHelperCalls.php new file mode 100644 index 000000000..4466ca2fb --- /dev/null +++ b/src/phpstan/src/Concerns/InfersBoundHelperCalls.php @@ -0,0 +1,47 @@ +getArgs(), + $functionReflection->getVariants(), + )->getReturnType(); + + $contractType = new ObjectType($contract); + + if ($contractType->isSuperTypeOf($returnType)->no()) { + return $returnType; + } + + return TypeCombinator::union(TypeCombinator::remove($returnType, $contractType), $boundType); + } +} diff --git a/src/phpstan/src/ReturnTypes/AppMakeHelper.php b/src/phpstan/src/ReturnTypes/AppMakeHelper.php index ce192ef4b..3a61a4c95 100644 --- a/src/phpstan/src/ReturnTypes/AppMakeHelper.php +++ b/src/phpstan/src/ReturnTypes/AppMakeHelper.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\ClosureType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -57,27 +58,33 @@ public function resolveTypeFromCall(FuncCall|MethodCall|StaticCall $call, Scope $types = []; foreach ($constantStrings as $constantString) { - $resolved = $this->resolve($constantString->getValue()); + $types[] = $this->resolveAbstractType($constantString->getValue()); + } - if (is_object($resolved)) { - $types[] = new ObjectType($resolved::class); + return TypeCombinator::union(...$types); + } - continue; - } + /** + * Resolve the type of the container entry for the given abstract. + */ + public function resolveAbstractType(string $abstract): Type + { + $resolved = $this->resolve($abstract); - // Not every class-string has a reflection; PHPStan reports a missing class itself. - $class = $constantString->getClassStringObjectType()->getObjectClassReflections()[0] ?? null; + if (is_object($resolved)) { + return new ObjectType($resolved::class); + } - // A class that does not resolve to an object keeps its type. Upstream applies this to any class-string, - // but facade aliases make keys such as 'session' class names, so only the class's own name qualifies. - if ($class === null || strcasecmp(ltrim($constantString->getValue(), '\\'), $class->getName()) !== 0) { - return new MixedType; - } + // Not every class-string has a reflection; PHPStan reports a missing class itself. + $class = (new ConstantStringType($abstract))->getClassStringObjectType()->getObjectClassReflections()[0] ?? null; - $types[] = new ObjectType($class->getName()); + // A class that does not resolve to an object keeps its type. Upstream applies this to any class-string, + // but facade aliases make keys such as 'session' class names, so only the class's own name qualifies. + if ($class === null || strcasecmp(ltrim($abstract, '\\'), $class->getName()) !== 0) { + return new MixedType; } - return TypeCombinator::union(...$types); + return new ObjectType($class->getName()); } /** diff --git a/src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php b/src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php new file mode 100644 index 000000000..e12c153b2 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php @@ -0,0 +1,61 @@ +getName() === 'logger'; + } + + /** + * Get the logger for a null message, and otherwise null. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + return $this->getBoundHelperType( + $functionReflection, + $functionCall, + $scope, + LoggerInterface::class, + $this->loggerType ??= $this->appMakeHelper->resolveAbstractType(LoggerInterface::class), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php index 0e4da78bd..436c553ab 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Routing\ResponseFactory; use Hypervel\Http\Response; use Hypervel\PHPStan\Concerns\InfersFactoryHelperCalls; +use Hypervel\PHPStan\ReturnTypes\AppMakeHelper; use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; @@ -21,10 +22,18 @@ final class ResponseExtension implements DynamicFunctionReturnTypeExtension { use InfersFactoryHelperCalls; - private ?ObjectType $responseFactoryType = null; + private ?Type $responseFactoryType = null; private ?ObjectType $responseType = null; + /** + * Create a response() return type extension. + */ + public function __construct( + private readonly AppMakeHelper $appMakeHelper, + ) { + } + /** * Determine whether the function is response(). */ @@ -42,9 +51,10 @@ public function getTypeFromFunctionCall( Scope $scope, ): Type { // Upstream counts arguments, so it types every call with unpacked arguments as a response, though they may be empty. + // Hypervel: the factory is the booted binding, as for app(ResponseFactory::class), rather than the contract. return $this->getFactoryHelperType( $functionCall, - $this->responseFactoryType ??= new ObjectType(ResponseFactory::class), + $this->responseFactoryType ??= $this->appMakeHelper->resolveAbstractType(ResponseFactory::class), $this->responseType ??= new ObjectType(Response::class), ); } diff --git a/src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php b/src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php new file mode 100644 index 000000000..9107bea6e --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php @@ -0,0 +1,62 @@ +getName() === 'url'; + } + + /** + * Get the generator for a null path, and otherwise the URL. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + return $this->getBoundHelperType( + $functionReflection, + $functionCall, + $scope, + UrlGenerator::class, + $this->generatorType ??= $this->appMakeHelper->resolveAbstractType(UrlGenerator::class), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php index 81ee4bb4f..182e00976 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Validation\Factory; use Hypervel\Contracts\Validation\Validator; use Hypervel\PHPStan\Concerns\InfersFactoryHelperCalls; +use Hypervel\PHPStan\ReturnTypes\AppMakeHelper; use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; @@ -21,10 +22,18 @@ final class ValidatorExtension implements DynamicFunctionReturnTypeExtension { use InfersFactoryHelperCalls; - private ?ObjectType $factoryType = null; + private ?Type $factoryType = null; private ?ObjectType $validatorType = null; + /** + * Create a validator() return type extension. + */ + public function __construct( + private readonly AppMakeHelper $appMakeHelper, + ) { + } + /** * Determine whether the function is validator(). */ @@ -44,9 +53,10 @@ public function getTypeFromFunctionCall( // Upstream counts arguments, so it types every call with unpacked arguments as a validator, though they may be empty. // It also claims the concrete validator, but the helper makes one through the bound factory // contract, whose make() promises only the validator contract. + // Hypervel: the factory is the booted binding, as for app(Factory::class), rather than the contract. return $this->getFactoryHelperType( $functionCall, - $this->factoryType ??= new ObjectType(Factory::class), + $this->factoryType ??= $this->appMakeHelper->resolveAbstractType(Factory::class), $this->validatorType ??= new ObjectType(Validator::class), ); } diff --git a/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php index 16b32872b..c0d085673 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\View\Factory; use Hypervel\Contracts\View\View; use Hypervel\PHPStan\Concerns\InfersFactoryHelperCalls; +use Hypervel\PHPStan\ReturnTypes\AppMakeHelper; use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; @@ -26,10 +27,18 @@ final class ViewExtension implements DynamicFunctionReturnTypeExtension { use InfersFactoryHelperCalls; - private ?ObjectType $factoryType = null; + private ?Type $factoryType = null; private ?ObjectType $viewType = null; + /** + * Create a view() return type extension. + */ + public function __construct( + private readonly AppMakeHelper $appMakeHelper, + ) { + } + /** * Determine whether the function is view(). */ @@ -46,9 +55,10 @@ public function getTypeFromFunctionCall( FuncCall $functionCall, Scope $scope, ): Type { + // The factory is the booted binding, as for app(Factory::class), rather than the contract. return $this->getFactoryHelperType( $functionCall, - $this->factoryType ??= new ObjectType(Factory::class), + $this->factoryType ??= $this->appMakeHelper->resolveAbstractType(Factory::class), $this->viewType ??= new ObjectType(View::class), ); } diff --git a/tests/PHPStan/Type/data/helpers.php b/tests/PHPStan/Type/data/helpers.php index 6ef0651c9..d22efcda3 100644 --- a/tests/PHPStan/Type/data/helpers.php +++ b/tests/PHPStan/Type/data/helpers.php @@ -4,6 +4,7 @@ use App\User; use Exception; +use Hypervel\Contracts\Routing\ResponseFactory; use Hypervel\PHPStan\ApplicationResolver; use Hypervel\Support\Str; use Throwable; @@ -96,7 +97,8 @@ function test(?int $value = 0, int|\Closure $intOrClosure = 0, int|\Closure $int }, 'failed', false)); assertType('Hypervel\Http\Response', response('foo')); - assertType('Hypervel\Contracts\Routing\ResponseFactory', response()); + // Hypervel: the factory helpers return the booted binding, as app() does, rather than its contract. + assertType('Hypervel\Routing\ResponseFactory', response()); assertType('null', retry(3, function () { })); @@ -136,9 +138,11 @@ function test(?int $value = 0, int|\Closure $intOrClosure = 0, int|\Closure $int assertType('Hypervel\Contracts\Validation\Validator', tap(validator([], []))->after(fn () => null)); assertType('string', url('/path')); - assertType('Hypervel\Contracts\Routing\UrlGenerator', url()); + // Hypervel: the booted binding, as for response(). + assertType('Hypervel\Routing\UrlGenerator', url()); - assertType('Hypervel\Contracts\Validation\Factory', validator()); + // Hypervel: the booted binding, as for response(). + assertType('Hypervel\Validation\Factory', validator()); assertType('Hypervel\Contracts\Validation\Validator', validator(['foo' => 'bar'], ['foo' => 'required'])); assertType('array', validator(['foo' => 'bar'], ['foo' => 'required'])->validated()); @@ -227,10 +231,31 @@ function testHypervelArguments(array $closures, array $arguments): void assertType('Hypervel\Support\Collection', collect(...[[1, 2]])); assertType('mixed', literal(...$arguments)); // An unpacked list may be empty, so these may return the factory or the result. - assertType('Hypervel\Contracts\Routing\ResponseFactory|Hypervel\Http\Response', response(...$arguments)); + assertType('Hypervel\Http\Response|Hypervel\Routing\ResponseFactory', response(...$arguments)); assertType('mixed', str(...$arguments)); - assertType('Hypervel\Contracts\Validation\Factory|Hypervel\Contracts\Validation\Validator', validator(...$arguments)); - assertType('Hypervel\Contracts\View\Factory|Hypervel\Contracts\View\View', view(...$arguments)); + assertType('Hypervel\Contracts\Validation\Validator|Hypervel\Validation\Factory', validator(...$arguments)); + assertType('Hypervel\Contracts\View\View|Hypervel\View\Factory', view(...$arguments)); +} + +/** + * The factory helpers resolve their factory from the container, so it is the booted binding. + */ +function testBoundHelpers(ResponseFactory $factory, ?string $path, ?string $message, array $arguments): void +{ + assertType('string', response()->caps('foo')); + // A contract value does not gain the macros of its current binding. + assertType('*ERROR*', $factory->caps('foo')); + + assertType('string', url()->full()); + assertType('Hypervel\Routing\UrlGenerator', url(null)); + assertType('Hypervel\Routing\UrlGenerator|string', url($path)); + assertType('Hypervel\Routing\UrlGenerator', url(parameters: ['foo' => 'bar'])); + assertType('Hypervel\Routing\UrlGenerator|string', url(...$arguments)); + + assertType('Hypervel\Log\LogManager', logger()); + assertType('Hypervel\Log\LogManager', logger(null)); + assertType('null', logger('message')); + assertType('Hypervel\Log\LogManager|null', logger($message)); } /** diff --git a/tests/PHPStan/Type/data/view.php b/tests/PHPStan/Type/data/view.php index 3a4ff0603..2e6771fd8 100644 --- a/tests/PHPStan/Type/data/view.php +++ b/tests/PHPStan/Type/data/view.php @@ -10,7 +10,8 @@ function test(View $view, CustomView $customView): void { // Upstream narrows view() to the concrete view; Hypervel's helper returns the contract. - assertType('Hypervel\Contracts\View\Factory', view()); + // Its factory is the booted binding, as for app(Factory::class). + assertType('Hypervel\View\Factory', view()); assertType('Hypervel\Contracts\View\View', view('foo')); assertType('Hypervel\Contracts\View\View', view('foo')->with('bar', 'baz')); // The contract does not promise the view's dynamic with*() methods. diff --git a/types/Foundation/Helpers.php b/types/Foundation/Helpers.php index bf30a02c5..5da680e7d 100644 --- a/types/Foundation/Helpers.php +++ b/types/Foundation/Helpers.php @@ -40,7 +40,8 @@ assertType('Hypervel\Foundation\Bus\PendingDispatch', dispatch('foo')); assertType('Hypervel\Foundation\Bus\PendingClosureDispatch', dispatch(fn () => 1)); -assertType('Psr\Log\LoggerInterface', logger()); +// Helpers that resolve a contract from the container return its booted binding, as app() does. +assertType('Hypervel\Log\LogManager', logger()); assertType('null', logger('foo')); assertType('Hypervel\Log\LogManager', logs()); @@ -61,7 +62,7 @@ assertType('mixed', request('foo')); assertType('array', request(['foo', 'bar'])); -assertType('Hypervel\Contracts\Routing\ResponseFactory', response()); +assertType('Hypervel\Routing\ResponseFactory', response()); assertType('Hypervel\Http\Response', response('foo')); assertType('Hypervel\Http\Response', response(null)); assertType('Hypervel\Http\Response', response(status: 204)); @@ -73,17 +74,17 @@ assertType('Hypervel\Translation\Translator', trans()); assertType('(array|string)', trans('foo')); -assertType('Hypervel\Contracts\Validation\Factory', validator()); +assertType('Hypervel\Validation\Factory', validator()); assertType('Hypervel\Contracts\Validation\Validator', validator([])); assertType('Hypervel\Contracts\Validation\Validator', validator(null)); assertType('Hypervel\Contracts\Validation\Validator', validator(rules: ['name' => 'required'])); -assertType('Hypervel\Contracts\View\Factory', view()); +assertType('Hypervel\View\Factory', view()); assertType('Hypervel\Contracts\View\View', view('welcome')); assertType('Hypervel\Contracts\View\View', view(null)); assertType('Hypervel\Contracts\View\View', view(data: [])); -assertType('Hypervel\Contracts\Routing\UrlGenerator', url()); +assertType('Hypervel\Routing\UrlGenerator', url()); assertType('string', url('foo')); /** @@ -96,7 +97,7 @@ function testFactoryHelperArguments(array $responseArguments, array $responseOptions, array $validationArguments): void { assertType('Hypervel\Http\Response', response('body', ...$responseOptions)); - assertType('Hypervel\Contracts\Routing\ResponseFactory|Hypervel\Http\Response', response(...$responseArguments)); + assertType('Hypervel\Http\Response|Hypervel\Routing\ResponseFactory', response(...$responseArguments)); assertType('Hypervel\Contracts\Validation\Validator', validator([], ...$validationArguments)); - assertType('Hypervel\Contracts\Validation\Factory|Hypervel\Contracts\Validation\Validator', validator(...$validationArguments)); + assertType('Hypervel\Contracts\Validation\Validator|Hypervel\Validation\Factory', validator(...$validationArguments)); } From 51e50d9e8e9ae7887c6f57a0d5359ff5abe8132b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:23:26 +0000 Subject: [PATCH 085/303] chore: remove suppressions covered by macro and facade analysis Remove obsolete undefined-method suppressions from concurrency dispatch, query embedding conversion and context registration. The ported macro and facade extensions now describe those calls. Preserve the runtime behavior and existing contract boundaries. Validation: the combined checkpoint passed formatting, root PHPStan, type fixtures, 969 analyzer tests and the affected Support, Image, Sentinel, Socialite, Hashing, Notifications, JWT, Session, Slack, Reverb and foundation provider runtime checks. --- src/concurrency/src/ProcessDriver.php | 2 -- src/database/src/Query/Builder.php | 8 ++++---- src/log/src/Context/ContextServiceProvider.php | 2 -- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/concurrency/src/ProcessDriver.php b/src/concurrency/src/ProcessDriver.php index 7d8c5b4b9..07eeb4cdf 100644 --- a/src/concurrency/src/ProcessDriver.php +++ b/src/concurrency/src/ProcessDriver.php @@ -41,7 +41,6 @@ public function run(Closure|array $tasks, CarbonInterval|int|null $timeout = nul $results = $this->processFactory->pool(function (Pool $pool) use ($tasks, $command, $timeout) { foreach (Arr::wrap($tasks) as $key => $task) { $process = $pool->as((string) $key)->path(base_path())->env([ - /* @phpstan-ignore staticMethod.notFound */ '__HYPERVEL_CONTEXT' => base64_encode(serialize(Context::dehydrate())), 'HYPERVEL_INVOKABLE_CLOSURE' => base64_encode( serialize(new SerializableClosure($task)) @@ -73,7 +72,6 @@ public function defer(Closure|array $tasks): DeferredCallback return defer(function () use ($tasks, $command) { foreach (Arr::wrap($tasks) as $task) { $this->processFactory->path(base_path())->env([ - /* @phpstan-ignore staticMethod.notFound */ '__HYPERVEL_CONTEXT' => base64_encode(serialize(Context::dehydrate())), 'HYPERVEL_INVOKABLE_CLOSURE' => base64_encode( serialize(new SerializableClosure($task)) diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 44c6f58cd..0976ad72f 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -508,7 +508,7 @@ public function selectVectorDistance(ExpressionContract|string $column, Collecti $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); } $this->addBinding( @@ -1097,7 +1097,7 @@ public function whereVectorSimilarTo(ExpressionContract|string $column, Collecti $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); } $this->whereVectorDistanceLessThan($column, $vector, 1 - $minSimilarity); @@ -1121,7 +1121,7 @@ public function whereVectorDistanceLessThan(ExpressionContract|string $column, C $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); } return $this->whereRaw( @@ -2636,7 +2636,7 @@ public function orderByVectorDistance(ExpressionContract|string $column, Collect $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); } $this->addBinding( diff --git a/src/log/src/Context/ContextServiceProvider.php b/src/log/src/Context/ContextServiceProvider.php index e1066d6de..8117c5b30 100644 --- a/src/log/src/Context/ContextServiceProvider.php +++ b/src/log/src/Context/ContextServiceProvider.php @@ -37,7 +37,6 @@ public function boot(): void return []; } - /** @phpstan-ignore staticMethod.notFound */ $context = Context::dehydrate(); // IMPORTANT: Uses Laravel's payload key for cross-framework queue interoperability. @@ -51,7 +50,6 @@ public function boot(): void $context = $event->job->payload()['illuminate:log:context'] ?? null; if ($context !== null || Repository::hasInstance() || $events->hasListeners(ContextHydrated::class)) { - /* @phpstan-ignore staticMethod.notFound */ Context::hydrate($context); } }); From 091ffd2e6917a8b15a551ac05e8b814045b10672 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:23:26 +0000 Subject: [PATCH 086/303] docs: record macro forwarding behavior and remaining analyzer work Document bound factory types, declared manager-driver inference and the interface boundary. Record the final macro, guard, generic-cache and manager dispatch design and remove completed port entries from the remaining checklist. Retain the shared result-cache obligations, including stub-directory membership and unchanged warm-run reuse. Register the completed manager and facade type fixtures in the general inference suite. Validation: the combined checkpoint passed formatting, root PHPStan, type fixtures, 969 analyzer tests and the affected Support, Image, Sentinel, Socialite, Hashing, Notifications, JWT, Session, Slack, Reverb and foundation provider runtime checks. --- .../2026-11-04-hypervel-phpstan-checklist.md | 18 ++++++--------- docs/plans/2026-11-04-hypervel-phpstan.md | 23 ++++++++++++++++++- src/docs/static-analysis.md | 4 +++- src/phpstan/README.md | 2 +- tests/PHPStan/Type/GeneralTypeTest.php | 7 ++++++ 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index d38b7be17..7b3bfdba9 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -23,12 +23,10 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `ClosureTypes/RelationshipQueryCallbackExtension.php` - copy with the queue rules: `Concerns/InspectsQueuedJobs.php` - omit: `Internal/ComposerHelper.php` (`Support\Composer::vendorPath()` resolves the actual install) -- copy: `Methods/{BuilderHelper,FacadesMethodsExtension,Macro,MacroMethodsClassReflectionExtension,ManagersMethodsExtension,ModelFactoryMethodsClassReflectionExtension,StorageMethodsClassReflectionExtension}.php` - - `Optional::__call()` runs a macro before the held object's method, so a macro sharing a held object's method name must take precedence over `OptionalExtension` (by extension order, or by `OptionalExtension` skipping macro names). Cover it in the Optional fixture. +- copy: `Methods/{BuilderHelper,ModelFactoryMethodsClassReflectionExtension}.php` - merge into moved forwarding extensions: `Methods/{EloquentBuilderForwardsCallsExtension,ModelForwardsCallsExtension,RelationForwardsCallsExtension}.php` -- omit: `Methods/ContractsMethodsExtension.php` (interfaces stay honest; README and `REMOVED:` recorded, so add its source comment to `ManagersMethodsExtension`'s class docblock, the sibling container-forwarding extension, when that ports) - copy: `Properties/*` except the ported `HigherOrderCollectionProxyPropertyExtension` (16 files, including `Schema/MySqlDataTypeToPhpTypeConverter.php`) -- copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection,ReflectionHelper,StaticMethodReflection}.php` +- copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection}.php` - merge into moved scope reflections: `Reflection/ModelScopeMethodReflection.php` - copy: `ReturnTypes/{AppFacadeEnvironmentReturnTypeExtension,EloquentBuilderExtension,FactoryDynamicMethodReturnTypeExtension,FormRequestSafeDynamicMethodReturnTypeExtension,ModelDynamicStaticMethodReturnTypeExtension,ModelFactoryDynamicStaticMethodReturnTypeExtension,ModelOnlyDynamicMethodReturnTypeExtension,ModelSerializationDynamicMethodReturnTypeExtension,RelationCollectionExtension,RequestFileExtension,RequestRouteExtension,StorageDynamicStaticMethodReturnTypeExtension,TestCaseExtension}.php` - copy with the Eloquent collection port: `ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php`, moving `common/EloquentCollection.stub`'s `map()`/`mapWithKeys()` conditionals into the Eloquent `Collection` source @@ -47,20 +45,19 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## stubs - copy: `common/*` except below, the ported view stubs, `Foundation/Console/Kernel.stub` (its `command()` closure type moved to source; the stub keeps only the empty declaration the `Artisan` mixin needs for stub validation) and the stubs whose types are now in source: `Collection` (except below), `Conditionable`, `Enumerable`, `EnumeratesValues`, `HigherOrderProxies` and `Support/{Arr,Benchmark,Reflector,Str,Tappable}` (60 files) -- merge into `common/Facades.stub` and `common/Helpers.stub` with their topics: the remaining facade entries, and the `cookie()`, `logger()`, `logs()`, `redirect()`, `request()`, `session()` and `url()` helper entries, whose conditional returns the source already declares (only Artisan, View, Route and `view()` are ported; `optional()` is typed in source and `OptionalExtension`; the stub records the omitted helpers) -- omit: `common/Config/Repository.stub` (only upstream's omitted `config()` helper stub references it) +- merge into `common/Helpers.stub` with their topics: the `cookie()`, `logger()`, `logs()`, `redirect()`, `request()`, `session()` and `url()` helper entries, whose conditional returns the source already declares (only `view()` is ported; `optional()` is typed in source and `OptionalExtension`; the stub records the omitted helpers) +- merge with their topic flows: the upstream typing for the declaration-only stubs that `common/Facades.stub`'s mixins need for stub validation: Events `dispatch()`/`until()` conditionals, `Connection::getQueryLog()` shape, Schema `getColumnListing()`, the `Request::header()` conditional, and the Cache `Repository` `ArrayAccess` implements (check Hypervel's enum keys) - add to source with the routing stubs: upstream `common/Route.stub`'s `methods()` and `middleware()` element types as `Route` PHPDoc in `src/routing`, with a `types/` fixture (the stub keeps only the empty declaration stub validation needs) - omit: `common/Redis/{PredisConnection,PredisFacade}.stub`, `common/Gate.stub` (Hypervel's `Gate::forUser()` and its contract already return `static`) - add to source: upstream `common/MailMessage.stub`'s property shapes (`$view`, `$from`, `$replyTo`, `$attachments`, `$rawAttachments`) as `MailMessage` PHPDoc in `src/notifications`, with a `types/` fixture - omit: `common/Contracts/Translation.stub` (only upstream's omitted `trans()` helper stub references it) -- merge into `common/`: `12.41.0/Http/PendingRequest.stub`, `13.3.0/{EloquentBuilder,Model}.stub`, `13.35.0/{BelongsToMany,HasOneOrMany}.stub`, `13.37.0/HasOneOrManyThrough.stub` +- merge into `common/`: `13.3.0/{EloquentBuilder,Model}.stub`, `13.35.0/{BelongsToMany,HasOneOrMany}.stub`, `13.37.0/HasOneOrManyThrough.stub` ## tests -- copy: `bootstrap.php`, `enum-definition.php`, then add the test bootstrap to `tests/PHPStan/phpstan-tests.neon`; omit `laravel-test.sh` +- omit: `laravel-test.sh` - copy: the remaining `application/` fixture app (app, config, factories, migrations, schema dump) - merge into `Integration/IntegrationTest.php`: the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties -- copy: `Reflection/MacroMethodsClassReflectionExtensionTest.php` - copy: `Rules/*Test.php` except below and the ported `ArrKeyExistence`, auth, config, console, environment, enumerable `toArray()`, view, translation and useless-construct rule tests, the remaining `Rules/data/*`, `Rules/Queue/data/*` - omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` - copy: `Type/*Test.php` except below, the ported collection and config tests, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php`, with the remaining `Type/data/*` @@ -68,7 +65,6 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - merge into `Type/CollectionDynamicReturnTypeExtensionsTest.php` with the Eloquent collection port, switching it to `config-with-migrations.neon` (they need model properties and the custom collections): `collection-generic-static-methods{,-l12}.php`, `eloquent-collection-map.php` - merge into `Type/CollectionDynamicReturnTypeExtensionTest.php` with the model fixtures: `collection-filter.php`, `collection-reject.php`, `collection-where.php` (they need `accounts()->active()->get()` to return `AccountCollection`) - omit: `Type/data/collection-generic-static-methods-l11.php` (Laravel 11's Eloquent `partition()` returned `static`; Hypervel follows the Laravel 12 base collection) -- merge into `Type/MethodsClassReflectionExtensionTest.php` with the macro port: `macros.php` and `macros-php-81.php` (unconditional; Hypervel requires PHP 8.4) - merge: the remaining `Type/GeneralTypeTest.php` data entries, and its `data/config-with-migrations.neon` configuration once migrations are ported; `higher-order-collection-proxy-methods.php` needs model properties and Hypervel's proxy results (`avg` is `float|int|null`, `max`/`min` include `null`) -- merge: `Type/{RedisDriverNullTypeTest,RedisDriverPhpRedisTypeTest}.php` and their data into one phpredis test; omit `Type/RedisDriverPredisTypeTest.php` with `redis-driver-predis.*` +- merge: `Type/{RedisDriverNullTypeTest,RedisDriverPhpRedisTypeTest}.php` and their data into one phpredis test, with the Redis stubs; recheck `Type/data/facades.php`'s Redis expectations, which reflect the current source types; omit `Type/RedisDriverPredisTypeTest.php` with `redis-driver-predis.*` - copy: `Unit/*Test.php` except `Support/{BootstrapErrorHandlerTest,RecursiveDirectoryIteratorTest}.php` (11 classes, including `SQL/` and `Support/`), `Unit/data/*` diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index d04f8e874..d5e6b7e7a 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -20,6 +20,7 @@ Remaining work: - The approved audit source, documentation and AGENTS.md changes are applied; the warm-run reuse check lands with the shared cache work. - Also completed: the helpers and collections flow (higher-order proxies, `Arr` types and `ArrKeyExistenceRule`, collection narrowing, helper extensions, the enumerable `toArray()` and useless-construct rules, collection stub types moved to source), with its bleeding-edge type and integration tests except the model-dependent fixtures listed in the checklist. - Also completed: Components' stale-ignore audit, with its `reportUnmatchedIgnoredErrors: false` override removed. The two private packages' overrides go with their analyzer adoption (see Consumer migration), because their ignores must be audited against this analyzer. +- Also completed: the macro and forwarding flow (Macros; facade, storage and manager forwarding; factory-helper bindings), with the `Support\Manager::getCreatorMethod()` runtime fix and its regression tests. - Next: port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. @@ -199,7 +200,17 @@ Remove each old `src/{database,foundation,head,scout}/extension.neon`, old `extr - Boot once per PHPStan process, not per file or model. Parallel analysis boots independently in worker processes. Do not start HTTP/queue workers, run migrations, or probe backend services merely for analysis. Provider code can itself perform I/O; do not hide real boot failures or claim boot is free of side effects. - Retain `BootstrapErrorHandler`'s clear CLI failure and nonzero exit. Adapt optional renderer integrations to supported Hypervel packages; do not add Whoops solely to preserve an unsupported Laravel integration. Do not continue with an empty container after a failed boot. Every failure gets one neutral title and tip list with the real exception and trace (upstream's `/vendor/` substring classification misreports custom vendor directories and path repositories), and exception text is escaped as literal console text. `bootstrap.php` also fails through the handler when there is no `bootstrap/app.php` and Testbench is absent (install message), or when `bootstrap/app.php` returns a non-Application; the consumer-installation fixture covers the missing-Testbench case. - Document the actual CLI boot requirements: Swoole (not OpenSwoole), `swoole.use_shortname=Off`, and Swoole built with OpenSSL and native cURL support. `Application::checkEnvironment()` already throws specific corrective messages. Preserve those messages through `BootstrapErrorHandler` and test that they remain visible; do not duplicate the checks or add exception-message matching to manufacture another tip. -- Preserve runtime macro/reflection discovery, facade resolution, class-string/string-key container returns, configured authentication user types and console argument/option definitions. Keep interfaces honest: an arbitrary interface variable does not acquire implementation-only methods merely because the container currently binds one implementation. For manager forwarding, prefer a truthful source `@mixin` where a manager forwards a shared driver API (as `MaintenanceModeManager`'s drivers implement `MaintenanceMode`); decide upstream's booted-default-driver fallback in that slice after checking supported custom managers. +- Preserve runtime macro/reflection discovery, facade resolution, class-string/string-key container returns, configured authentication user types and console argument/option definitions. Keep interfaces honest: an arbitrary interface variable does not acquire implementation-only methods or macros merely because the container currently binds one implementation, so upstream's interface-macro branch is omitted. Explicit resolution is typed from the booted binding instead: container calls (below) and the factory helpers, reusing `AppMakeHelper`'s per-abstract resolution (memoized per extension). `response()`, `view()` and `validator()` return their factory only without arguments; `url()` and `logger()` for a null path/message, including explicit null; maybe-null and unpacked arguments give unions. So `response()->caps()` reaches a `ResponseFactory` macro and the documented `url()->full()` resolves, while a `ResponseFactory` parameter does not gain `caps()`. +- **Manager forwarding.** `Manager::__call()` forwards to the default driver, and custom managers rely on it without a mixin, so port `ManagersMethodsExtension` as a boot-profile fallback like explicit resolution. Native methods, `@method` tags and mixins (`ReflectionHelper::hasMethodTag()`) win. Otherwise the declared type of the booted manager's default driver, resolved once per manager specialization (misses cached), supplies its public methods through `ProxiedMethodReflection`: instance-only, because `Manager` has no `__callStatic()`; by-value parameters; driver-bound `static`. The driver is never built, because creators can need secrets, extensions or connections (building `JwtManager`'s driver without a JWT secret aborted the whole run). The checks below read the class the container binds the manager to, because a bound subclass does the forwarding; the requested (possibly specialized) reflection is used when it is that class or the manager does not resolve. The type follows `Manager::driver()`, using declared returns and never interpreting method bodies: + - a `__call()` not declared by `Manager` (Macroable, as in `ChannelManager`, or custom) forwards nothing; + - an overriding `driver()` (`SocialiteManager`) gives its declared return; + - otherwise `getDefaultDriver()` names the driver; null, or an `InvalidArgumentException` (no default, or missing config through `Repository::string()`), forwards nothing; `''` continues as in `driver()`; + - an overriding `createDriver()` (`ImageManager`) gives its declared return; + - a registered custom creator gives the overriding `callCustomCreator()`'s declared return (`SessionManager`), else the closure's own; an untyped creator never falls through to the named method; + - otherwise the method named by `Manager::getCreatorMethod()` gives its declared return, called on the booted manager unless overridden (then nothing is forwarded); + - an already-created default driver that the type rejects (`extend()` after resolution, which `driver()` ignores) forwards nothing; a compatible one never refines the declared type, so results don't depend on what boot happened to resolve. + + Upstream's driver construction, `StaticMethodReflection` wrap, non-public methods and per-lookup resolution are inherited defects. A name that studly-cases to nothing (`''`, `'-'`, whitespace) built the creator name `createDriver` and recursed into `createDriver()` without arguments (`ArgumentCountError`, as in Laravel); `Manager::getCreatorMethod()` excludes it and is shared by `createDriver()`, `ImageManager`'s pre-check and `SentinelManager::driverOrFallback()`, which now falls back for such names as documented. `MaintenanceModeManager` declares `@mixin MaintenanceMode` (all its drivers implement it). The docs state that forwarded calls are typed from the default driver the analysed environment selects. - **Container typing.** Boot registers facade aliases eagerly (`RegisterFacades`, unchanged), and PHP class names are case-insensitive, so keys such as `'config'` and `'request'` become class-strings that the declared `class-string` returns would type as facades. `AppMakeHelper` types constant keys from the booted container for `make`/`makeWith`/`makeTransient`/`get`/`resolve`/`factory` on the container contract (which also covers the Application contract, so upstream's `ApplicationMakeDynamicReturnTypeExtension` is omitted), the same `App` facade methods except `resolve`, and `app()`/`resolve()`. `factory()` wraps the result as `Closure(): T`. A non-constant or null abstract returns null, so the declared return applies (generic class-strings keep `TClass`; `app()`/`app(null)` stay `Application`). When resolution fails or returns a non-object, a key keeps its class type only when it names that class exactly (ignoring a leading `\`); otherwise, including unknown keys, unknown class literals, scalar bindings and alias keys, the result is `mixed`. An unpacked first argument is `mixed`: PHPStan reorders named arguments for extensions but does not expand unpacked ones. Explicit resolution is typed from the booted binding, so a driver the analysed environment selects (the default queue or database connection, for example) decides the type; the docs state this boundary. Do not replace it with declared or alias-ranked types, which would lose an application's own bound implementations. The source fixes this exposed keep public APIs except one recorded Laravel difference: `CacheTokenRepository` accepts the cache contract that `CacheManager::store()` returns (auth README). - Before changing AOP behavior, add a focused app/package subprocess fixture with an aspect, a class-map replacement, a provider registering a macro, and parallel PHPStan workers. Check original source diagnostics, inferred signatures and registration behavior. `GenerateProxies` seals registries and exposes `proxySources()`; `ProxyManager` reuses fingerprints and writes atomically. Parallel writes alone do not justify bypassing it. Preserve ordinary boot unless the fixture proves a conflict. Resolve a proven source-lookup conflict using the existing original-source mapping and PHPStan's supported facilities; no blanket disabling of aspects, custom AST rewriting, or speculative alternative class loader. If a supported API cannot preserve both semantics, bring the concrete failure and simplest alternatives to the owner before a capability change. @@ -232,6 +243,15 @@ Port every applicable upstream inference feature, not just the rules in the next Do not copy `ContractsMethodsExtension`, container ArrayAccess/dynamic-property support, Lumen paths, deferred-provider machinery, or unsupported Laravel integrations. Remove compatibility branches for old Laravel releases, but first merge applicable newer-version stub refinements into the single supported Hypervel surface. Deleting version directories without examining their overrides would lose features. +**Macros.** One memoized lookup per class and method (hits and misses) builds macro reflections for closures, string and array callables and invokable objects (`Macroable::macro()` accepts `callable|object`; an invokable reflects as `[$object, '__invoke']`, keeping the callable's real name). Signatures and exceptions are resolved separately: + +- An `@method` tag on the receiver's or the macro host's ancestors (a facade's host is its booted root) and Head's `withHead()` signature take precedence over the callable, whose type `ClosureTypeFactory` reads from its declared signature only (`Request::validate()` keeps `array`, Builder's `whereCan()` its `$this`). Mixins do not count: a macro runs before mixin forwarding. +- `Macro`'s `ValidationException` map applies only to `Request` macros. `Methods/MacroThrowTypeExtension` (method and static) supplies a host macro's throw type for a tag-resolved method only when the tag's class has no native method of that name, has a registered macro with a throw type, and the reflection already has a non-null throw type (the magic handler's `@throws`); otherwise PHPStan's normal handling applies (`Http\Client\Factory::__call()` declares none). It looks up the macro on the call's receiver or resolved class (facades through their root) and returns the existing throw type when that host has none. +- Macro, facade and Storage forwarding pass argument values: by-reference parameters become by-value through `ProxiedParameterReflection`, `StaticMethodReflection` binds forwarded `static` returns to the target (not the facade), and native-method macros are callable statically and on instances, as closures are. +- `OptionalExtension` skips names with an `Optional` macro, because `Optional::__call()` runs macros first; this does not rely on extension ordering. +- `AuthManager::__call()` forwards to the current guard, which `shouldUse()` can set to any configured guard (`AuthGuardHelper::getDefaultGuardType()`; an unknown guard type claims nothing). A name is claimed when every configured guard has a public method of that name, natively or as a macro, and at least one supplies the macro; the combined guard method supplies the signature, static for the `Auth` facade and an instance `ProxiedMethodReflection` for `AuthManager`, which has no `__callStatic()`. This applies only where `AuthManager`'s own `__call()` handles the call (for the facade, its booted root); a subclass's Macroable or custom `__call()` keeps the ordinary macro lookup. Upstream's fixed `SessionGuard`/`RequestGuard` hosts claimed a macro the current guard may lack. Upstream's fixed Cache facade hosts are removed: `CacheManager` is not Macroable, and the facade's and manager's `@mixin Repository` already reach the store's macros. +- Lookups whose result depends on a generic class's template types (`ReflectionHelper`'s tag-or-mixin caches, the manager extension's caches) are keyed by `ClassReflection::getCacheKey()`; class-wide facts (registered macros, facade roots, trait use) stay keyed by class name. + General type improvements go into source, with dedicated type fixtures and no runtime overhead. Confirmed examples include `Attribute` and the conditional `FormRequest::validated()` result. `Enumerable::keyBy()` already declares a new-key template, and `Builder::make()`/`create()` already return `TModel`; do not duplicate these. Analyzer-only `model-property` refinements on Model/Builder (including `array, mixed>` inputs) and `view-string` refinements on mail/view/route methods live in package stubs, not framework source. Adapt Redis stubs to Hypervel's phpredis-only pooled `RedisProxy` surface. Omit Predis stubs, Laravel's client-selection branches and the `redisDriver` parameter; there is no supported alternate transport to select. Preserve upstream's Request and Optional `universalObjectCratesClasses` entries: `Request::__get()` resolves arbitrary input/route keys, and `Optional::__get()` forwards an arbitrary property name or returns null. These are supported dynamic APIs, not undefined-property mistakes. Do not add blanket crates for models or other classes to hide missing inference; use their dedicated reflection extensions and preserve explicit property types. Include the documented `$request->name` and Optional property access in typing coverage. Removing either dynamic API would require owner approval. @@ -283,6 +303,7 @@ Track connection/table identity where it changes the result, schema changes acro - Track runtime inputs explicitly; normal class dependencies alone do not capture registration code. For macros/mixins, track registered closure/method source files through reflection, plus registration providers and the mixin class itself. For container string keys, track loaded provider source, `bootstrap/app.php`, `bootstrap/providers.php`, Composer's installed metadata/root discovery configuration and package discovery cache, Testbench YAML, `.env` and the relevant pre-boot environment identity. Resolved concrete classes retain normal class dependencies. For console definitions, track command class files and `routes/console.php`. Auth configuration and view/translation namespace registrations inherit their config/provider/resource dependencies. Include files called by registration helpers where they supply a consumed fact; retain an explicit dependency at that boundary rather than assuming the analyzed caller references them. Compute shared inputs once per process, not per symbol. - Package-mode boot paths point into Testbench's per-process runtime copy, so tracked config, view and translation dependencies vanish between runs. Resolve provenance once at this shared tracking boundary, for all three: track the actual source inputs of copied files and registrations, and keep conservative invalidation where provenance is unknown (the analyzer fixture overwriting runtime `config/auth.php` and `resources/views`, other Testbench file changes, borrowed runtimes, a predefined `BASE_PATH`). No per-parser remapping or blanket prefix substitution. Required before final integration signoff. - Boot-derived config mutation targets (the recorded `ConfigMutationTracker` keys) depend on external conditions, such as Testbench rewriting `database.default` only when the SQLite database file is missing. A stale result can keep a wrong narrow type or an unnecessarily broad one, and provider hashes and environment identity do not notice the file appearing or disappearing. Include that condition (file presence) in the shared tracking, and cover a warm-cache presence transition in both directions against a cold run. No new tracking in the config parser, no boot during cache restoration and no database content hashing. +- `HypervelStubFilesExtension` discovers stub files from a directory. PHPStan rechecks the hashes of stubs recorded in the cache but not a changed list from an extension, so track stub-directory membership with the shared inputs and verify that adding a stub with unchanged extension PHP/config invalidates a warm cache. - PHPStan restores its result cache in the main process before application bootstrap. A `ResultCacheMetaExtension` cannot assume a booted container. Use it only for global inputs available before boot, such as selected configuration/environment identity; use existing PHPStan facilities for per-file facts. Do not serialize the live container or eagerly boot it during cache restoration. - Test a changed boot-derived return type with unchanged analyzed source, on a warm cache. A real provider/macro fixture must change a formerly valid call into an error without manually clearing the cache, and recover when the provider is corrected. Include a `mixin()` class referenced only by provider registration. Verify the same result with a cold run. - Verify unchanged warm-run reuse for an application and a Testbench-backed package: a second normal run with no changes restores the result cache and reanalyses nothing, even though Testbench creates a new runtime directory. Identical warm and cold diagnostics alone cannot show reuse. Use PHPStan's ordinary result-cache output; no timing threshold, benchmark, `--debug` or routine cache clearing. diff --git a/src/docs/static-analysis.md b/src/docs/static-analysis.md index 3c4a0b3fd..d73b133ee 100644 --- a/src/docs/static-analysis.md +++ b/src/docs/static-analysis.md @@ -76,7 +76,9 @@ $enabled = config('feature.enabled'); The facts your application registers come from the booted application: container bindings, authentication guards and user models, console commands, views and translations, including the default locale. Your environment can influence these facts. For instance, `app('db.connection')` is typed as the connection class your `DB_CONNECTION` variable selects, and the authentication user model may come from `AUTH_MODEL`. So, you should run the analysis with the same environment locally and in CI. When you analyze a package, the analysis sees the Testbench application's registrations, not those of every application that may install the package. -A value typed as an interface only has the interface's methods, even when the container binds a class with more of them. When your code needs a specific implementation, type it as that implementation. Resolving a service from the container, such as `app(Repository::class)`, is typed as the class the booted application binds. +A value typed as an interface only has the interface's methods, even when the container binds a class with more of them or macros are registered on that class. When your code needs a specific implementation, type it as that implementation. Resolving a service from the container, such as `app(Repository::class)`, is typed as the class the booted application binds. So are the factories that helpers such as `response()`, `view()`, `validator()`, `url()` and `logger()` resolve, which is why `response()` has your response macros and `url()->full()` is available. + +Calls a manager forwards to its default driver are typed from the return type declared by whatever creates the driver the booted application selects, such as its `create...Driver()` method or an `extend()` callback, unless the manager declares the method or a `@mixin` for its drivers. Analysis never builds the driver, so give `extend()` callbacks a return type. Like container bindings, the selected driver can depend on your environment. Dates are different: `now()`, `today()` and the `Date` facade return `CarbonInterface`, as they are declared, even when your application configures a specific date class. When your code needs a specific class, convert the value, such as with `toImmutable()`. diff --git a/src/phpstan/README.md b/src/phpstan/README.md index 1a25d79ce..3d2c2d2d0 100644 --- a/src/phpstan/README.md +++ b/src/phpstan/README.md @@ -9,7 +9,7 @@ Documentation: https://hypervel.org/docs/static-analysis - Several checks are enabled by default, and Hypervel adds coroutine, worker-lifetime and authoring-policy checks. See the [configuration defaults](https://hypervel.org/docs/static-analysis#configuration). - There is no Octane compatibility rule or `checkOctaneCompatibility` setting; Hypervel's worker-lifetime checks replace it. There is no deferred service provider rule, because Hypervel has no deferred providers. - Container ArrayAccess return types are not provided, because the Hypervel container does not support ArrayAccess. -- A value typed as an interface does not gain methods from its implementations. Upstream resolves an undefined method on a contract against its container binding, and on the auth user contracts against the configured auth models. Explicit container resolution, such as `app(Repository::class)`, is still typed from the booted application's binding. +- A value typed as an interface does not gain methods or macros from its implementations. Upstream resolves an undefined method or a macro on a contract against its container binding, and on the auth user contracts against the configured auth models. Explicit resolution, such as `app(Repository::class)` or the factory returned by `response()`, is typed from the booted application's binding instead. - Without a guard name, `Auth::guard()` and `auth()->guard()` may return any configured guard, because `shouldUse()` changes the default guard per request. A guard whose type cannot be read from its driver keeps the declared `Guard` type rather than `StatefulGuard`. - The request-scope auth rules offer no automatic fix, because the request's user resolver can return a different user than the `Auth` facade or `auth()` helper. - Collections keep Laravel's covariant value type, so a `Collection` is accepted where a `Collection` is expected. Upstream's stubs make it invariant. With bleeding edge, PHPStan does not widen a covariant value type it has already inferred, so a call such as `collect([1, 2, 3])->push(4)` is reported. See [Collection Types](https://hypervel.org/docs/static-analysis#collection-types). diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php index 0739c9d34..10ebd1911 100644 --- a/tests/PHPStan/Type/GeneralTypeTest.php +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -21,6 +21,7 @@ public function testFileAsserts( /** @return iterable */ public static function dataFileAsserts(): iterable { + yield from self::gatherAssertTypes(__DIR__ . '/data/abstract-manager.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/app-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/application-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/arr-get-pull.php'); @@ -35,9 +36,15 @@ public static function dataFileAsserts(): iterable // REMOVED: container-array-access.php; container ArrayAccess is unsupported. yield from self::gatherAssertTypes(__DIR__ . '/data/container-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/contracts.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/facade-fakes.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/facades.php'); + // REMOVED: facades-l11-42.php; it asserts Laravel 11's Redis facade docblock types. + yield from self::gatherAssertTypes(__DIR__ . '/data/facades-l12-20.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/facades-l12-41.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/gate-facade.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/helpers.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/higher-order-collection-proxies.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/managers.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/optional-helper.php'); // Upstream never registers request-user.php, which shares request-object.php's namespace. yield from self::gatherAssertTypes(__DIR__ . '/data/request-user.php'); From b6a6cfd34ab5eaa9a3541ab0958b05e952e7fa79 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:13:58 +0000 Subject: [PATCH 087/303] fix: simplify identical conditional facade return types Collapse parameter-conditional return types when resolving their branches produces the same type. This avoids redundant generated conditionals after generic template types normalize to mixed. Cover the actual documenter output with an existing fixture harness. Validation: the combined checkpoint passed formatting, root/type PHPStan and 1077 analyzer tests. The affected Http, HttpServer, FacadeDocumenter, Filesystem, Validation, Foundation and Redis runtime suites passed, with their reported skips. --- src/facade-documenter/facade.php | 4 ++ .../ConditionalDedupeTest.php | 54 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/facade-documenter/facade.php b/src/facade-documenter/facade.php index eff26c3ea..1f23bf2d6 100755 --- a/src/facade-documenter/facade.php +++ b/src/facade-documenter/facade.php @@ -695,6 +695,10 @@ function resolveDocblockTypes($method, $typeNode, int $depth = 1) $if = resolveDocblockTypes($method, $typeNode->if, $depth + 1); $else = resolveDocblockTypes($method, $typeNode->else, $depth + 1); + if ($if === $else) { + return $if; + } + if (! canPreserveConditionalTarget($method, $typeNode->targetType)) { return flattenConditionalBranches($if, $else); } diff --git a/tests/FacadeDocumenter/ConditionalDedupeTest.php b/tests/FacadeDocumenter/ConditionalDedupeTest.php index ba5b003c2..71ac9f9d0 100644 --- a/tests/FacadeDocumenter/ConditionalDedupeTest.php +++ b/tests/FacadeDocumenter/ConditionalDedupeTest.php @@ -91,6 +91,60 @@ class Facade $this->assertContains('object', $members); } + public function testParameterConditionalWithIdenticalBranchesIsCollapsed(): void + { + $this->writeAppFile( + 'Conditional/IdenticalBranches/Proxy.php', + <<<'PHP' + writeAppFile( + 'Conditional/IdenticalBranches/Facade.php', + <<<'PHP' + runDocumenter(['App\Conditional\IdenticalBranches\Facade']); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput() . $process->getOutput()); + $this->assertStringContainsString( + '@method static mixed when(callable|null $callback = null)', + $this->appFileContents('App\Conditional\IdenticalBranches\Facade'), + ); + } + public function testNestedGenericUnionIsNotShreddedByDedupe(): void { $this->writeAppFile( From 4e5a8a0d6d530504baf4effebdb28c0a1c7770f2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:13:58 +0000 Subject: [PATCH 088/303] fix: preserve request input values and default types Accept supported scalar, Stringable and arbitrary default values without native-type failures. Type whole input bags with preserved integer keys, recursive nullable file inputs and evaluated file defaults. Preserve header nulls and make bearer-token extraction handle them. Keep route-binding results mixed, remove obsolete file-conversion suppressions, and regenerate the Request facade from the corrected source contracts. Cover runtime defaults and the public analysis surface. Validation: the combined checkpoint passed formatting, root/type PHPStan and 1077 analyzer tests. The affected Http, HttpServer, FacadeDocumenter, Filesystem, Validation, Foundation and Redis runtime suites passed, with their reported skips. --- src/http/src/Concerns/InteractsWithInput.php | 54 +++++++++++++++----- src/http/src/Request.php | 4 +- src/support/src/Facades/Request.php | 16 +++--- tests/Http/HttpRequestTest.php | 30 +++++++++++ tests/PHPStan/Type/data/request-header.php | 17 ++++++ tests/PHPStan/Type/data/request-object.php | 40 +++++++++++++++ types/Http/Request.php | 2 +- 7 files changed, 140 insertions(+), 23 deletions(-) create mode 100644 tests/PHPStan/Type/data/request-header.php create mode 100644 tests/PHPStan/Type/data/request-object.php diff --git a/src/http/src/Concerns/InteractsWithInput.php b/src/http/src/Concerns/InteractsWithInput.php index 814bc15c7..597720af8 100644 --- a/src/http/src/Concerns/InteractsWithInput.php +++ b/src/http/src/Concerns/InteractsWithInput.php @@ -4,6 +4,7 @@ namespace Hypervel\Http\Concerns; +use Closure; use Hypervel\Http\UploadedFile; use Hypervel\Image\Image; use Hypervel\Support\Arr; @@ -11,6 +12,7 @@ use Hypervel\Support\Traits\Dumpable; use Hypervel\Support\Traits\InteractsWithData; use SplFileInfo; +use Stringable; use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; use Symfony\Component\HttpFoundation\InputBag; @@ -21,8 +23,10 @@ trait InteractsWithInput /** * Retrieve a server variable from the request. + * + * @return ($key is null ? array : mixed) */ - public function server(?string $key = null, string|array|int|float|null $default = null): string|array|int|float|null + public function server(?string $key = null, mixed $default = null): mixed { return $this->retrieveItem('server', $key, $default); } @@ -37,6 +41,10 @@ public function hasHeader(string $key): bool /** * Retrieve a header from the request. + * + * A header stored with a null value returns null even when a default is given. + * + * @return ($key is null ? array> : null|string) */ public function header(?string $key = null, string|array|null $default = null): string|array|null { @@ -48,7 +56,7 @@ public function header(?string $key = null, string|array|null $default = null): */ public function bearerToken(): ?string { - $header = $this->header('Authorization', ''); + $header = $this->header('Authorization') ?? ''; $position = strripos($header, 'Bearer '); @@ -115,16 +123,26 @@ public function fluent(array|string|null $key = null, array $default = []): Flue /** * Retrieve a query string item from the request. + * + * @template TDefault + * + * @param TDefault $default + * @return ($key is null ? array : array|bool|float|int|string|Stringable|TDefault) */ - public function query(?string $key = null, string|array|null $default = null): string|array|null + public function query(?string $key = null, mixed $default = null): mixed { return $this->retrieveItem('query', $key, $default); } /** * Retrieve a request payload item from the request. + * + * @template TDefault + * + * @param TDefault $default + * @return ($key is null ? array : array|bool|float|int|string|Stringable|TDefault) */ - public function post(?string $key = null, string|array|null $default = null): string|array|null + public function post(?string $key = null, mixed $default = null): mixed { return $this->retrieveItem('request', $key, $default); } @@ -139,8 +157,13 @@ public function hasCookie(string $key): bool /** * Retrieve a cookie from the request. + * + * @template TDefault + * + * @param TDefault $default + * @return ($key is null ? array : array|bool|float|int|string|Stringable|TDefault) */ - public function cookie(?string $key = null, string|array|null $default = null): string|array|null + public function cookie(?string $key = null, mixed $default = null): mixed { return $this->retrieveItem('cookies', $key, $default); } @@ -148,7 +171,9 @@ public function cookie(?string $key = null, string|array|null $default = null): /** * Get an array of all of the files on the request. * - * @return array + * Nested inputs give nested arrays, and an input submitted without a file is null. + * + * @return array|UploadedFile> */ public function allFiles(): array { @@ -160,13 +185,15 @@ public function allFiles(): array /** * Convert the given array of Symfony UploadedFiles to custom Hypervel UploadedFiles. * - * @param array $files - * @return array + * @template TKey of array-key + * + * @param array|SymfonyUploadedFile> $files + * @return array|UploadedFile> */ protected function convertUploadedFiles(array $files): array { return array_map(function ($file) { - if (is_null($file) || (is_array($file) && empty(array_filter($file)))) { /* @phpstan-ignore function.impossibleType, arrayFilter.same (PHP's $_FILES can produce null entries despite the docblock type) */ + if (is_null($file) || (is_array($file) && empty(array_filter($file)))) { return $file; } @@ -206,9 +233,12 @@ protected function isValidFile(mixed $file): bool /** * Retrieve a file from the request. * - * @return ($key is null ? array : null|UploadedFile|UploadedFile[]) + * @template TDefault + * + * @param (Closure(): TDefault)|TDefault $default + * @return ($key is null ? array|UploadedFile> : null|array|TDefault|UploadedFile) */ - public function file(?string $key = null, mixed $default = null): UploadedFile|array|null + public function file(?string $key = null, mixed $default = null): mixed { return data_get($this->allFiles(), $key, $default); } @@ -238,7 +268,7 @@ protected function data(?string $key = null, mixed $default = null): mixed /** * Retrieve a parameter item from a given source. */ - protected function retrieveItem(string $source, ?string $key, string|array|int|float|null $default): string|array|int|float|null + protected function retrieveItem(string $source, ?string $key, mixed $default): mixed { if (is_null($key)) { return $this->{$source}->all(); diff --git a/src/http/src/Request.php b/src/http/src/Request.php index abcdc4691..79b97b1d2 100644 --- a/src/http/src/Request.php +++ b/src/http/src/Request.php @@ -87,7 +87,7 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess /** * All of the converted files for the request. * - * @var null|array + * @var null|array|UploadedFile> */ protected ?array $convertedFiles = null; @@ -1249,7 +1249,7 @@ public function user(?string $guard = null): mixed /** * Get the route handling the request. * - * @return ($param is null ? null|Route : null|object|string) + * @return ($param is null ? null|Route : mixed) */ public function route(?string $param = null, mixed $default = null): mixed { diff --git a/src/support/src/Facades/Request.php b/src/support/src/Facades/Request.php index 1579adc0c..c8c678e98 100644 --- a/src/support/src/Facades/Request.php +++ b/src/support/src/Facades/Request.php @@ -12,14 +12,14 @@ * @method static bool acceptsMarkdown() * @method static bool ajax() * @method static array all(mixed $keys = null) - * @method static array allFiles() + * @method static array|\Hypervel\Http\UploadedFile> allFiles() * @method static bool anyFilled(array|string $keys) * @method static array array(array|string|null $key = null) * @method static string|null bearerToken() * @method static ($strict is true ? bool|null : bool) boolean(string|null $key = null, \Closure|bool|null $default = false, bool $strict = false) * @method static int|float clamp(string $key, int|float $min, int|float $max, int|float $default = 0) * @method static \Hypervel\Support\Collection collect(array|string|null $key = null) - * @method static array|string|null cookie(string|null $key = null, array|string|null $default = null) + * @method static ($key is null ? array : mixed) cookie(string|null $key = null, mixed $default = null) * @method static \Hypervel\Http\Request create(string $uri, string $method = 'GET', array $parameters = [], array $cookies = [], array $files = [], array $server = [], string|resource|null $content = null) * @method static \Hypervel\Http\Request createFrom(\Hypervel\Http\Request $from, \Hypervel\Http\Request|null $to = null) * @method static \Hypervel\Http\Request createFromBase(\Symfony\Component\HttpFoundation\Request $request) @@ -35,7 +35,7 @@ * @method static array except(mixed $keys) * @method static bool exists(array|string $key) * @method static bool expectsJson() - * @method static ($key is null ? array : null|\Hypervel\Http\UploadedFile|\Hypervel\Http\UploadedFile[]) file(string|null $key = null, mixed $default = null) + * @method static ($key is null ? array|\Hypervel\Http\UploadedFile> : mixed) file(string|null $key = null, mixed $default = null) * @method static bool filled(array|string $key) * @method static array filterPrecognitiveRules(array $rules) * @method static string fingerprint() @@ -110,7 +110,7 @@ * @method static bool hasValidRelativeSignatureWhileIgnoring(mixed $ignoreQuery = []) * @method static bool hasValidSignature(bool $absolute = true) * @method static bool hasValidSignatureWhileIgnoring(mixed $ignoreQuery = [], mixed $absolute = true) - * @method static array|string|null header(string|null $key = null, array|string|null $default = null) + * @method static ($key is null ? array> : null|string) header(string|null $key = null, array|string|null $default = null) * @method static string host() * @method static string httpHost() * @method static \Hypervel\Image\Image|null image(string $key) @@ -150,20 +150,20 @@ * @method static void overrideGlobals() * @method static string path() * @method static bool pjax() - * @method static array|string|null post(string|null $key = null, array|string|null $default = null) + * @method static ($key is null ? array : mixed) post(string|null $key = null, mixed $default = null) * @method static string|null prefers(array|string $contentTypes) * @method static bool preferSafeContent() * @method static bool prefetch() - * @method static array|string|null query(string|null $key = null, array|string|null $default = null) + * @method static ($key is null ? array : mixed) query(string|null $key = null, mixed $default = null) * @method static \Hypervel\Http\Request replace(array $input) * @method static string root() - * @method static ($param is null ? null|\Hypervel\Routing\Route : null|object|string) route(string|null $param = null, mixed $default = null) + * @method static ($param is null ? null|\Hypervel\Routing\Route : mixed) route(string|null $param = null, mixed $default = null) * @method static bool routeIs(mixed ...$patterns) * @method static string schemeAndHttpHost() * @method static bool secure() * @method static string|null segment(int $index, string|null $default = null) * @method static array segments() - * @method static array|string|int|float|null server(string|null $key = null, array|string|int|float|null $default = null) + * @method static ($key is null ? array : mixed) server(string|null $key = null, mixed $default = null) * @method static \Hypervel\Contracts\Session\Session session() * @method static void setAllowedHttpMethodOverride(string[]|null $methods) * @method static void setDefaultLocale(string $locale) diff --git a/tests/Http/HttpRequestTest.php b/tests/Http/HttpRequestTest.php index aadffbcaf..3b99c1263 100644 --- a/tests/Http/HttpRequestTest.php +++ b/tests/Http/HttpRequestTest.php @@ -10,6 +10,7 @@ use Hypervel\Routing\Route; use Hypervel\Session\NullSessionHandler; use Hypervel\Session\Store; +use Hypervel\Support\Stringable; use Hypervel\Tests\Database\Fixtures\Models\Money\Price; use Hypervel\Tests\Support\Concerns\TestsInteractsWithDataContract; use Hypervel\Tests\TestCase; @@ -785,6 +786,29 @@ public function testCookieMethod(): void $this->assertSame('Taylor', $all['name']); } + public function testInputSourceGettersReturnAnyDefaultAndStoredValue(): void + { + $request = Request::create('/', 'GET'); + $this->assertSame(15, $request->query('per_page', 15)); + $this->assertFalse($request->cookie('consent', false)); + $this->assertTrue($request->server('MISSING', true)); + + $request->merge(['active' => true]); + $this->assertTrue($request->query('active')); + + $name = new Stringable('Taylor'); + $request->query->set('name', $name); + $this->assertSame($name, $request->query('name')); + + $request->server->set('HTTPS', true); + $this->assertTrue($request->server('HTTPS')); + + $request = Request::createFromBase( + SymfonyRequest::create('/', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], '{"count":5}') + ); + $this->assertSame(5, $request->post('count')); + } + public function testHasCookieMethod(): void { $request = Request::create('/', 'GET', [], ['foo' => 'bar']); @@ -805,6 +829,8 @@ public function testFileMethod(): void ]; $request = Request::create('/', 'GET', [], [], $files); $this->assertInstanceOf(SymfonyUploadedFile::class, $request->file('foo')); + $this->assertSame('none', $request->file('bar', 'none')); + $this->assertSame('lazy', $request->file('bar', fn () => 'lazy')); } public function testImageMethod(): void @@ -976,6 +1002,10 @@ public function testBearerTokenMethod(): void $request = Request::create('/', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'foo,bar']); $this->assertNull($request->bearerToken()); + + $request = Request::create('/'); + $request->headers->set('Authorization', null); + $this->assertNull($request->bearerToken()); } public function testJSONMethod(): void diff --git a/tests/PHPStan/Type/data/request-header.php b/tests/PHPStan/Type/data/request-header.php new file mode 100644 index 000000000..39e443431 --- /dev/null +++ b/tests/PHPStan/Type/data/request-header.php @@ -0,0 +1,17 @@ +>', $request->header()); + assertType('string|null', $request->header('key')); + // Hypervel: a header stored with a null value returns null even when a default is given. + assertType('string|null', $request->header('key', 'default')); +} diff --git a/tests/PHPStan/Type/data/request-object.php b/tests/PHPStan/Type/data/request-object.php new file mode 100644 index 000000000..ddba3802c --- /dev/null +++ b/tests/PHPStan/Type/data/request-object.php @@ -0,0 +1,40 @@ +|Hypervel\Http\UploadedFile|null>', $request->file()); + assertType('array|Hypervel\Http\UploadedFile|null', $request->file('foo')); + assertType('array|Hypervel\Http\UploadedFile|stdClass|null', $request->file('foo', new \stdClass())); + assertType('123|array|Hypervel\Http\UploadedFile|null', $request->file('foo', fn () => 123)); + + assertType('Hypervel\Routing\Route|null', $request->route()); + assertType('mixed', $request->route('foo')); + assertType('mixed', $request->route('foo', 'bar')); + + assertType('array', $request->server()); + assertType('mixed', $request->server('foo')); + + assertType('array', $request->post()); + assertType('array|bool|float|int|string|Stringable|null', $request->post('foo')); + assertType('array|bool|float|int|string|Stringable', $request->post('foo', 'bar')); + + assertType('array', $request->query()); + assertType('array|bool|float|int|string|Stringable|null', $request->query('foo')); + assertType('array|bool|float|int|string|Stringable', $request->query('foo', 'bar')); + + assertType('array', $request->cookie()); + assertType('array|bool|float|int|string|Stringable|null', $request->cookie('foo')); +} diff --git a/types/Http/Request.php b/types/Http/Request.php index e597acce5..7e1c599b2 100644 --- a/types/Http/Request.php +++ b/types/Http/Request.php @@ -29,7 +29,7 @@ enum TestEnum: string assertType('bool|null', $request->boolean('key', null, true)); assertType('Hypervel\Routing\Route|null', $request->route()); -assertType('object|string|null', $request->route('key')); +assertType('mixed', $request->route('key')); assertType('Symfony\Component\HttpFoundation\InputBag', $request->json()); assertType('mixed', $request->json('key')); From c207b06859d7024324063ec51aa35a459050f41e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:13:58 +0000 Subject: [PATCH 089/303] fix: share JSON request input in the Swoole bridge Populate the request bag from JSON and share it with the JSON cache, matching createFromBase() and Laravel request behavior. Production post(), input() and direct bag access now agree with HTTP tests. Decode once, preserve malformed-body behavior and verify that input mutations remain visible through both access paths. Validation: the combined checkpoint passed formatting, root/type PHPStan and 1077 analyzer tests. The affected Http, HttpServer, FacadeDocumenter, Filesystem, Validation, Foundation and Redis runtime suites passed, with their reported skips. --- src/http-server/src/RequestBridge.php | 11 ++++++++++- tests/HttpServer/RequestBridgeTest.php | 24 +++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/http-server/src/RequestBridge.php b/src/http-server/src/RequestBridge.php index 62caab888..10aa4d197 100644 --- a/src/http-server/src/RequestBridge.php +++ b/src/http-server/src/RequestBridge.php @@ -34,7 +34,7 @@ public static function createFromSwoole(SwooleRequest $swooleRequest): Request $content = $swooleRequest->rawContent(); $headers = static::transformHeaders($swooleRequest->header ?? [], $server); - return new Request( + $request = new Request( query: $swooleRequest->get ?? [], request: $swooleRequest->post ?? [], attributes: [], @@ -45,6 +45,15 @@ public static function createFromSwoole(SwooleRequest $swooleRequest): Request headers: $headers, pathInfo: static::extractPathInfo($server), ); + + // As in Request::createFromBase() and Laravel's capture(), a JSON payload fills the + // request bag, which then doubles as the JSON cache, so post() reads it too. + if ($request->isJson()) { + $request->request->replace($request->json()->all()); + $request->setJson($request->request); + } + + return $request; } /** diff --git a/tests/HttpServer/RequestBridgeTest.php b/tests/HttpServer/RequestBridgeTest.php index 64f89064c..27865bae9 100644 --- a/tests/HttpServer/RequestBridgeTest.php +++ b/tests/HttpServer/RequestBridgeTest.php @@ -174,7 +174,7 @@ public function testCreateFromSwooleWithCookies(): void public function testCreateFromSwooleWithRawJsonBody(): void { - $body = '{"name":"Taylor","role":"admin"}'; + $body = '{"name":"Taylor","age":38,"admin":true,"profile":{"city":"Paris"}}'; $swooleRequest = $this->createSwooleRequest( server: ['request_method' => 'post', 'request_uri' => '/api/users'], @@ -185,6 +185,28 @@ public function testCreateFromSwooleWithRawJsonBody(): void $request = RequestBridge::createFromSwoole($swooleRequest); $this->assertSame($body, $request->getContent()); + $this->assertSame(38, $request->post('age')); + $this->assertTrue($request->input('admin')); + $this->assertSame(['city' => 'Paris'], $request->request->all('profile')); + + // The request bag and the JSON payload are the same bag. + $request->merge(['team' => 'core']); + $this->assertSame('core', $request->request->get('team')); + $this->assertSame('core', $request->json('team')); + } + + public function testCreateFromSwooleWithMalformedJsonBody(): void + { + $swooleRequest = $this->createSwooleRequest( + server: ['request_method' => 'post', 'request_uri' => '/api/users'], + header: ['host' => 'example.com', 'content-type' => 'application/json'], + rawContent: '{"name":', + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + + $this->assertSame('{"name":', $request->getContent()); + $this->assertSame([], $request->post()); } public function testCreateFromSwooleWithEmptyRawContent(): void From 258a6339fff66cadb3799eb0ac41be50352f022f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:13:58 +0000 Subject: [PATCH 090/303] fix: type environment calls and preserve named arguments Read the first variadic environment value without assuming numeric key zero, preserving named-argument calls and the existing list-versus-pattern semantics. Infer string for calls without effective arguments and bool for calls known to supply arguments. Preserve the declared union for uncertain unpacking and retain narrower custom declarations. Validation: the combined checkpoint passed formatting, root/type PHPStan and 1077 analyzer tests. The affected Http, HttpServer, FacadeDocumenter, Filesystem, Validation, Foundation and Redis runtime suites passed, with their reported skips. --- src/foundation/src/Application.php | 4 +- .../AppEnvironmentReturnTypeExtension.php | 84 +++++++++++++++++++ ...ppFacadeEnvironmentReturnTypeExtension.php | 48 +++++++++++ .../Foundation/FoundationApplicationTest.php | 2 + .../PHPStan/Type/data/environment-helper.php | 34 ++++++++ 5 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 src/phpstan/src/ReturnTypes/AppEnvironmentReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/AppFacadeEnvironmentReturnTypeExtension.php create mode 100644 tests/PHPStan/Type/data/environment-helper.php diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index cfce74217..77b50829f 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -793,7 +793,9 @@ public function joinPaths(?string $basePath, string $path = ''): string public function environment(UnitEnum|array|string ...$environments): bool|string { if ($environments !== []) { - $patterns = is_array($environments[0]) ? $environments[0] : $environments; + // Hypervel: a named environments: argument is keyed by name, so read the first value, not key 0. + $first = $environments[array_key_first($environments)]; + $patterns = is_array($first) ? $first : $environments; return Str::is(array_map(enum_value(...), $patterns), $this->make('env')); } diff --git a/src/phpstan/src/ReturnTypes/AppEnvironmentReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/AppEnvironmentReturnTypeExtension.php new file mode 100644 index 000000000..aa8718348 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AppEnvironmentReturnTypeExtension.php @@ -0,0 +1,84 @@ +class; + } + + /** + * Determine whether the method gets or checks the environment. + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'environment'; + } + + /** + * Get the environment name without arguments, or the result of checking it with them. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + return self::getEnvironmentType($methodCall->getArgs(), $scope, $methodReflection->getVariants()[0]->getReturnType()); + } + + /** + * Get the type of an environment() call with the given arguments. + * + * Hypervel: upstream counts the arguments, but an unpacked empty array passes none. A narrower + * declaration than the inferred type is kept. + * + * @param list $args + */ + public static function getEnvironmentType(array $args, Scope $scope, Type $declaredType): ?Type + { + $type = new StringType; + + foreach ($args as $arg) { + $passesValues = $arg->unpack ? $scope->getType($arg->value)->isIterableAtLeastOnce() : TrinaryLogic::createYes(); + + if ($passesValues->yes()) { + $type = new BooleanType; + + break; + } + + if ($passesValues->maybe()) { + // A later argument can still prove that values are passed. + $type = null; + } + } + + return $type !== null && $declaredType->isSuperTypeOf($type)->yes() ? $type : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/AppFacadeEnvironmentReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/AppFacadeEnvironmentReturnTypeExtension.php new file mode 100644 index 000000000..c9c19a304 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AppFacadeEnvironmentReturnTypeExtension.php @@ -0,0 +1,48 @@ +getName() === 'environment'; + } + + /** + * Get the environment name without arguments, or the result of checking it with them. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + return AppEnvironmentReturnTypeExtension::getEnvironmentType( + $methodCall->getArgs(), + $scope, + $methodReflection->getVariants()[0]->getReturnType(), + ); + } +} diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index 626bc699c..9842f3aff 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -297,6 +297,8 @@ public function testEnvironment(): void $this->assertTrue($app->environment('f*')); $this->assertTrue($app->environment('foo', 'bar')); $this->assertTrue($app->environment(['foo', 'bar'])); + $this->assertTrue($app->environment(environments: 'foo')); + $this->assertTrue($app->environment(environments: ['qux', 'foo'])); $this->assertFalse($app->environment('qux')); $this->assertFalse($app->environment('q*')); diff --git a/tests/PHPStan/Type/data/environment-helper.php b/tests/PHPStan/Type/data/environment-helper.php new file mode 100644 index 000000000..cf9511fce --- /dev/null +++ b/tests/PHPStan/Type/data/environment-helper.php @@ -0,0 +1,34 @@ +environment()); + assertType('bool', app()->environment('local')); + assertType('bool', app()->environment(['local', 'production'])); +} + +/** + * Hypervel: unpacked arguments pass values only when the array is not empty. + * + * @param list $maybeEmpty + * @param non-empty-list $nonEmpty + * @param array{} $empty + */ +function unpackedArguments(array $maybeEmpty, array $nonEmpty, array $empty): void +{ + assertType('bool|string', app()->environment(...$maybeEmpty)); + assertType('bool', app()->environment(...$nonEmpty)); + assertType('string', app()->environment(...$empty)); + assertType('bool', app()->environment(...$maybeEmpty, ...['local'])); + assertType('bool', app()->environment(environments: 'local')); + + assertType('string', App::environment()); + assertType('bool', App::environment('local')); + assertType('bool|string', App::environment(...$maybeEmpty)); +} From 53242937879e5447f336c037367d1cfa1c69d68a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:13:58 +0000 Subject: [PATCH 091/303] feat: describe validated input subsets with optional keys Preserve integer keys in validated data and safe() results. Infer optional shape entries for constant direct keys because missing validated fields are omitted. Use the same extension for FormRequest and the Validator contract. Retain declarations for unpacked, dotted, wildcard or unknown selections instead of inventing a partial shape. Validation: the combined checkpoint passed formatting, root/type PHPStan and 1077 analyzer tests. The affected Http, HttpServer, FacadeDocumenter, Filesystem, Validation, Foundation and Redis runtime suites passed, with their reported skips. --- src/contracts/src/Validation/Validator.php | 2 +- src/foundation/src/Http/FormRequest.php | 4 +- ...stSafeDynamicMethodReturnTypeExtension.php | 98 +++++++++++++++++++ src/validation/src/Validator.php | 2 +- tests/PHPStan/Type/data/form-request.php | 34 +++++++ 5 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 src/phpstan/src/ReturnTypes/FormRequestSafeDynamicMethodReturnTypeExtension.php create mode 100644 tests/PHPStan/Type/data/form-request.php diff --git a/src/contracts/src/Validation/Validator.php b/src/contracts/src/Validation/Validator.php index 984fcdc2d..942c20f6c 100644 --- a/src/contracts/src/Validation/Validator.php +++ b/src/contracts/src/Validation/Validator.php @@ -90,7 +90,7 @@ public function retainRules(array $attributes): static; * Get a validated input container for the validated input. * * @param null|array $keys - * @return ($keys is array ? array : ValidatedInput) + * @return ($keys is array ? array : ValidatedInput) * * @throws ValidationException */ diff --git a/src/foundation/src/Http/FormRequest.php b/src/foundation/src/Http/FormRequest.php index 5fdec02d3..415f835e7 100644 --- a/src/foundation/src/Http/FormRequest.php +++ b/src/foundation/src/Http/FormRequest.php @@ -332,7 +332,7 @@ protected function failedAuthorization(): void * Get a validated input container for the validated input. * * @param null|array $keys - * @return ($keys is array ? array : ValidatedInput) + * @return ($keys is array ? array : ValidatedInput) * * @throws ValidationException */ @@ -347,6 +347,8 @@ public function safe(?array $keys = null): array|ValidatedInput /** * Get the validated data from the request. + * + * @return ($key is null ? array : mixed) */ public function validated(array|int|string|null $key = null, mixed $default = null): mixed { diff --git a/src/phpstan/src/ReturnTypes/FormRequestSafeDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/FormRequestSafeDynamicMethodReturnTypeExtension.php new file mode 100644 index 000000000..d77d4d0c8 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/FormRequestSafeDynamicMethodReturnTypeExtension.php @@ -0,0 +1,98 @@ +class; + } + + /** + * Determine whether the method returns validated input. + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'safe'; + } + + /** + * Get the shape of the validated input for the given keys. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $args = $methodCall->getArgs(); + + if (count($args) === 0 || $args[0]->unpack) { + return null; + } + + $argType = $scope->getType($args[0]->value); + + if (! $argType->isConstantArray()->yes()) { + return null; + } + + $types = []; + + foreach ($argType->getConstantArrays() as $constantArray) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($constantArray->getValueTypes() as $keyType) { + $keys = $keyType->getConstantStrings(); + + // Hypervel: a non-constant key can name any input, so the shape is unknown. + if ($keys === []) { + return null; + } + + foreach ($keys as $constantString) { + // Hypervel: only() nests dotted keys and expands wildcards. + if (str_contains($constantString->getValue(), '.') || str_contains($constantString->getValue(), '*')) { + return null; + } + + // Hypervel: optional, because only() omits a key the validated input does not contain. + $builder->setOffsetValueType($constantString, new MixedType, true); + } + } + + $types[] = $builder->getArray(); + } + + return TypeCombinator::union(...$types); + } +} diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index a23981f50..fb7de05b5 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -1059,7 +1059,7 @@ public function validateWithBag(string $errorBag): array * Get a validated input container for the validated input. * * @param null|array $keys - * @return ($keys is array ? array : ValidatedInput) + * @return ($keys is array ? array : ValidatedInput) * * @throws ValidationException */ diff --git a/tests/PHPStan/Type/data/form-request.php b/tests/PHPStan/Type/data/form-request.php new file mode 100644 index 000000000..1e34f81e9 --- /dev/null +++ b/tests/PHPStan/Type/data/form-request.php @@ -0,0 +1,34 @@ +safe()); + // Hypervel: optional keys, because only() omits keys the validated input does not contain. + assertType('array{key?: mixed}', $request->safe(['key'])); + assertType('array{key?: mixed}|array{other?: mixed, another?: mixed}', $request->safe($condition ? ['key'] : ['other', 'another'])); + // Hypervel: a top-level numeric rule key, such as a list payload's '*.name', gives int keys. + assertType('array', $request->validated()); +} + +/** + * Hypervel: keys the extension cannot completely describe keep the declaration. + * + * @param list $keys + */ +function undescribedKeys(FormRequest $request, Validator $validator, array $keys, string $key): void +{ + assertType('array{key?: mixed}', $validator->safe(['key'])); + assertType('array', $request->safe(['user.name'])); + assertType('array', $request->safe(['items*'])); + assertType('array', $request->safe([$key])); + assertType('array', $request->safe(...[$keys])); +} From e4aefa76fce1981614bf9317ee79569e22a31651 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:13:58 +0000 Subject: [PATCH 092/303] feat: narrow conditions after abort and throw helpers Port one type-specifying mechanism for the global abort_if, abort_unless, throw_if and throw_unless helpers. Narrow only the actual condition and decline missing or unpacked arguments. Cover branch certainty, named arguments and unpacking, and correct the upstream fixture's undefined variable. Validation: the combined checkpoint passed formatting, root/type PHPStan and 1077 analyzer tests. The affected Http, HttpServer, FacadeDocumenter, Filesystem, Validation, Foundation and Redis runtime suites passed, with their reported skips. --- ...AbortIfFunctionTypeSpecifyingExtension.php | 76 +++++++++++++++++++ tests/PHPStan/Type/data/abort.php | 54 +++++++++++++ tests/PHPStan/Type/data/throw.php | 46 +++++++++++ 3 files changed, 176 insertions(+) create mode 100644 src/phpstan/src/Types/AbortIfFunctionTypeSpecifyingExtension.php create mode 100644 tests/PHPStan/Type/data/abort.php create mode 100644 tests/PHPStan/Type/data/throw.php diff --git a/src/phpstan/src/Types/AbortIfFunctionTypeSpecifyingExtension.php b/src/phpstan/src/Types/AbortIfFunctionTypeSpecifyingExtension.php new file mode 100644 index 000000000..b310e8d29 --- /dev/null +++ b/src/phpstan/src/Types/AbortIfFunctionTypeSpecifyingExtension.php @@ -0,0 +1,76 @@ +methodName = $methodName . '_' . ($negate === false ? 'if' : 'unless'); + } + + /** + * Determine whether the call is a statement-level call of the helper. + */ + public function isFunctionSupported( + FunctionReflection $functionReflection, + FuncCall $node, + TypeSpecifierContext $context, + ): bool { + return $functionReflection->getName() === $this->methodName && $context->null(); + } + + /** + * Narrow the condition to the outcome that lets the call return. + */ + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes { + $args = $node->getArgs(); + + // Hypervel: an unpacked first argument is the whole argument array, not the condition. + if ($args === [] || $args[0]->unpack) { + return new SpecifiedTypes; + } + + if (! str_starts_with($this->methodName, 'throw') && count($node->args) < 2) { + return new SpecifiedTypes; + } + + $context = $this->negate === false ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createTruthy(); + + return $this->typeSpecifier->specifyTypesInCondition($scope, $args[0]->value, $context); + } + + /** + * Set the type specifier used to narrow the condition. + */ + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } +} diff --git a/tests/PHPStan/Type/data/abort.php b/tests/PHPStan/Type/data/abort.php new file mode 100644 index 000000000..d00588cf7 --- /dev/null +++ b/tests/PHPStan/Type/data/abort.php @@ -0,0 +1,54 @@ + 0) { + $operator = '+'; + } elseif ($foo < 0) { + $operator = '-'; + } else { + abort(422); + } + + // Should not complain about $operator possibly not being defined + assertVariableCertainty( + \PHPStan\TrinaryLogic::createYes(), + $operator + ); + + abort_if(! $foo, 500); + assertType('int|int<1, max>', $foo); + + /** @var int|string $foo */ + abort_if(is_string($foo), 500); + assertType('int', $foo); + + /** @var ?int $foo */ + abort_unless(! is_null($foo), 500); + assertType('int', $foo); + + /** @var int|string $foo */ + abort_unless(! is_string($foo), 500); + assertType('int', $foo); + + // Hypervel: the condition binds by name. + /** @var ?int $foo */ + abort_if(code: 500, boolean: $foo === null); + assertType('int', $foo); +} + +/** + * Hypervel: an unpacked argument array is not the condition. + * + * @param array{bool, int} $arguments + */ +function unpackedArguments(array $arguments): void +{ + abort_if(...$arguments); + assertType('array{bool, int}', $arguments); +} diff --git a/tests/PHPStan/Type/data/throw.php b/tests/PHPStan/Type/data/throw.php new file mode 100644 index 000000000..f248dec30 --- /dev/null +++ b/tests/PHPStan/Type/data/throw.php @@ -0,0 +1,46 @@ +|int<1, max>', $foo); + } catch (\Exception $e) { + } + + assertType('int|null', $foo); + + throw_unless(! is_null($foo), ValidationException::withMessages(['$foo is null'])); + assertType('int', $foo); + + /** @var int|string $foo */ + throw_if(is_string($foo), new \Exception('$foo is a string')); + assertType('int', $foo); + + /** @var int|string $foo */ + throw_unless(! is_string($foo), new \Exception('$foo is a string')); + assertType('int', $foo); + + /** @var int|string $foo */ + throw_if(is_string($foo)); + assertType('int', $foo); + + /** @var int|string $foo */ + throw_if(is_string($foo), 'Exception message'); + assertType('int', $foo); + + /** @var int|string $foo */ + throw_unless(! is_string($foo)); + assertType('int', $foo); + + /** @var int|string $foo */ + throw_unless(! is_string($foo), 'Exception message'); + assertType('int', $foo); +} From faf65708acc611c2647c0e88669c2d007953a1df Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:13:59 +0000 Subject: [PATCH 093/303] feat: infer filesystem disk types without constructing backends Resolve named disk types from booted configuration, scoped normalization, creator declarations and the live pooling policy. Preserve custom dispatch declarations and decline incompatible already-created disks without constructing clients or filesystem backends. Type default-facade forwarding from the selected disk rather than the manager's broad mixins. Add truthful creator PHPDoc and verify pooled/custom/scoped selections, named unions, default forwarding and override boundaries. Validation: the combined checkpoint passed formatting, root/type PHPStan and 1077 analyzer tests. The affected Http, HttpServer, FacadeDocumenter, Filesystem, Validation, Foundation and Redis runtime suites passed, with their reported skips. --- src/filesystem/src/FilesystemManager.php | 12 + ...StorageMethodsClassReflectionExtension.php | 44 +-- .../src/ReturnTypes/FilesystemDiskHelper.php | 291 ++++++++++++++++++ ...anagerDynamicMethodReturnTypeExtension.php | 63 ++++ ...DynamicStaticMethodReturnTypeExtension.php | 63 ++++ src/support/src/Facades/Storage.php | 12 +- .../ForwardingFilesystemManager.php | 21 ++ .../Filesystem/SelectingFilesystemManager.php | 23 ++ ...ageMethodsClassReflectionExtensionTest.php | 56 ++++ tests/PHPStan/Type/data/facades.php | 6 +- tests/PHPStan/Type/data/storage-disks.php | 48 +++ .../application/config/filesystems.php | 15 + 12 files changed, 626 insertions(+), 28 deletions(-) create mode 100644 src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php create mode 100644 src/phpstan/src/ReturnTypes/FilesystemManagerDynamicMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/StorageDynamicStaticMethodReturnTypeExtension.php create mode 100644 tests/PHPStan/Fixtures/Filesystem/ForwardingFilesystemManager.php create mode 100644 tests/PHPStan/Fixtures/Filesystem/SelectingFilesystemManager.php create mode 100644 tests/PHPStan/Methods/StorageMethodsClassReflectionExtensionTest.php create mode 100644 tests/PHPStan/Type/data/storage-disks.php create mode 100644 tests/PHPStan/application/config/filesystems.php diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index 1dd164b86..e19ecd484 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -371,6 +371,8 @@ protected function diskPoolDefinition( /** * Create an instance of the local driver. + * + * @return LocalFilesystemAdapter */ public function createLocalDriver(array $config, string $name = 'local'): Filesystem { @@ -405,6 +407,8 @@ public function createLocalDriver(array $config, string $name = 'local'): Filesy /** * Create an instance of the ftp driver. + * + * @return FilesystemAdapter */ public function createFtpDriver(array $config): Filesystem { @@ -422,6 +426,8 @@ public function createFtpDriver(array $config): Filesystem /** * Create an instance of the sftp driver. + * + * @return FilesystemAdapter */ public function createSftpDriver(array $config): Filesystem { @@ -445,6 +451,8 @@ public function createSftpDriver(array $config): Filesystem /** * Create an instance of the Amazon S3 driver. + * + * @return AwsS3V3Adapter */ public function createS3Driver(array $config): Cloud { @@ -456,6 +464,8 @@ public function createS3Driver(array $config): Cloud /** * Create an instance of the read-through driver. + * + * @return ReadThroughFilesystem */ public function createReadThroughDriver(array $config, string $name = 'read-through'): Filesystem { @@ -669,6 +679,8 @@ protected function s3CredentialProvider(mixed $credentials): mixed /** * Create an instance of the Google Cloud Storage driver. + * + * @return GoogleCloudStorageAdapter */ public function createGcsDriver(array $config): Cloud { diff --git a/src/phpstan/src/Methods/StorageMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/StorageMethodsClassReflectionExtension.php index 71dcf54fe..a333f7d0c 100644 --- a/src/phpstan/src/Methods/StorageMethodsClassReflectionExtension.php +++ b/src/phpstan/src/Methods/StorageMethodsClassReflectionExtension.php @@ -4,9 +4,9 @@ namespace Hypervel\PHPStan\Methods; -use Hypervel\Filesystem\FilesystemAdapter; use Hypervel\Filesystem\FilesystemManager; use Hypervel\PHPStan\Reflection\StaticMethodReflection; +use Hypervel\PHPStan\ReturnTypes\FilesystemDiskHelper; use Hypervel\Support\Facades\Storage; use PHPStan\Analyser\OutOfClassScope; use PHPStan\DependencyInjection\AutowiredService; @@ -14,6 +14,8 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MethodsClassReflectionExtension; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; #[AutowiredService] class StorageMethodsClassReflectionExtension implements MethodsClassReflectionExtension @@ -21,8 +23,10 @@ class StorageMethodsClassReflectionExtension implements MethodsClassReflectionEx /** * Create a storage methods extension. */ - public function __construct(private ReflectionProvider $reflectionProvider) - { + public function __construct( + private ReflectionProvider $reflectionProvider, + private FilesystemDiskHelper $diskHelper, + ) { } /** @@ -34,15 +38,7 @@ public function hasMethod(ClassReflection $classReflection, string $methodName): return false; } - if ($this->reflectionProvider->getClass(FilesystemManager::class)->hasMethod($methodName)) { - return true; - } - - if ($this->reflectionProvider->getClass(FilesystemAdapter::class)->hasMethod($methodName)) { - return true; - } - - return false; + return $this->getTargetType($methodName)->hasMethod($methodName)->yes(); } /** @@ -52,14 +48,24 @@ public function getMethod( ClassReflection $classReflection, string $methodName, ): MethodReflection { - if ($this->reflectionProvider->getClass(FilesystemManager::class)->hasMethod($methodName)) { - return new StaticMethodReflection( - $this->reflectionProvider->getClass(FilesystemManager::class)->getMethod($methodName, new OutOfClassScope), - ); + return new StaticMethodReflection($this->getTargetType($methodName)->getMethod($methodName, new OutOfClassScope)); + } + + /** + * Get the type whose method a facade call runs: the manager's own, else the default disk's. + * + * Hypervel: upstream checks the manager with its adapter mixins first, then FilesystemAdapter. Those + * mixins would shadow the default disk, whose methods differ for a pooled disk, so the manager's + * declared mixins only apply when the default disk cannot be typed. + */ + private function getTargetType(string $methodName): Type + { + $manager = $this->reflectionProvider->getClass(FilesystemManager::class); + + if ($manager->hasNativeMethod($methodName)) { + return new ObjectType(FilesystemManager::class); } - return new StaticMethodReflection( - $this->reflectionProvider->getClass(FilesystemAdapter::class)->getMethod($methodName, new OutOfClassScope), - ); + return $this->diskHelper->getDefaultDiskType() ?? new ObjectType(FilesystemManager::class); } } diff --git a/src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php b/src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php new file mode 100644 index 000000000..5b64c710a --- /dev/null +++ b/src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php @@ -0,0 +1,291 @@ + + */ + private array $diskTypes = []; + + /** + * The bound manager and its reflection, or false when neither follows the stock resolution. + * + * @var null|array{FilesystemManager, ClassReflection}|false + */ + private false|array|null $manager = null; + + /** + * Create a new helper instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClosureTypeFactory $closureTypeFactory, + ) { + } + + /** + * Get the type of the disk a disk() or drive() call selects. + * + * Returns null when the call's declared return already describes the disk. + * + * @param list $receiverClasses the receiving manager classes, empty for the Storage facade + */ + public function getDiskType(string $methodName, ?Arg $name, Scope $scope, Type $declaredType, array $receiverClasses = []): ?Type + { + $manager = $this->getManager(); + + if ($manager === null) { + return null; + } + + [, $managerReflection] = $manager; + + foreach ($receiverClasses as $receiverClass) { + // A subclass other than the bound one resolves its disks in its own way. + if ($receiverClass !== FilesystemManager::class && $receiverClass !== $managerReflection->getName()) { + return null; + } + } + + // drive() delegates to disk(), so an override of either replaces the stock selection. + if ($this->overrides($managerReflection, 'disk') || $this->overrides($managerReflection, $methodName)) { + return null; + } + + $names = $this->getDiskNames($name, $scope); + + if ($names === null) { + return null; + } + + $types = []; + + foreach ($names as $diskName) { + $type = $this->getNamedDiskType($manager, $diskName); + + if ($type === null) { + return null; + } + + $types[] = $type; + } + + $diskType = TypeCombinator::union(...$types); + + // A facade or manager declaring a narrower disk keeps its declaration. + return $declaredType->isSuperTypeOf($diskType)->yes() ? $diskType : null; + } + + /** + * Get the type of the default disk, which the manager forwards unknown calls to. + */ + public function getDefaultDiskType(): ?Type + { + $manager = $this->getManager(); + + // __call() forwards to disk(), so an override of either replaces the stock forwarding. + if ($manager === null || $this->overrides($manager[1], '__call') || $this->overrides($manager[1], 'disk')) { + return null; + } + + return $this->getNamedDiskType($manager, null); + } + + /** + * Get the disk names a name argument can select, with null for the default disk. + * + * Returns null when the argument can name a disk the analysis does not know. + * + * @return null|list + */ + private function getDiskNames(?Arg $name, Scope $scope): ?array + { + if ($name === null) { + return [null]; + } + + if ($name->unpack) { + return null; + } + + $names = []; + + foreach ($scope->getType($name->value)->getFiniteTypes() as $type) { + if ($type->isNull()->yes()) { + $names[] = null; + } elseif ($type->getConstantStrings() !== []) { + $names[] = $type->getConstantStrings()[0]->getValue(); + } elseif ($type->getEnumCases() !== []) { + // enum_value() resolves a backed case to its value and a pure case to its name. + $enumCase = $type->getEnumCases()[0]; + $names[] = (string) ($enumCase->getBackingValueType()?->getConstantScalarValues()[0] ?? $enumCase->getEnumCaseName()); + } else { + return null; + } + } + + return $names === [] ? null : $names; + } + + /** + * Get the type of the disk with the given name, resolving it once per name. + * + * @param array{FilesystemManager, ClassReflection} $manager + */ + private function getNamedDiskType(array $manager, ?string $name): ?Type + { + if ($name === null || $name === '') { + try { + $name = $manager[0]->getDefaultDriver(); + } catch (InvalidArgumentException) { + // There is no configured default disk. + return null; + } + } + + if (! array_key_exists($name, $this->diskTypes)) { + $this->diskTypes[$name] = $this->resolveDiskType($manager, $name); + } + + return $this->diskTypes[$name]; + } + + /** + * Resolve the declared type of the named disk, following FilesystemManager::resolveConstructionDescriptor(). + * + * @param array{FilesystemManager, ClassReflection} $manager + */ + private function resolveDiskType(array $manager, string $name): ?Type + { + [$manager, $managerReflection] = $manager; + + try { + // getConfig() and prepareConstructionDescriptor() are internal to FilesystemManager. The latter + // collapses scoped disks into the disk they scope, so their stock construction is that disk's. + $facts = Closure::bind(function (string $name): array { + $config = $this->getConfig($name); + $driver = $this->prepareConstructionDescriptor($config, $name)['config']['driver'] ?? null; + + return [ + 'scoped' => ($config['driver'] ?? null) === 'scoped', + 'driver' => $driver, + 'creator' => is_string($driver) ? ($this->customCreators[$driver] ?? null) : null, + 'poolable' => in_array($driver, $this->poolableDrivers, true), + 'cached' => $this->disks[$name] ?? null, + ]; + }, $manager, FilesystemManager::class)($name); + } catch (InvalidArgumentException) { + // The disk is not configured, or its configuration is invalid. + return null; + } + + $driver = $facts['driver']; + + if (! is_string($driver) || $driver === '' || ($facts['scoped'] && $this->overrides($managerReflection, 'createScopedDriver'))) { + return null; + } + + if ($facts['creator'] instanceof Closure) { + $type = match (true) { + $facts['poolable'] => $this->getReturnType($managerReflection, 'createDriverPooledDisk'), + $this->overrides($managerReflection, 'callCustomCreator') => $this->getReturnType($managerReflection, 'callCustomCreator'), + default => $this->closureTypeFactory->fromClosureObject($facts['creator'])->getReturnType(), + }; + } elseif ($facts['poolable'] && in_array($driver, ['s3', 'gcs'], true)) { + $type = $this->getReturnType($managerReflection, 'createClientPooledDisk'); + } else { + $method = 'create' . Str::studly($driver) . 'Driver'; + + if (! $managerReflection->hasNativeMethod($method)) { + // The manager does not support the driver. + return null; + } + + $type = $this->getReturnType($managerReflection, $facts['poolable'] ? 'createDriverPooledDisk' : $method); + } + + // disk() keeps returning a disk it already resolved or was given through set() or a fake. + // Its class is exact, so a parent of the computed type does not fit either. + if ($facts['cached'] !== null && ! $type->isSuperTypeOf(ConstantTypeHelper::getTypeFromValue($facts['cached']))->yes()) { + return null; + } + + return $type; + } + + /** + * Get the bound filesystem manager and its reflection when it resolves disks the stock way. + * + * @return null|array{FilesystemManager, ClassReflection} + */ + private function getManager(): ?array + { + if ($this->manager === null) { + $manager = $this->resolve('filesystem'); + $this->manager = false; + + if ($manager instanceof FilesystemManager) { + $reflection = $this->reflectionProvider->getClass($manager::class); + + // These replace how a selected disk name becomes a disk. + if (! $this->overrides($reflection, 'get') && ! $this->overrides($reflection, 'resolve') && ! $this->overrides($reflection, 'getConfig')) { + $this->manager = [$manager, $reflection]; + } + } + } + + return $this->manager === false ? null : $this->manager; + } + + /** + * Determine whether the manager overrides the given FilesystemManager method. + */ + private function overrides(ClassReflection $classReflection, string $methodName): bool + { + return $classReflection->getNativeMethod($methodName)->getDeclaringClass()->getName() !== FilesystemManager::class; + } + + /** + * Get the declared return type of the manager's method. + */ + private function getReturnType(ClassReflection $classReflection, string $methodName): Type + { + return $classReflection->getNativeMethod($methodName)->getOnlyVariant()->getReturnType(); + } +} diff --git a/src/phpstan/src/ReturnTypes/FilesystemManagerDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/FilesystemManagerDynamicMethodReturnTypeExtension.php new file mode 100644 index 000000000..5ca6c90f6 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/FilesystemManagerDynamicMethodReturnTypeExtension.php @@ -0,0 +1,63 @@ +getName(), ['disk', 'drive'], true); + } + + /** + * Get the type of the selected disk. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + return $this->diskHelper->getDiskType( + $methodReflection->getName(), + $methodCall->getArgs()[0] ?? null, + $scope, + $methodReflection->getVariants()[0]->getReturnType(), + $scope->getType($methodCall->var)->getObjectClassNames(), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/StorageDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/StorageDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 000000000..576204f59 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/StorageDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,63 @@ +getName(), ['disk', 'drive'], strict: true); + } + + /** + * Get the type of the selected disk. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + return $this->diskHelper->getDiskType( + $methodReflection->getName(), + $methodCall->getArgs()[0] ?? null, + $scope, + $methodReflection->getVariants()[0]->getReturnType(), + ); + } +} diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php index 35aea7425..d0a84111f 100644 --- a/src/support/src/Facades/Storage.php +++ b/src/support/src/Facades/Storage.php @@ -14,13 +14,13 @@ /** * @method static \Hypervel\Filesystem\FilesystemManager addPoolableDriver(string $driver) * @method static \Hypervel\Contracts\Filesystem\Filesystem build(array|string $config, string|null $name = null) - * @method static \Hypervel\Contracts\Filesystem\Filesystem createFtpDriver(array $config) - * @method static \Hypervel\Contracts\Filesystem\Cloud createGcsDriver(array $config) - * @method static \Hypervel\Contracts\Filesystem\Filesystem createLocalDriver(array $config, string $name = 'local') - * @method static \Hypervel\Contracts\Filesystem\Filesystem createReadThroughDriver(array $config, string $name = 'read-through') - * @method static \Hypervel\Contracts\Filesystem\Cloud createS3Driver(array $config) + * @method static \Hypervel\Filesystem\FilesystemAdapter createFtpDriver(array $config) + * @method static \Hypervel\Filesystem\GoogleCloudStorageAdapter createGcsDriver(array $config) + * @method static \Hypervel\Filesystem\LocalFilesystemAdapter createLocalDriver(array $config, string $name = 'local') + * @method static \Hypervel\Filesystem\ReadThroughFilesystem createReadThroughDriver(array $config, string $name = 'read-through') + * @method static \Hypervel\Filesystem\AwsS3V3Adapter createS3Driver(array $config) * @method static \Hypervel\Contracts\Filesystem\Filesystem createScopedDriver(array $config, string|null $name = null) - * @method static \Hypervel\Contracts\Filesystem\Filesystem createSftpDriver(array $config) + * @method static \Hypervel\Filesystem\FilesystemAdapter createSftpDriver(array $config) * @method static \Hypervel\Contracts\Filesystem\Filesystem disk(UnitEnum|string|null $name = null) * @method static \Hypervel\Contracts\Filesystem\Filesystem drive(UnitEnum|string|null $name = null) * @method static \Hypervel\Filesystem\FilesystemManager extend(string $driver, \Closure $callback, bool $poolable = false) diff --git a/tests/PHPStan/Fixtures/Filesystem/ForwardingFilesystemManager.php b/tests/PHPStan/Fixtures/Filesystem/ForwardingFilesystemManager.php new file mode 100644 index 000000000..4ffe5e197 --- /dev/null +++ b/tests/PHPStan/Fixtures/Filesystem/ForwardingFilesystemManager.php @@ -0,0 +1,21 @@ + $managerClass + */ + #[DataProvider('managerProvider')] + public function testFacadeCallsForwardToTheDefaultDiskOnlyThroughTheStockManager(string $managerClass, string $returnType): void + { + $app = new Container; + $app->instance('config', new Repository(['filesystems' => [ + 'default' => 's3', + 'disks' => ['s3' => ['driver' => 's3']], + ]])); + $app->instance('filesystem', new $managerClass($app)); + + $reflectionProvider = $this->createReflectionProvider(); + $diskHelper = new FilesystemDiskHelper($reflectionProvider, self::getContainer()->getByType(ClosureTypeFactory::class)); + $diskHelper->setContainer($app); + + $method = (new StorageMethodsClassReflectionExtension($reflectionProvider, $diskHelper)) + ->getMethod($reflectionProvider->getClass(Storage::class), 'assertExists'); + + $this->assertSame($returnType, $method->getVariants()[0]->getReturnType()->describe(VerbosityLevel::precise())); + } + + /** @return iterable, string}> */ + public static function managerProvider(): iterable + { + // The pooled default disk's own assertExists() returns that disk. + yield 'stock manager' => [FilesystemManager::class, 'Hypervel\Filesystem\ClientPooledFilesystem']; + + // A manager whose disk() or __call() is overridden may not reach the default disk, so the + // manager's declared mixins apply. + yield 'overridden disk()' => [SelectingFilesystemManager::class, 'Hypervel\Filesystem\FilesystemAdapter']; + yield 'overridden __call()' => [ForwardingFilesystemManager::class, 'Hypervel\Filesystem\FilesystemAdapter']; + } +} diff --git a/tests/PHPStan/Type/data/facades.php b/tests/PHPStan/Type/data/facades.php index dcae39676..77e56feef 100644 --- a/tests/PHPStan/Type/data/facades.php +++ b/tests/PHPStan/Type/data/facades.php @@ -39,9 +39,9 @@ function test(): void assertType('Psr\Log\LoggerInterface', Log::getLogger()); - // Hypervel: disk() declares the filesystem contract, which a custom driver may implement. - assertType('Hypervel\Contracts\Filesystem\Filesystem', Storage::disk()); - assertType('Hypervel\Contracts\Filesystem\Filesystem', Storage::drive()); + // Hypervel: each disk is typed from the booted configuration, where the default disk is local. + assertType('Hypervel\Filesystem\LocalFilesystemAdapter', Storage::disk()); + assertType('Hypervel\Filesystem\LocalFilesystemAdapter', Storage::drive()); // REMOVED: Storage::cloud() cases; Hypervel omits the default-cloud shortcut. assertType('bool', Storage::disk()->deleteDirectory('foo')); assertType('bool', Storage::drive()->deleteDirectory('foo')); diff --git a/tests/PHPStan/Type/data/storage-disks.php b/tests/PHPStan/Type/data/storage-disks.php new file mode 100644 index 000000000..b3e57e415 --- /dev/null +++ b/tests/PHPStan/Type/data/storage-disks.php @@ -0,0 +1,48 @@ +drive('ftp')); + // A manager other than the bound one can hold other configuration. + assertType('Hypervel\Contracts\Filesystem\Filesystem', $tenantManager->disk('s3')); +} diff --git a/tests/PHPStan/application/config/filesystems.php b/tests/PHPStan/application/config/filesystems.php new file mode 100644 index 000000000..97af114d2 --- /dev/null +++ b/tests/PHPStan/application/config/filesystems.php @@ -0,0 +1,15 @@ + [ + 'ftp' => ['driver' => 'ftp', 'host' => 'localhost'], + 'custom' => ['driver' => 'custom'], + 'pooled-custom' => ['driver' => 'pooled-custom'], + 'scoped' => ['driver' => 'scoped', 'disk' => 's3', 'prefix' => 'tenant'], + 'unsupported' => ['driver' => 'unsupported'], + 'replaced' => ['driver' => 'local', 'root' => '/tmp'], + ], +]; From c24c1d70a34df035ba9a9b74ac51c9fbe1821352 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:13:59 +0000 Subject: [PATCH 094/303] feat: preserve Redis command casing and connection ownership Resolve case-insensitive Redis commands through canonical source tags while preserving exact macro names and the receiving class's registry. Avoid runtime reflection on unloaded registry owners and reuse the existing proxy reflection to prevent ancestor relookup from selecting another registry. Report connection-bound calls through pooled proxies and managers while allowing accessible native methods, custom handlers and held connections. Reuse the runtime rejection list, remove duplicate tags, correct held pipeline typing and remove the stale tag shadowing native client(). Validation: the combined checkpoint passed formatting, root/type PHPStan and 1077 analyzer tests. The affected Http, HttpServer, FacadeDocumenter, Filesystem, Validation, Foundation and Redis runtime suites passed, with their reported skips. --- .../MacroMethodsClassReflectionExtension.php | 8 ++ .../Methods/RedisCommandMethodsExtension.php | 114 +++++++++++++++ .../Rules/RedisConnectionBoundMethodRule.php | 133 ++++++++++++++++++ src/redis/src/RedisConnection.php | 25 +--- .../RedisConnectionBoundMethodRuleTest.php | 46 ++++++ .../data/redis-connection-bound-method.php | 45 ++++++ .../Type/data/redis-driver-phpredis.php | 90 ++++++++++++ tests/Redis/PackageMetadataTest.php | 48 +------ 8 files changed, 442 insertions(+), 67 deletions(-) create mode 100644 src/phpstan/src/Methods/RedisCommandMethodsExtension.php create mode 100644 src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php create mode 100644 tests/PHPStan/Rules/RedisConnectionBoundMethodRuleTest.php create mode 100644 tests/PHPStan/Rules/data/redis-connection-bound-method.php create mode 100644 tests/PHPStan/Type/data/redis-driver-phpredis.php diff --git a/src/phpstan/src/Methods/MacroMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/MacroMethodsClassReflectionExtension.php index 94f2fa096..99c7a6937 100644 --- a/src/phpstan/src/Methods/MacroMethodsClassReflectionExtension.php +++ b/src/phpstan/src/Methods/MacroMethodsClassReflectionExtension.php @@ -30,6 +30,7 @@ use function array_any; use function array_key_exists; use function array_keys; +use function class_exists; use function explode; use function in_array; use function is_array; @@ -142,6 +143,13 @@ private function findHostMacro(ClassReflection $classReflection, string $methodN } $refProperty = $macroClassReflection->getNativeReflection()->getProperty($macroTraitProperty); + + // Hypervel: macros are registered at runtime, so the registry of an owner the booted application + // never loaded, such as a class that only analysed files declare, is unavailable. + if (! class_exists($refProperty->getDeclaringClass()->getName(), false)) { + continue; + } + $macros = $refProperty->getValue(); if (! array_key_exists($methodName, $macros)) { diff --git a/src/phpstan/src/Methods/RedisCommandMethodsExtension.php b/src/phpstan/src/Methods/RedisCommandMethodsExtension.php new file mode 100644 index 000000000..a5a7b38a8 --- /dev/null +++ b/src/phpstan/src/Methods/RedisCommandMethodsExtension.php @@ -0,0 +1,114 @@ + + */ + private ?array $commandNames = null; + + /** + * Create a new extension instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + ) { + } + + /** + * Determine whether the method is a documented Redis command in another casing. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + $commandName = $this->getCommandName($classReflection, $methodName); + + return $commandName !== null && $classReflection->hasMethod($commandName); + } + + /** + * Get the documented command's reflection, as the receiver resolves it. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + $method = $classReflection->getMethod($this->getCommandNames()[strtolower($methodName)], new OutOfClassScope); + + // The facade's tags are its own. A connection's tag is RedisConnection's, and PHPStan looks the + // called name up again on a method's declaring class, whose macros may differ from the receiver's. + return $classReflection->getName() === Redis::class + ? $method + : new ProxiedMethodReflection($classReflection, $method, static fn (Type $returnType): Type => $returnType); + } + + /** + * Get the tag name of the command that the method name calls in another casing. + */ + private function getCommandName(ClassReflection $classReflection, string $methodName): ?string + { + if ($classReflection->is(RedisConnection::class)) { + // Macroable keeps macros in static::$macros, which a subclass shares unless it declares its own. + $macroClass = $classReflection->getNativeProperty('macros')->getDeclaringClass()->getName(); + } elseif ($classReflection->getName() === Redis::class) { + // The facade's calls reach the pooled connections, which share RedisConnection's macros. + $macroClass = RedisConnection::class; + } else { + return null; + } + + $commandName = $this->getCommandNames()[strtolower($methodName)] ?? null; + + // __call() runs a macro registered under the exact name before it lowercases the name. Macros are + // registered at runtime, so the registry of an owner the booted application never loaded is unavailable. + if ($commandName === null || $commandName === $methodName || (class_exists($macroClass, false) && $macroClass::hasMacro($methodName))) { + return null; + } + + return $commandName; + } + + /** + * Get RedisConnection's command tag names, keyed by their lowercase form. + * + * @return array + */ + private function getCommandNames(): array + { + if ($this->commandNames === null) { + $this->commandNames = []; + + foreach ($this->reflectionProvider->getClass(RedisConnection::class)->getMethodTags() as $name => $tag) { + $this->commandNames[strtolower($name)] = $name; + } + } + + return $this->commandNames; + } +} diff --git a/src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php b/src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php new file mode 100644 index 000000000..b487a8e12 --- /dev/null +++ b/src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php @@ -0,0 +1,133 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkRedisConnectionBoundMethods%')] +final class RedisConnectionBoundMethodRule implements Rule +{ + /** + * The lowercase method names the proxy rejects, as keys. + * + * @var null|array + */ + private ?array $connectionBoundMethods = null; + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Report connection-bound method calls that reach the proxy's __call(). + * + * @param MethodCall $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Identifier) { + return []; + } + + $methodName = $node->name->toString(); + + if (! isset($this->getConnectionBoundMethods()[strtolower($methodName)])) { + return []; + } + + $classReflections = $scope->getType($node->var)->getObjectClassReflections(); + + if ($classReflections === []) { + return []; + } + + foreach ($classReflections as $classReflection) { + if (! $this->reachesProxyCall($classReflection, $methodName, $scope)) { + return []; + } + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Redis connection method %s() must be called on a held Redis connection. Use Redis::withConnection() or Redis::withPinnedConnection().', + $methodName, + ))->identifier('hypervel.redisConnectionBoundMethod')->build(), + ]; + } + + /** + * Determine whether calling the method on the receiver runs the proxy's stock __call(). + */ + private function reachesProxyCall(ClassReflection $classReflection, string $methodName, Scope $scope): bool + { + // A native method the caller can reach runs instead, such as the proxy's own getConnection() calls. + if ($classReflection->hasNativeMethod($methodName) && $scope->canCallMethod($classReflection->getNativeMethod($methodName))) { + return false; + } + + if ($classReflection->is(RedisProxy::class)) { + return $this->declares($classReflection, '__call', RedisProxy::class); + } + + // The manager forwards to the proxy its connection() returns. + return $classReflection->is(RedisManager::class) + && $this->declares($classReflection, '__call', RedisManager::class) + && $this->declares($classReflection, 'connection', RedisManager::class); + } + + /** + * Determine whether the receiver's method is the given class's own, not an override. + */ + private function declares(ClassReflection $classReflection, string $methodName, string $className): bool + { + return $classReflection->getNativeMethod($methodName)->getDeclaringClass()->getName() === $className; + } + + /** + * Get the lowercase method names the proxy rejects, read from the proxy itself. + * + * @return array + */ + private function getConnectionBoundMethods(): array + { + // The constant is private to the proxy, so it is read in the proxy's scope. + return $this->connectionBoundMethods ??= array_fill_keys( + Closure::bind(static fn (): array => self::CONNECTION_BOUND_METHODS, null, RedisProxy::class)(), + true, + ); + } +} diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index c8126ac11..02a9a2192 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -36,7 +36,6 @@ * @method mixed set(string $key, mixed $value, mixed $expireResolution = null, int|null $expireTTL = null, string|null $flag = null) Set the value of a key * @method array|false|Redis|RedisCluster mget(array $keys) Get the values of multiple keys * @method bool|int|Redis|RedisCluster setnx(string $key, mixed $value) Set key if not exists - * @method bool|int|Redis|RedisCluster setNx(string $key, mixed $value) Set key if not exists * @method array|false|Redis|RedisCluster hmget(string $key, array $fields) Get hash field values * @method bool|Redis|RedisCluster hmset(string $key, array $fieldValues) Set hash field values * @method bool|int|Redis|RedisCluster hsetnx(string $hash, string $key, mixed $value) Set hash field if not exists @@ -59,7 +58,7 @@ * @method mixed evalsha(string $script, int $numkeys, mixed ...$arguments) Evaluate Lua script by SHA1 * @method mixed flushdb(mixed ...$arguments) Flush database * @method mixed executeRaw(array $parameters) Execute raw Redis command - * @method mixed pipeline(callable|null $callback = null) Execute commands in a pipeline + * @method bool|Redis pipeline() Execute commands in a pipeline * @method array|false|Redis|RedisCluster smembers(string $key) Get all set members * @method false|int|Redis|RedisCluster hdel(string $key, string $field, string ...$other_fields) Delete hash fields * @method false|int|Redis|RedisCluster zrem(mixed $key, mixed $member, mixed ...$other_members) Remove sorted set members @@ -78,8 +77,6 @@ * @method false|int|Redis|RedisCluster bitcount(string $key, int $start = 0, int $end = -1, bool $bybit = false) * @method false|int|Redis|RedisCluster bitop(string $operation, string $deskey, string $srckey, string ...$other_keys) * @method false|int|Redis|RedisCluster bitpos(string $key, bool $bit, int $start = 0, int $end = -1, bool $bybit = false) - * @method null|array|false|Redis|RedisCluster blPop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking left pop from list - * @method null|array|false|Redis|RedisCluster brPop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking right pop from list * @method false|Redis|RedisCluster|string brpoplpush(string $src, string $dst, float|int $timeout) * @method array|false|Redis|RedisCluster bzPopMax(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) * @method array|false|Redis|RedisCluster bzPopMin(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) @@ -88,7 +85,6 @@ * @method null|array|false|Redis|RedisCluster blmpop(float $timeout, array $keys, string $from, int $count = 1) * @method null|array|false|Redis|RedisCluster lmpop(array $keys, string $from, int $count = 1) * @method bool clearLastError() - * @method mixed client(string $opt = '', mixed ...$args) * @method mixed command(string|null $opt = null, mixed ...$args) * @method mixed config(string $operation, array|string|null $key_or_settings = null, string|null $value = null) * @method bool connect(string $host, int $port = 6379, float $timeout = 0, string|null $persistent_id = null, int $retry_interval = 0, float $read_timeout = 0, array|null $context = null) @@ -115,7 +111,6 @@ * @method mixed fcall(string $fn, array $keys = [], array $args = []) * @method mixed fcall_ro(string $fn, array $keys = [], array $args = []) * @method bool|Redis|RedisCluster flushAll(bool|null $sync = null) - * @method mixed flushDB(mixed ...$arguments) Flush database * @method array|bool|Redis|string function(string $operation, mixed ...$args) * @method false|int|Redis|RedisCluster geoadd(string $key, float $lng, float $lat, string $member, mixed ...$other_triples_and_options) * @method false|float|Redis|RedisCluster geodist(string $key, string $src, string $dst, string|null $unit = null) @@ -146,7 +141,6 @@ * @method array getTransferredBytes() * @method void clearTransferredBytes() * @method array|false|Redis|RedisCluster getWithMeta(string $key) - * @method false|int|Redis|RedisCluster hDel(string $key, string $field, string ...$other_fields) Delete hash fields * @method array|false|Redis|RedisCluster hexpire(string $key, int $ttl, array $fields, string|null $mode = null) * @method array|false|Redis|RedisCluster hpexpire(string $key, int $ttl, array $fields, string|null $mode = null) * @method array|false|Redis|RedisCluster hexpireat(string $key, int $time, array $fields, string|null $mode = null) @@ -157,20 +151,13 @@ * @method array|false|Redis|RedisCluster hpexpiretime(string $key, array $fields) * @method array|false|Redis|RedisCluster hpersist(string $key, array $fields) * @method bool|Redis|RedisCluster hExists(string $key, string $field) - * @method mixed hGet(string $key, string $member) Get hash field value * @method array|false|Redis|RedisCluster hGetAll(string $key) * @method mixed hGetWithMeta(string $key, string $member) * @method array|false|Redis|RedisCluster hgetdel(string $key, array $fields) * @method array|false|Redis|RedisCluster hgetex(string $key, array $fields, string|array|null $expiry = null) * @method false|int|Redis|RedisCluster hIncrBy(string $key, string $field, int $value) * @method false|float|Redis|RedisCluster hIncrByFloat(string $key, string $field, float $value) - * @method array|false|Redis|RedisCluster hKeys(string $key) Get all hash field names - * @method false|int|Redis|RedisCluster hLen(string $key) Get number of hash fields - * @method array|false|Redis|RedisCluster hMget(string $key, array $fields) Get hash field values - * @method bool|Redis|RedisCluster hMset(string $key, array $fieldValues) Set hash field values * @method array|false|Redis|RedisCluster|string hRandField(string $key, array|null $options = null) - * @method false|int|Redis|RedisCluster hSet(string $key, mixed ...$fields_and_vals) Set hash field values - * @method bool|int|Redis|RedisCluster hSetNx(string $hash, string $key, mixed $value) Set hash field if not exists * @method false|int|Redis|RedisCluster hsetex(string $key, array $fields, array|null $expiry = null) * @method false|int|Redis|RedisCluster hStrLen(string $key, string $field) * @method array|false|Redis|RedisCluster hVals(string $key) @@ -181,7 +168,6 @@ * @method bool isConnected() * @method array|false|Redis|RedisCluster keys(string $pattern) * @method false|int|Redis|RedisCluster lInsert(string $key, string $pos, mixed $pivot, mixed $value) - * @method false|int|Redis|RedisCluster lLen(string $key) Get list length * @method false|Redis|RedisCluster|string lMove(string $src, string $dst, string $wherefrom, string $whereto) * @method false|Redis|RedisCluster|string blmove(string $src, string $dst, string $wherefrom, string $whereto, float $timeout) * @method array|bool|Redis|RedisCluster|string lPop(string $key, int $count = 0) @@ -230,10 +216,8 @@ * @method array|false|Redis|RedisCluster sInter(array|string $key, string ...$other_keys) * @method false|int|Redis|RedisCluster sintercard(array $keys, int $limit = -1) * @method false|int|Redis|RedisCluster sInterStore(array|string $key, string ...$other_keys) - * @method array|false|Redis|RedisCluster sMembers(string $key) Get all set members * @method array|false|Redis|RedisCluster sMisMember(string $key, string $member, string ...$other_members) * @method bool|Redis|RedisCluster sMove(string $src, string $dst, mixed $value) - * @method mixed sPop(string $key, int $count = 0) Remove and return random set member * @method mixed sRandMember(string $key, int $count = 0) * @method array|false|Redis|RedisCluster sUnion(string $key, string ...$other_keys) * @method false|int|Redis|RedisCluster sUnionStore(string $dst, string $key, string ...$other_keys) @@ -253,7 +237,6 @@ * @method mixed slowlog(string $operation, int $length = 0) * @method mixed sort(string $key, array|null $options = null) * @method mixed sort_ro(string $key, array|null $options = null) - * @method false|int|Redis|RedisCluster srem(string $key, mixed $value, mixed ...$other_values) Remove members from set * @method false|int|Redis|RedisCluster strlen(string $key) * @method array|bool|Redis sunsubscribe(array $channels) * @method bool|Redis swapdb(int $src, int $dst) @@ -293,9 +276,6 @@ * @method array|bool|Redis|RedisCluster xreadgroup(string $group, string $consumer, array $streams, int $count = 1, int $block = 1) * @method array|bool|Redis|RedisCluster xrevrange(string $key, int|string $end, int|string $start, int $count = -1) * @method false|int|Redis|RedisCluster xtrim(string $key, int|string $threshold, bool $approx = false, bool $minid = false, int $limit = -1) - * @method false|float|int|Redis|RedisCluster zAdd(string $key, array|float $score_or_options, mixed ...$more_scores_and_mems) Add members to sorted set - * @method false|int|Redis|RedisCluster zCard(string $key) Get sorted set cardinality - * @method false|int|Redis|RedisCluster zCount(string $key, float|int|string $start, float|int|string $end) Count sorted set members by score range * @method false|float|Redis|RedisCluster zIncrBy(string $key, float $value, mixed $member) * @method false|int|Redis|RedisCluster zLexCount(string $key, string $min, string $max) * @method array|false|Redis|RedisCluster zMscore(string $key, mixed $member, mixed ...$other_members) @@ -303,17 +283,14 @@ * @method array|false|Redis|RedisCluster zPopMin(string $key, int|null $count = null) * @method array|false|Redis|RedisCluster zRange(string $key, string|int $start, string|int $end, array|bool|null $options = null) * @method array|false|Redis|RedisCluster zRangeByLex(string $key, string $min, string $max, int $offset = -1, int $count = -1) - * @method array|false|Redis|RedisCluster zRangeByScore(string $key, float|int|string $min, float|int|string $max, array $options = []) Get sorted set members by score range * @method false|int|Redis|RedisCluster zrangestore(string $dstkey, string $srckey, string $start, string $end, array|bool|null $options = null) * @method array|Redis|RedisCluster|string zRandMember(string $key, array|null $options = null) * @method false|int|Redis|RedisCluster zRank(string $key, mixed $member) - * @method false|int|Redis|RedisCluster zRem(mixed $key, mixed $member, mixed ...$other_members) Remove sorted set members * @method false|int|Redis|RedisCluster zRemRangeByLex(string $key, string $min, string $max) * @method false|int|Redis|RedisCluster zRemRangeByRank(string $key, int $start, int $end) * @method false|int|Redis|RedisCluster zRemRangeByScore(string $key, float|int|string $start, float|int|string $end) * @method array|false|Redis|RedisCluster zRevRange(string $key, int $start, int $end, mixed $scores = null) * @method array|false|Redis|RedisCluster zRevRangeByLex(string $key, string $max, string $min, int $offset = -1, int $count = -1) - * @method array|false|Redis|RedisCluster zRevRangeByScore(string $key, float|int|string $max, float|int|string $min, array $options = []) Get sorted set members by score range (reverse) * @method false|int|Redis|RedisCluster zRevRank(string $key, mixed $member) * @method false|float|Redis|RedisCluster zScore(string $key, mixed $member) * @method array|false|Redis|RedisCluster zdiff(array $keys, array|null $options = null) diff --git a/tests/PHPStan/Rules/RedisConnectionBoundMethodRuleTest.php b/tests/PHPStan/Rules/RedisConnectionBoundMethodRuleTest.php new file mode 100644 index 000000000..a4471c837 --- /dev/null +++ b/tests/PHPStan/Rules/RedisConnectionBoundMethodRuleTest.php @@ -0,0 +1,46 @@ + */ +class RedisConnectionBoundMethodRuleTest extends RuleTestCase +{ + private const string MESSAGE = 'Redis connection method %s() must be called on a held Redis connection. Use Redis::withConnection() or Redis::withPinnedConnection().'; + + protected function getRule(): Rule + { + return new RedisConnectionBoundMethodRule; + } + + #[Test] + public function itReportsConnectionBoundMethodsThatReachTheProxy(): void + { + $this->analyse([__DIR__ . '/data/redis-connection-bound-method.php'], [ + [sprintf(self::MESSAGE, 'client'), 36], + [sprintf(self::MESSAGE, 'AUTH'), 37], + [sprintf(self::MESSAGE, 'release'), 38], + [sprintf(self::MESSAGE, 'getConnection'), 39], + ]); + } + + #[Test] + public function itAllowsTheProxysOwnProtectedMethodCalls(): void + { + $this->analyse([__DIR__ . '/../../../src/redis/src/RedisProxy.php'], []); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/data/redis-connection-bound-method.php b/tests/PHPStan/Rules/data/redis-connection-bound-method.php new file mode 100644 index 000000000..5c2cff841 --- /dev/null +++ b/tests/PHPStan/Rules/data/redis-connection-bound-method.php @@ -0,0 +1,45 @@ +client(); + $proxy->AUTH('secret'); + $manager->release(); + $proxy->getConnection(); + + $held->client(); + $proxy->get('key'); + $clientProxy->client(); + $forwardingProxy->client(); +} diff --git a/tests/PHPStan/Type/data/redis-driver-phpredis.php b/tests/PHPStan/Type/data/redis-driver-phpredis.php new file mode 100644 index 000000000..fcdef2aa4 --- /dev/null +++ b/tests/PHPStan/Type/data/redis-driver-phpredis.php @@ -0,0 +1,90 @@ +|false', Redis::pipeline(function ($pipe): void { + $pipe->set('foo', 'bar'); + })); + assertType('array|false', Redis::transaction(fn ($transaction) => $transaction->incr('foo'))); + assertType('Redis', Redis::pipeline()); + assertType('Redis|RedisCluster', Redis::transaction()); + + // #907 — Laravel set arity (not ext-redis's 2–3 arg set) + // Hypervel: SET with the GET option returns the previous decoded value. + assertType('mixed', Redis::set('key', 'value', 'EX', 60)); + assertType('mixed', Redis::set('key', 'value', 'EX', 60, 'NX')); + + // #907 — phpredis return types (via PhpRedisConnection @mixin \Redis) + assertType('bool|Redis|RedisCluster', Redis::hexists('h', 'field')); + assertType('float|Redis|RedisCluster|false', Redis::zscore('z', 'member')); + + // #2453 — Laravel eval order: script, numKeys, ...args + assertType('mixed', Redis::eval('return 1', 0)); + assertType('mixed', Redis::eval('return redis.call("get", KEYS[1])', 1, 'mykey')); + + // #14 smoke + assertType('int|Redis|RedisCluster|false', Redis::publish('channel', 'message')); + assertType('array|Redis|RedisCluster|false', Redis::hmget('h', ['field1', 'field2'])); + assertType('bool|Redis|RedisCluster', Redis::expire('foo', 3)); + + $connection = Redis::connection(); + assertType('Hypervel\Redis\RedisProxy', $connection); + assertType('array|false', $connection->pipeline(function ($pipe): void { + $pipe->get('foo'); + })); +} + +/** + * Hypervel: connections lowercase a command's name before running it, so every casing + * calls the documented command. A macro is matched by its exact name first. + */ +function commandCasing(PhpRedisConnection $held): void +{ + assertType('bool|Redis|RedisCluster', Redis::hExists('h', 'field')); + assertType('bool|Redis|RedisCluster', Redis::HEXISTS('h', 'field')); + assertType('float|Redis|RedisCluster|false', Redis::connection()->ZSCORE('z', 'member')); + assertType('bool|int|Redis|RedisCluster', Redis::setNx('key', 'value')); + assertType('bool|Redis|RedisCluster', $held->hexists('h', 'field')); + assertType('bool|Redis', $held->pipeline()); + + // The bootstrap registers zrank as a macro, which replaces the zRank command for that exact name. + assertType('string', $held->zrank('z', 'member')); + assertType('int|Redis|RedisCluster|false', $held->ZRANK('z', 'member')); + + // Macro names are not case-folded. + assertType('int', $held->customCommand()); + assertType('*ERROR*', $held->customcommand()); +} + +/** + * Hypervel: connections that only analysed files declare, which the booted application never loaded. + */ +class OwnRegistryConnection extends PhpRedisConnection +{ + protected static array $macros = []; +} + +class InheritedRegistryConnection extends PhpRedisConnection +{ +} + +function unloadedConnections(OwnRegistryConnection $own, InheritedRegistryConnection $inherited): void +{ + // The unloaded class's own registry is unavailable, so its calls are commands, not the parent's macros. + assertType('float|Redis|RedisCluster|false', $own->zscore('z', 'member')); + assertType('int|Redis|RedisCluster|false', $own->zrank('z', 'member')); + + // The loaded parent's registry still supplies its macro. + assertType('string', $inherited->zrank('z', 'member')); +} diff --git a/tests/Redis/PackageMetadataTest.php b/tests/Redis/PackageMetadataTest.php index a8de68806..47f32ca2c 100644 --- a/tests/Redis/PackageMetadataTest.php +++ b/tests/Redis/PackageMetadataTest.php @@ -90,54 +90,16 @@ public function testConnectionBoundMethodsAreExcludedFromFacadeDocumentation(): } } - public function testCaseInsensitiveCommandAliasesHaveIdenticalSignatures(): void + public function testCommandsAreDocumentedOncePerCaseInsensitiveName(): void { $docblock = (new ReflectionClass(RedisConnection::class))->getDocComment(); $this->assertIsString($docblock); - preg_match_all( - '/@method\s+(\S+)\s+([A-Za-z_][A-Za-z0-9_]*)\(([^)]*)\)/', - $docblock, - $matches, - PREG_SET_ORDER, - ); - - $methods = []; + preg_match_all('/@method\s+\S+\s+([A-Za-z_][A-Za-z0-9_]*)\(/', $docblock, $matches); - foreach ($matches as $match) { - $methods[strtolower($match[2])][] = "{$match[1]}({$match[3]})"; - } + // Commands are case-insensitive, and the static analysis matches every casing to the one tag. + $counts = array_count_values(array_map(strtolower(...), $matches[1])); - foreach ([ - 'setnx', - 'hmget', - 'hmset', - 'hsetnx', - 'hget', - 'hset', - 'llen', - 'blpop', - 'brpop', - 'spop', - 'srem', - 'zadd', - 'zcard', - 'zcount', - 'zrangebyscore', - 'zrevrangebyscore', - 'flushdb', - 'smembers', - 'hdel', - 'zrem', - 'hlen', - 'hkeys', - ] as $method) { - $this->assertCount(2, $methods[$method] ?? [], "Expected two case variants for [{$method}]."); - $this->assertCount( - 1, - array_unique($methods[$method]), - "Expected both case variants of [{$method}] to have the same signature.", - ); - } + $this->assertSame([], array_keys(array_filter($counts, static fn (int $count): bool => $count > 1))); } public function testFacadeDocumentsManagerAndMacroSurfaces(): void From 754884f88a1621997258bf519c24cfd17c43cc9f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:13:59 +0000 Subject: [PATCH 095/303] fix: reject cached manager drivers outside the inferred class Require definite compatibility between an already-created driver's exact class and the inferred creator result. A cached parent instance must not gain child-only methods after extend() registers a child creator. Add the inheritance regression alongside the existing incompatible-driver coverage. Validation: the combined checkpoint passed formatting, root/type PHPStan and 1077 analyzer tests. The affected Http, HttpServer, FacadeDocumenter, Filesystem, Validation, Foundation and Redis runtime suites passed, with their reported skips. --- .../src/Methods/ManagersMethodsExtension.php | 3 +- .../Managers/ResolvedChildDriverManager.php | 29 +++++++++++++++++++ .../Fixtures/Managers/TabbedCsvDriver.php | 19 ++++++++++++ tests/PHPStan/Type/data/managers.php | 5 ++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Fixtures/Managers/ResolvedChildDriverManager.php create mode 100644 tests/PHPStan/Fixtures/Managers/TabbedCsvDriver.php diff --git a/src/phpstan/src/Methods/ManagersMethodsExtension.php b/src/phpstan/src/Methods/ManagersMethodsExtension.php index 0cce8c5a7..bafbefbda 100644 --- a/src/phpstan/src/Methods/ManagersMethodsExtension.php +++ b/src/phpstan/src/Methods/ManagersMethodsExtension.php @@ -180,9 +180,10 @@ private function resolveDriverType(ClassReflection $classReflection): ?Type } // Manager::driver() keeps returning a driver it already created, even after extend() registers another creator. + // Its class is exact, so a parent of the computed type does not fit either. $cached = $manager->getDrivers()[$driver] ?? null; - if ($cached !== null && $type->isSuperTypeOf(ConstantTypeHelper::getTypeFromValue($cached))->no()) { + if ($cached !== null && ! $type->isSuperTypeOf(ConstantTypeHelper::getTypeFromValue($cached))->yes()) { return null; } diff --git a/tests/PHPStan/Fixtures/Managers/ResolvedChildDriverManager.php b/tests/PHPStan/Fixtures/Managers/ResolvedChildDriverManager.php new file mode 100644 index 000000000..db35bb2c9 --- /dev/null +++ b/tests/PHPStan/Fixtures/Managers/ResolvedChildDriverManager.php @@ -0,0 +1,29 @@ +toJson()); + // The cached plain CSV driver is not the subclass the later creator declares. + assertType('*ERROR*', $resolvedChild->exportTabbed()); + // Analysis never calls an overriding getCreatorMethod(), so it cannot tell which creator runs. assertType('*ERROR*', $renaming->export([])); } From caa5712594915f2b4312e7fa7ade7eb4be87d74d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:13:59 +0000 Subject: [PATCH 096/303] test: cover source-declared testing mock types Port mock, partialMock and spy integration coverage using the source conditional return types. No dedicated return-type extension is needed. Remove the obsolete partialMock existence check and preserve normal base-test application creation. Validation: the combined checkpoint passed formatting, root/type PHPStan and 1077 analyzer tests. The affected Http, HttpServer, FacadeDocumenter, Filesystem, Validation, Foundation and Redis runtime suites passed, with their reported skips. --- tests/PHPStan/Integration/IntegrationTest.php | 1 + .../Integration/data/test-case-extension.php | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/PHPStan/Integration/data/test-case-extension.php diff --git a/tests/PHPStan/Integration/IntegrationTest.php b/tests/PHPStan/Integration/IntegrationTest.php index 41c5b73b5..db85532e5 100644 --- a/tests/PHPStan/Integration/IntegrationTest.php +++ b/tests/PHPStan/Integration/IntegrationTest.php @@ -41,6 +41,7 @@ public static function dataIntegrationTests(): iterable self::getContainer(); yield [__DIR__ . '/data/blade-view.php']; + yield [__DIR__ . '/data/test-case-extension.php']; yield [ __DIR__ . '/data/view-string-content.php', diff --git a/tests/PHPStan/Integration/data/test-case-extension.php b/tests/PHPStan/Integration/data/test-case-extension.php new file mode 100644 index 000000000..85b18f8fa --- /dev/null +++ b/tests/PHPStan/Integration/data/test-case-extension.php @@ -0,0 +1,45 @@ +testMockMethod(); + } + + public function testPartialMockMethod(): void + { + (new TestTestCase('foo'))->testPartialMockMethod(); + } + + public function testSpyMethod(): void + { + (new TestTestCase('foo'))->testSpyMethod(); + } +} + +// Hypervel: the testing trait declares these mock types in source, and the base TestCase +// already creates the application and always has partialMock(), so upstream's +// createApplication() override and method_exists() guard are omitted. +class TestTestCase extends \Hypervel\Foundation\Testing\TestCase +{ + public function testMockMethod(): void + { + $mock = $this->mock(User::class); + $mock->accounts(); + } + + public function testPartialMockMethod(): void + { + $mock = $this->partialMock(User::class); + $mock->accounts(); + } + + public function testSpyMethod(): void + { + $mock = $this->spy(User::class); + $mock->accounts(); + } +} From d001a7159c3c46dc08c8893ed6f1dd4a6239dbd7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:13:59 +0000 Subject: [PATCH 097/303] docs: integrate request storage and Redis analysis Register the completed extensions, fixtures and default-enabled Redis connection-bound rule with its normal configuration switch. Document input unions, configured disk inference and held Redis connections. Update the final design and remaining port checklist, removing completed entries and rejected limitation claims. Keep shared result-cache verification and remaining integration work explicit. Validation: the combined checkpoint passed formatting, root/type PHPStan and 1077 analyzer tests. The affected Http, HttpServer, FacadeDocumenter, Filesystem, Validation, Foundation and Redis runtime suites passed, with their reported skips. --- .../2026-11-04-hypervel-phpstan-checklist.md | 8 +-- docs/plans/2026-11-04-hypervel-phpstan.md | 17 +++++- src/docs/static-analysis.md | 21 +++++++ src/phpstan/README.md | 3 + src/phpstan/extension.neon | 56 +++++++++++++++++++ tests/PHPStan/Type/GeneralTypeTest.php | 9 +++ tests/PHPStan/bootstrap.php | 23 ++++++++ 7 files changed, 131 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index 7b3bfdba9..44851093b 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -28,7 +28,7 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `Properties/*` except the ported `HigherOrderCollectionProxyPropertyExtension` (16 files, including `Schema/MySqlDataTypeToPhpTypeConverter.php`) - copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection}.php` - merge into moved scope reflections: `Reflection/ModelScopeMethodReflection.php` -- copy: `ReturnTypes/{AppFacadeEnvironmentReturnTypeExtension,EloquentBuilderExtension,FactoryDynamicMethodReturnTypeExtension,FormRequestSafeDynamicMethodReturnTypeExtension,ModelDynamicStaticMethodReturnTypeExtension,ModelFactoryDynamicStaticMethodReturnTypeExtension,ModelOnlyDynamicMethodReturnTypeExtension,ModelSerializationDynamicMethodReturnTypeExtension,RelationCollectionExtension,RequestFileExtension,RequestRouteExtension,StorageDynamicStaticMethodReturnTypeExtension,TestCaseExtension}.php` +- copy: `ReturnTypes/{EloquentBuilderExtension,FactoryDynamicMethodReturnTypeExtension,ModelDynamicStaticMethodReturnTypeExtension,ModelFactoryDynamicStaticMethodReturnTypeExtension,ModelOnlyDynamicMethodReturnTypeExtension,ModelSerializationDynamicMethodReturnTypeExtension,RelationCollectionExtension}.php` - copy with the Eloquent collection port: `ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php`, moving `common/EloquentCollection.stub`'s `map()`/`mapWithKeys()` conditionals into the Eloquent `Collection` source - copy: `Rules/*` except below and the ported `ArrKeyExistenceRule`, auth request-scope, config, console, `NoEnvCallsOutsideOfConfigRule`, `NoMissingTranslationsRule`, `NoUnnecessaryEnumerableToArrayCallsRule`, `UnusedViewsRule` and `UselessConstructs/` rules (including `Queue/`) - omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` @@ -46,9 +46,10 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `common/*` except below, the ported view stubs, `Foundation/Console/Kernel.stub` (its `command()` closure type moved to source; the stub keeps only the empty declaration the `Artisan` mixin needs for stub validation) and the stubs whose types are now in source: `Collection` (except below), `Conditionable`, `Enumerable`, `EnumeratesValues`, `HigherOrderProxies` and `Support/{Arr,Benchmark,Reflector,Str,Tappable}` (60 files) - merge into `common/Helpers.stub` with their topics: the `cookie()`, `logger()`, `logs()`, `redirect()`, `request()`, `session()` and `url()` helper entries, whose conditional returns the source already declares (only `view()` is ported; `optional()` is typed in source and `OptionalExtension`; the stub records the omitted helpers) -- merge with their topic flows: the upstream typing for the declaration-only stubs that `common/Facades.stub`'s mixins need for stub validation: Events `dispatch()`/`until()` conditionals, `Connection::getQueryLog()` shape, Schema `getColumnListing()`, the `Request::header()` conditional, and the Cache `Repository` `ArrayAccess` implements (check Hypervel's enum keys) +- merge with their topic flows: the upstream typing for the declaration-only stubs that `common/Facades.stub`'s mixins need for stub validation: Events `dispatch()`/`until()` conditionals, `Connection::getQueryLog()` shape, Schema `getColumnListing()`, and the Cache `Repository` `ArrayAccess` implements (check Hypervel's enum keys) - add to source with the routing stubs: upstream `common/Route.stub`'s `methods()` and `middleware()` element types as `Route` PHPDoc in `src/routing`, with a `types/` fixture (the stub keeps only the empty declaration stub validation needs) -- omit: `common/Redis/{PredisConnection,PredisFacade}.stub`, `common/Gate.stub` (Hypervel's `Gate::forUser()` and its contract already return `static`) +- omit: `common/{Http/InteractsWithInput,Foundation/Http/FormRequest,Support/ValidatedInput}.stub`; `common/Http/Request.stub` stays an empty declaration (the source declares the request getter, `safe()` and `validated()` types) +- omit: `common/Redis/*.stub` and `common/Contracts/Redis.stub` (the source tags, `MultiExec` and mixins type the pooled surface; `@mixin \Redis` would drop the cluster unions and expose connection-bound methods), `common/Gate.stub` (Hypervel's `Gate::forUser()` and its contract already return `static`) - add to source: upstream `common/MailMessage.stub`'s property shapes (`$view`, `$from`, `$replyTo`, `$attachments`, `$rawAttachments`) as `MailMessage` PHPDoc in `src/notifications`, with a `types/` fixture - omit: `common/Contracts/Translation.stub` (only upstream's omitted `trans()` helper stub references it) - merge into `common/`: `13.3.0/{EloquentBuilder,Model}.stub`, `13.35.0/{BelongsToMany,HasOneOrMany}.stub`, `13.37.0/HasOneOrManyThrough.stub` @@ -66,5 +67,4 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - merge into `Type/CollectionDynamicReturnTypeExtensionTest.php` with the model fixtures: `collection-filter.php`, `collection-reject.php`, `collection-where.php` (they need `accounts()->active()->get()` to return `AccountCollection`) - omit: `Type/data/collection-generic-static-methods-l11.php` (Laravel 11's Eloquent `partition()` returned `static`; Hypervel follows the Laravel 12 base collection) - merge: the remaining `Type/GeneralTypeTest.php` data entries, and its `data/config-with-migrations.neon` configuration once migrations are ported; `higher-order-collection-proxy-methods.php` needs model properties and Hypervel's proxy results (`avg` is `float|int|null`, `max`/`min` include `null`) -- merge: `Type/{RedisDriverNullTypeTest,RedisDriverPhpRedisTypeTest}.php` and their data into one phpredis test, with the Redis stubs; recheck `Type/data/facades.php`'s Redis expectations, which reflect the current source types; omit `Type/RedisDriverPredisTypeTest.php` with `redis-driver-predis.*` - copy: `Unit/*Test.php` except `Support/{BootstrapErrorHandlerTest,RecursiveDirectoryIteratorTest}.php` (11 classes, including `SQL/` and `Support/`), `Unit/data/*` diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index d5e6b7e7a..397da0a91 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -21,6 +21,7 @@ Remaining work: - Also completed: the helpers and collections flow (higher-order proxies, `Arr` types and `ArrKeyExistenceRule`, collection narrowing, helper extensions, the enumerable `toArray()` and useless-construct rules, collection stub types moved to source), with its bleeding-edge type and integration tests except the model-dependent fixtures listed in the checklist. - Also completed: Components' stale-ignore audit, with its `reportUnmatchedIgnoredErrors: false` override removed. The two private packages' overrides go with their analyzer adoption (see Consumer migration), because their ignores must be audited against this analyzer. - Also completed: the macro and forwarding flow (Macros; facade, storage and manager forwarding; factory-helper bindings), with the `Support\Manager::getCreatorMethod()` runtime fix and its regression tests. +- Also completed: the request, environment and storage flow, Redis command casing and the connection-bound Redis rule, with their source fixes and tests (see Requests, environment and storage). - Next: port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. @@ -90,6 +91,7 @@ Omit `checkOctaneCompatibility`, `generalizeEnvReturnType` and `redisDriver` wit | `noLegacyModelProperties` | `true` | `hypervel.legacyModelProperty` | | `noLegacyQueueProperties` | `true` | `hypervel.legacyQueueProperty` | | `noPipeSeparatedValidationRules` | `true` | `hypervel.pipeSeparatedValidationRules` | +| `checkRedisConnectionBoundMethods` | `true` | `hypervel.redisConnectionBoundMethod` | `ConfigTypeRule` adds `hypervel.configType` under the retained `checkConfigTypes` switch (see Upstream rule disposition). @@ -243,7 +245,7 @@ Port every applicable upstream inference feature, not just the rules in the next Do not copy `ContractsMethodsExtension`, container ArrayAccess/dynamic-property support, Lumen paths, deferred-provider machinery, or unsupported Laravel integrations. Remove compatibility branches for old Laravel releases, but first merge applicable newer-version stub refinements into the single supported Hypervel surface. Deleting version directories without examining their overrides would lose features. -**Macros.** One memoized lookup per class and method (hits and misses) builds macro reflections for closures, string and array callables and invokable objects (`Macroable::macro()` accepts `callable|object`; an invokable reflects as `[$object, '__invoke']`, keeping the callable's real name). Signatures and exceptions are resolved separately: +**Macros.** Registries are read from the booted runtime: the registry of a `$macros` owner the application never loaded (`class_exists($owner, false)`), such as a class only analysed files declare, is unavailable, so its macros are not claimed and analysed files are never executed. One memoized lookup per class and method (hits and misses) builds macro reflections for closures, string and array callables and invokable objects (`Macroable::macro()` accepts `callable|object`; an invokable reflects as `[$object, '__invoke']`, keeping the callable's real name). Signatures and exceptions are resolved separately: - An `@method` tag on the receiver's or the macro host's ancestors (a facade's host is its booted root) and Head's `withHead()` signature take precedence over the callable, whose type `ClosureTypeFactory` reads from its declared signature only (`Request::validate()` keeps `array`, Builder's `whereCan()` its `$this`). Mixins do not count: a macro runs before mixin forwarding. - `Macro`'s `ValidationException` map applies only to `Request` macros. `Methods/MacroThrowTypeExtension` (method and static) supplies a host macro's throw type for a tag-resolved method only when the tag's class has no native method of that name, has a registered macro with a throw type, and the reflection already has a non-null throw type (the magic handler's `@throws`); otherwise PHPStan's normal handling applies (`Http\Client\Factory::__call()` declares none). It looks up the macro on the call's receiver or resolved class (facades through their root) and returns the existing throw type when that host has none. @@ -252,7 +254,16 @@ Do not copy `ContractsMethodsExtension`, container ArrayAccess/dynamic-property - `AuthManager::__call()` forwards to the current guard, which `shouldUse()` can set to any configured guard (`AuthGuardHelper::getDefaultGuardType()`; an unknown guard type claims nothing). A name is claimed when every configured guard has a public method of that name, natively or as a macro, and at least one supplies the macro; the combined guard method supplies the signature, static for the `Auth` facade and an instance `ProxiedMethodReflection` for `AuthManager`, which has no `__callStatic()`. This applies only where `AuthManager`'s own `__call()` handles the call (for the facade, its booted root); a subclass's Macroable or custom `__call()` keeps the ordinary macro lookup. Upstream's fixed `SessionGuard`/`RequestGuard` hosts claimed a macro the current guard may lack. Upstream's fixed Cache facade hosts are removed: `CacheManager` is not Macroable, and the facade's and manager's `@mixin Repository` already reach the store's macros. - Lookups whose result depends on a generic class's template types (`ReflectionHelper`'s tag-or-mixin caches, the manager extension's caches) are keyed by `ClassReflection::getCacheKey()`; class-wide facts (registered macros, facade roots, trait use) stay keyed by class name. -General type improvements go into source, with dedicated type fixtures and no runtime overhead. Confirmed examples include `Attribute` and the conditional `FormRequest::validated()` result. `Enumerable::keyBy()` already declares a new-key template, and `Builder::make()`/`create()` already return `TModel`; do not duplicate these. Analyzer-only `model-property` refinements on Model/Builder (including `array, mixed>` inputs) and `view-string` refinements on mail/view/route methods live in package stubs, not framework source. Adapt Redis stubs to Hypervel's phpredis-only pooled `RedisProxy` surface. Omit Predis stubs, Laravel's client-selection branches and the `redisDriver` parameter; there is no supported alternate transport to select. +**Requests, environment and storage.** + +- Request getters are typed in source with honest unions, not upstream's benevolent ones: clients control array input. Runtime fix: `query`/`post`/`cookie`/`server`/`file` take `mixed` defaults and return `mixed` natively, as Laravel accepts any default and merged or JSON values are scalars (the copied docblock types threw TypeErrors). PHPDoc: `query`/`post`/`cookie` keyed `string|int|float|bool|array|Stringable|TDefault` (`InputBag::set()`'s value contract, read through `all()`; a stored null gives the default), key-null `array` (`?0=x` and JSON list bodies give int keys, as do the file bag's `allFiles()`/`file()`); `server` keyed `mixed`; `header` keeps its native types, keyed `string|null` (a stored `[null]` header wins over the default; `bearerToken()` handles it) and key-null `array>`; `file`/`allFiles`/the conversion use `UploadedFile|array|null` (nested inputs; `UPLOAD_ERR_NO_FILE` is null), with `file()`'s default `(Closure(): TDefault)|TDefault` as `data_get()` evaluates it; keyed `route()` is `mixed` (bindings can be any value). Upstream's `InteractsWithInput`/`Request` stub typing, `RequestFileExtension`, `RequestRouteExtension` and `TestCaseExtension` (the source `mock()`/`partialMock()`/`spy()` conditionals cover it) are omitted. +- `RequestBridge` fills the request bag from a JSON body and shares it as the JSON cache, as `createFromBase()` and Laravel's `capture()` do, so `post()` matches between production and tests. JSON requests now decode once even when input is never read. +- `AbortIfFunctionTypeSpecifyingExtension` narrows the condition of the global `abort_if`/`abort_unless`/`throw_if`/`throw_unless`; a missing or unpacked condition declines. `AppEnvironmentReturnTypeExtension` is registered once, on the Application contract (extension lookup matches interfaces). It and the facade extension inspect every argument: none or only a definitely empty unpack gives `string`, a definite argument `bool`, otherwise the declaration. Runtime fix: `Application::environment()` reads its first value with `array_key_first()`, because a named `environments:` argument has no key 0. +- `FormRequestSafeDynamicMethodReturnTypeExtension` covers `FormRequest` and the `Validator` contract. Constant string keys give optional shape keys (`only()` omits absent keys); unpacked, dotted or wildcard, and non-constant keys decline. Source: `FormRequest::validated()` with a null key and the three `safe($keys)` annotations (contract, `FormRequest`, `Validator`) return `array`, because `Arr::set()` makes a top-level numeric rule key an int. Model inputs keep `array`: `create()` accepts the result at every level, and `fill()`/`update()`/the constructor report it only at level 10, truthfully (int keys reach `isFillable(string)` when nothing filters them). +- `Storage::disk()`/`drive()` and `FilesystemManager` receivers are typed per selected disk without building it; the `Factory` contract keeps its declaration and `cloud()` stays omitted. Upstream's fixed `FilesystemAdapter` is wrong for pooled (s3/gcs `ClientPooledFilesystem`, ftp/sftp `FilesystemPoolProxy`) and custom disks. One helper follows the bound manager's stock dispatch through declared returns, never method bodies: the `filesystems.disks` entry normalized by the private `prepareConstructionDescriptor()` (called in `FilesystemManager` scope); a custom creator gives `createDriverPooledDisk()`'s declared return for a poolable driver (the live poolable list), else the overriding `callCustomCreator()`'s or the closure's; poolable s3/gcs give `createClientPooledDisk()`'s, other poolable drivers `createDriverPooledDisk()`'s, otherwise the creator method's (creators carry truthful `@return` PHPDoc). Overridden `get()`/`resolve()`/`getConfig()`, an overridden `createScopedDriver()` for a scoped selection, a normalization exception, an unknown or non-constant name, and a cached or set disk the computed type rejects keep the declaration; an overridden `disk()`/`drive()` keeps its own. Constant names, enum cases and their unions are supported; null and `''` select the default disk. Results are cached per selection. A cached disk, like `ManagersMethodsExtension`'s cached driver, keeps the computed type only when that type definitely accepts the object's exact class (`->yes()`), so a parent-class instance declines. `StorageMethodsClassReflectionExtension` resolves a `Storage` facade call on the manager's native methods first, then on the default disk's type; the manager's declared adapter mixins (upstream's surface) apply only when that type is unknown, including when the bound manager overrides `disk()` or `__call()`. Checking the manager with its mixins first would shadow pooled disks' signatures (their `getDriver()` is `never`). +- Redis commands are case-insensitive at runtime (`RedisConnection::__call()` and `RedisProxy::__call()` lowercase the name after the exact-macro check), but PHPStan matches `@method` tags exactly. `RedisCommandMethodsExtension` covers `RedisConnection` and subclasses (and through the `@mixin` chain `RedisProxy`/`RedisManager`) and the `Redis` facade: a memoized lowercase index of `RedisConnection`'s own tags maps another casing to its tag, resolved through the receiver's normal lookup so the nearest declaration wins. It declines exact names, names outside the index (subclass tags and macros are never case-folded) and exact macros in the receiver's own registry (`static::$macros`; the facade uses the shared `RedisConnection` registry). Remove `RedisConnection`'s 22 case-only duplicate tags (identical signatures; keep the transformed-command block's entry) and regenerate the facade. The held connection's `pipeline` tag is `bool|Redis pipeline()`: only `RedisProxy`'s `MultiExec` takes a callback, and phpredis throws `ArgumentCountError` for one. Remove the `client(string $opt = '', ...)` tag: it shadowed the native `client()`, which returns the underlying phpredis client and ignores arguments (`$connection->client()->client('LIST')` runs CLIENT LIST). A connection's re-cased command is returned as a `ProxiedMethodReflection` declared on the receiver: PHPStan looks the called name up again on a method's declaring class (`ObjectType::getUnresolvedMethodPrototype()`), and `RedisConnection`'s macros can differ from a subclass's own registry. The facade keeps its own static tag. + +General type improvements go into source, with dedicated type fixtures and no runtime overhead. Confirmed examples include `Attribute` and the conditional `FormRequest::validated()` result. `Enumerable::keyBy()` already declares a new-key template, and `Builder::make()`/`create()` already return `TModel`; do not duplicate these. Analyzer-only `model-property` refinements on Model/Builder (including `array, mixed>` inputs) and `view-string` refinements on mail/view/route methods live in package stubs, not framework source. The Redis facade's tags and the `RedisManager`/`RedisProxy` mixins already type Hypervel's phpredis-only pooled surface: port the applicable phpredis assertions and add stubs only for demonstrated gaps. Omit Predis stubs, Laravel's client-selection branches and the `redisDriver` parameter; there is no supported alternate transport to select. Preserve upstream's Request and Optional `universalObjectCratesClasses` entries: `Request::__get()` resolves arbitrary input/route keys, and `Optional::__get()` forwards an arbitrary property name or returns null. These are supported dynamic APIs, not undefined-property mistakes. Do not add blanket crates for models or other classes to hide missing inference; use their dedicated reflection extensions and preserve explicit property types. Include the documented `$request->name` and Optional property access in typing coverage. Removing either dynamic API would require owner approval. @@ -407,6 +418,8 @@ Start from evidenced container candidates: registered `bind`/`singleton`/`scoped 3. Detect supported child-coroutine spawning APIs inside transaction callbacks when work is being moved off the transaction-owning coroutine. Explain that pooled database ownership belongs to the parent and the child does not inherit its transaction. Preserve established transaction-preserving test infrastructure and calls that do not actually spawn children. 4. Diagnose raw source `sleep()`/`usleep()` where the documented replacement is `Hypervel\Support\Sleep`. Preserve real-time test/infrastructure waits and other explicitly justified timing contracts. +5. `RedisConnectionBoundMethodRule` reports a call that reaches `RedisProxy`'s stock `__call()` with a name in its `CONNECTION_BOUND_METHODS` (read once in the proxy's scope, not copied), which always throws there: `Redis::connection()->client()` and the like, on `RedisProxy` or `RedisManager` receivers. The `@mixin RedisConnection` chain otherwise accepts them; the facade already rejects them. It skips a native method accessible in scope (the proxy's own protected `getConnection()` calls, a public subclass method) and declines a receiver that overrides `__call()`, or for `RedisManager` `connection()`. The message names the held-connection replacement (`Redis::withConnection()`/`withPinnedConnection()`). + Use collectors only for cross-file binding facts needed by diagnostics. Derive known runtime registrations through existing bootstrap/reflection facilities when available. Unknown ownership must not produce a confident leak claim. Evaluate candidates across Components and the private source before finalizing detection predicates; fix genuine violations and cover intentional patterns as negative fixtures. Do not add a separate policy framework, service registry, deep interprocedural data-flow engine or runtime instrumentation. ### Legacy authoring policy and safe fixes diff --git a/src/docs/static-analysis.md b/src/docs/static-analysis.md index d73b133ee..5bb793bc8 100644 --- a/src/docs/static-analysis.md +++ b/src/docs/static-analysis.md @@ -16,6 +16,7 @@ - [Views](#views) - [Console Input](#console-input) - [Authentication in Requests](#authentication-in-requests) + - [Redis Connections](#redis-connections) - [Deprecations](#deprecations) @@ -80,6 +81,10 @@ A value typed as an interface only has the interface's methods, even when the co Calls a manager forwards to its default driver are typed from the return type declared by whatever creates the driver the booted application selects, such as its `create...Driver()` method or an `extend()` callback, unless the manager declares the method or a `@mixin` for its drivers. Analysis never builds the driver, so give `extend()` callbacks a return type. Like container bindings, the selected driver can depend on your environment. +Filesystem disks work the same way. `Storage::disk('s3')` is typed from your `filesystems` configuration as the disk Hypervel builds for it, such as `ClientPooledFilesystem` for a pooled S3 disk or `LocalFilesystemAdapter` for a local one. Calls the `Storage` facade forwards to the default disk are typed from that disk. An unknown or non-constant disk name keeps the `Filesystem` contract type. + +Request input is typed as what the request can hold. A keyed `query()`, `post()` or `cookie()` value may be a string, number, boolean or array from the client, or a `Stringable` your code stored, so check or cast it before relying on one type. Whole input arrays may have integer keys, such as from a JSON list body. By default, typed helpers such as `$request->string()`, `integer()` and `boolean()` return a single type. + Dates are different: `now()`, `today()` and the `Date` facade return `CarbonInterface`, as they are declared, even when your application configures a specific date class. When your code needs a specific class, convert the value, such as with `toImmutable()`. Some facts cannot be known statically. When the analysis cannot tell what a configuration key holds, such as a value that provider code writes in a way the analysis does not recognize and that did not run while your application booted, it uses the declared return type instead of guessing. @@ -263,6 +268,22 @@ if ($this->hasOption('force') && $this->option('force')) { When the `checkAuthCallsWhenInRequestScope` parameter is enabled, the analysis recommends `$request->user()` over `Auth::user()` and `auth()->user()` where a request is available. The facade and helper remain fully supported. Since a request's user resolver may return a different user than the `Auth` facade, review each change instead of replacing calls mechanically. + +### Redis Connections + +`Redis::connection()` returns a proxy that runs each command on a pooled connection, so methods that act on one connection, such as `client()`, `auth()` or `release()`, are reported. Call them on a held connection instead: + +```php +use Hypervel\Redis\RedisConnection; +use Hypervel\Support\Facades\Redis; + +Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); +}); +``` + +These checks may be disabled using the `checkRedisConnectionBoundMethods` parameter. + ### Deprecations diff --git a/src/phpstan/README.md b/src/phpstan/README.md index 3d2c2d2d0..eed0a4c54 100644 --- a/src/phpstan/README.md +++ b/src/phpstan/README.md @@ -15,5 +15,8 @@ Documentation: https://hypervel.org/docs/static-analysis - Collections keep Laravel's covariant value type, so a `Collection` is accepted where a `Collection` is expected. Upstream's stubs make it invariant. With bleeding edge, PHPStan does not widen a covariant value type it has already inferred, so a call such as `collect([1, 2, 3])->push(4)` is reported. See [Collection Types](https://hypervel.org/docs/static-analysis#collection-types). - `now()`, `today()` and the `Date` facade return `CarbonInterface`, as declared, rather than the date class configured when analysis runs. Code that needs a specific class should convert the value, for example with `toImmutable()`. - The `generalizeEnvReturnType` and `redisDriver` settings are omitted. An `env()` default does not decide the type of the value it reads, and Redis types always describe Hypervel's phpredis connection. +- Request input getters return the types the input can hold. A keyed `query()`, `post()` or `cookie()` value can be a string, number, boolean, array or `Stringable`, a keyed `server()` or `route()` value is `mixed`, and `header()` can return null even with a default. Whole query, post, cookie and file arrays have `array-key` keys, because a query string or JSON list body can use integer keys. Upstream's narrower types do not cover these values. `FormRequest::validated()` and `safe($keys)` return `array` for the same reason. +- `Storage::disk()` and `FilesystemManager::disk()` are typed per disk from the booted configuration, such as `ClientPooledFilesystem` for a pooled S3 disk, instead of always `FilesystemAdapter`. Calls the `Storage` facade forwards to the default disk use that disk's methods. `Storage::cloud()` is not supported. +- Connection-bound Redis methods such as `client()` and `auth()` are reported when called through `Redis::connection()` or the manager, because the pooled proxy rejects them. Call them on a held connection from `Redis::withConnection()`. Ported from: https://github.com/larastan/larastan diff --git a/src/phpstan/extension.neon b/src/phpstan/extension.neon index 2d659e4ef..feffaddce 100644 --- a/src/phpstan/extension.neon +++ b/src/phpstan/extension.neon @@ -53,6 +53,7 @@ parameters: noLegacyModelProperties: true noLegacyQueueProperties: true noPipeSeparatedValidationRules: true + checkRedisConnectionBoundMethods: true # Builder macros added by model traits' global scopes: trait => interface declaring the macros. eloquentBuilderMacros: Hypervel\Database\Eloquent\SoftDeletes: Hypervel\PHPStan\Methods\Macros\SoftDeletingMacros @@ -101,9 +102,64 @@ parametersSchema: noLegacyModelProperties: bool() noLegacyQueueProperties: bool() noPipeSeparatedValidationRules: bool() + checkRedisConnectionBoundMethods: bool() eloquentBuilderMacros: arrayOf(string(), string()) services: + - + class: Hypervel\PHPStan\Types\AbortIfFunctionTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.functionTypeSpecifyingExtension + arguments: + methodName: 'abort' + negate: false + + - + class: Hypervel\PHPStan\Types\AbortIfFunctionTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.functionTypeSpecifyingExtension + arguments: + methodName: 'abort' + negate: true + + - + class: Hypervel\PHPStan\Types\AbortIfFunctionTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.functionTypeSpecifyingExtension + arguments: + methodName: throw + negate: false + + - + class: Hypervel\PHPStan\Types\AbortIfFunctionTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.functionTypeSpecifyingExtension + arguments: + methodName: throw + negate: true + + # The contract also covers the concrete application, so upstream's second entry is omitted. + - + class: Hypervel\PHPStan\ReturnTypes\AppEnvironmentReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + arguments: + class: "Hypervel\\Contracts\\Foundation\\Application" + + - + class: Hypervel\PHPStan\ReturnTypes\FormRequestSafeDynamicMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + arguments: + class: "Hypervel\\Foundation\\Http\\FormRequest" + + - + class: Hypervel\PHPStan\ReturnTypes\FormRequestSafeDynamicMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + arguments: + class: "Hypervel\\Contracts\\Validation\\Validator" + - class: Hypervel\Filesystem\Filesystem autowired: self diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php index 10ebd1911..5c3f29845 100644 --- a/tests/PHPStan/Type/GeneralTypeTest.php +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -21,6 +21,7 @@ public function testFileAsserts( /** @return iterable */ public static function dataFileAsserts(): iterable { + yield from self::gatherAssertTypes(__DIR__ . '/data/abort.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/abstract-manager.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/app-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/application-make.php'); @@ -36,19 +37,27 @@ public static function dataFileAsserts(): iterable // REMOVED: container-array-access.php; container ArrayAccess is unsupported. yield from self::gatherAssertTypes(__DIR__ . '/data/container-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/contracts.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/environment-helper.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/facade-fakes.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/facades.php'); // REMOVED: facades-l11-42.php; it asserts Laravel 11's Redis facade docblock types. yield from self::gatherAssertTypes(__DIR__ . '/data/facades-l12-20.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/facades-l12-41.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/form-request.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/gate-facade.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/helpers.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/higher-order-collection-proxies.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/managers.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/optional-helper.php'); + // Upstream runs this per redisDriver setting; Hypervel only has phpredis, so it runs once here. + yield from self::gatherAssertTypes(__DIR__ . '/data/redis-driver-phpredis.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/request-header.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/request-object.php'); // Upstream never registers request-user.php, which shares request-object.php's namespace. yield from self::gatherAssertTypes(__DIR__ . '/data/request-user.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/storage-disks.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/tappable.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/throw.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/translate.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/translator.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/view-exists.php'); diff --git a/tests/PHPStan/bootstrap.php b/tests/PHPStan/bootstrap.php index 8c01c538c..aa3f475c0 100644 --- a/tests/PHPStan/bootstrap.php +++ b/tests/PHPStan/bootstrap.php @@ -6,10 +6,13 @@ use Hypervel\Auth\SessionGuard; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Query\Builder as QueryBuilder; +use Hypervel\Filesystem\FilesystemAdapter; +use Hypervel\Redis\RedisConnection; use Hypervel\Routing\ResponseFactory; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Cache; use Hypervel\Support\Facades\Route; +use Hypervel\Support\Facades\Storage; use Hypervel\Support\Optional; use Hypervel\Support\Str; use Hypervel\Tests\PHPStan\Fixtures\Macros\InvokableMacro; @@ -19,8 +22,12 @@ use Hypervel\Tests\PHPStan\Fixtures\Managers\CsvDriver; use Hypervel\Tests\PHPStan\Fixtures\Managers\ExtendedExportManager; use Hypervel\Tests\PHPStan\Fixtures\Managers\JsonDriver; +use Hypervel\Tests\PHPStan\Fixtures\Managers\ResolvedChildDriverManager; use Hypervel\Tests\PHPStan\Fixtures\Managers\ResolvedExportManager; +use Hypervel\Tests\PHPStan\Fixtures\Managers\TabbedCsvDriver; use Hypervel\Tests\PHPStan\Fixtures\Managers\WrappedCreatorManager; +use League\Flysystem\Filesystem as Flysystem; +use League\Flysystem\Local\LocalFilesystemAdapter as LocalFlysystemAdapter; Builder::macro('globalCustomMacro', static function (string $arg = 'foobar', int $b = 5): string { return $arg; @@ -80,8 +87,24 @@ app(ResolvedExportManager::class)->driver(); app(ResolvedExportManager::class)->extend('csv', static fn (): JsonDriver => new JsonDriver); +app()->singleton(ResolvedChildDriverManager::class); +app(ResolvedChildDriverManager::class)->driver(); +app(ResolvedChildDriverManager::class)->extend('csv', static fn (): TabbedCsvDriver => new TabbedCsvDriver); + app()->singleton(BoundExportManager::class, ChildExportManager::class); +// Hypervel: Redis connection macros, one under a command's lowercase name, which runs instead of the command. +RedisConnection::macro('zrank', static fn (): string => 'macro'); +RedisConnection::macro('customCommand', static fn (): int => 1); + +// Hypervel: filesystem creators for the storage disk fixtures, and a configured local +// disk replaced with another filesystem, which disk() then returns instead. +Storage::extend('custom', static fn (): FilesystemAdapter => Storage::createLocalDriver(['root' => sys_get_temp_dir()])); +Storage::extend('pooled-custom', static fn (): FilesystemAdapter => Storage::createLocalDriver(['root' => sys_get_temp_dir()]), poolable: true); + +$replacedDiskAdapter = new LocalFlysystemAdapter(sys_get_temp_dir()); +Storage::set('replaced', new FilesystemAdapter(new Flysystem($replacedDiskAdapter), $replacedDiskAdapter)); + class CustomCollectionMacro { /** From cc8eab273aa8607514a2800cd2c339f7e4dfb281 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:39:51 +0000 Subject: [PATCH 098/303] Use the first-party schema parser in the PHPStan plan Replace third-party SQL parser evaluations and adapter work with the existing hypervel/schema-parser public API. Retain analyzer-owned connection and schema resolution, incomplete-schema handling, fetched PHP types, caching and meaningful integration regressions. Record that independently parsed raw migration SQL does not establish changes to previously accumulated schema facts. Preserve explicit model transformations while avoiding stale inferred types and unsupported missing-column claims. Update the remaining-file checklist to omit superseded parser implementations and their adapter-specific tests. --- .../2026-11-04-hypervel-phpstan-checklist.md | 6 +-- docs/plans/2026-11-04-hypervel-phpstan.md | 50 +++++++++++++++---- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index 44851093b..02ea03d5e 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -32,7 +32,7 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy with the Eloquent collection port: `ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php`, moving `common/EloquentCollection.stub`'s `map()`/`mapWithKeys()` conditionals into the Eloquent `Collection` source - copy: `Rules/*` except below and the ported `ArrKeyExistenceRule`, auth request-scope, config, console, `NoEnvCallsOutsideOfConfigRule`, `NoMissingTranslationsRule`, `NoUnnecessaryEnumerableToArrayCallsRule`, `UnusedViewsRule` and `UselessConstructs/` rules (including `Queue/`) - omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` -- copy: `SQL/*` (7 files) +- omit: `SQL/*` (`hypervel/schema-parser` replaces the parser boundary and its iamcal/phpMyAdmin adapters) - copy: `Types/*` except the ported `ArrPullParameterOutTypeExtension` and `ViewString*` files (12 files) ## New Hypervel source @@ -40,7 +40,6 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - add: `Rules/Coroutine/` cancellation, worker-lifetime, static-state, unbounded-cache, runtime-config, transaction-coroutine and raw-sleep rules - add: `Rules/` legacy model scope and legacy model property rules; `Rules/Queue/NoLegacyQueuePropertiesRule.php` - add: `Rules/Validation/` pipe-separated validation rule -- add: PostgreSQL and SQLite schema parser adapters under `SQL/` ## stubs @@ -67,4 +66,5 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - merge into `Type/CollectionDynamicReturnTypeExtensionTest.php` with the model fixtures: `collection-filter.php`, `collection-reject.php`, `collection-where.php` (they need `accounts()->active()->get()` to return `AccountCollection`) - omit: `Type/data/collection-generic-static-methods-l11.php` (Laravel 11's Eloquent `partition()` returned `static`; Hypervel follows the Laravel 12 base collection) - merge: the remaining `Type/GeneralTypeTest.php` data entries, and its `data/config-with-migrations.neon` configuration once migrations are ported; `higher-order-collection-proxy-methods.php` needs model properties and Hypervel's proxy results (`avg` is `float|int|null`, `max`/`min` include `null`) -- copy: `Unit/*Test.php` except `Support/{BootstrapErrorHandlerTest,RecursiveDirectoryIteratorTest}.php` (11 classes, including `SQL/` and `Support/`), `Unit/data/*` +- copy: `Unit/*Test.php` (9 classes; upstream's `IamcalSqlParser` becomes `MySqlParser`), `Unit/data/*` +- omit: `Unit/SQL/*` (parser factory and phpMyAdmin adapter tests; `hypervel/schema-parser` has its own) diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 397da0a91..46f256a84 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -22,7 +22,7 @@ Remaining work: - Also completed: Components' stale-ignore audit, with its `reportUnmatchedIgnoredErrors: false` override removed. The two private packages' overrides go with their analyzer adoption (see Consumer migration), because their ignores must be audited against this analyzer. - Also completed: the macro and forwarding flow (Macros; facade, storage and manager forwarding; factory-helper bindings), with the `Support\Manager::getCreatorMethod()` runtime fix and its regression tests. - Also completed: the request, environment and storage flow, Redis command casing and the connection-bound Redis rule, with their source fixes and tests (see Requests, environment and storage). -- Next: port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. +- Next: the model-properties/schema flow (Properties on `hypervel/schema-parser`, `Types/ModelProperty`, `ModelSerializationDynamicMethodReturnTypeExtension`), because the Eloquent stubs (including `Factory.stub`, which drops the source `trashed()` tag) need `model-property`. After it, port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -139,7 +139,7 @@ Local upstream: `/home/binaryfire/workspace/references/larastan` (`3.x`). Read i |---|---| | Larastan `bootstrap.php`, `src/ApplicationResolver.php`, `src/Concerns/HasContainer.php` | App/Testbench startup and runtime reflection; adapt supported Hypervel boot APIs rather than recreating them. | | Larastan `src/Support/ConfigParser.php` | Uses PHPStan scopes and `NodeScopeResolver`, including imports and preceding statements. Preserve the full parser, recursion protection, lazy evaluation and dependency tracking. | -| Larastan `src/Properties/{ModelPropertyHelper,MigrationHelper,SquashedMigrationHelper,MigrationCache}.php`, `src/SQL/` | Model schema inference and parsed-schema caching; parser/database support needs Hypervel adaptation. | +| Larastan `src/Properties/{ModelPropertyHelper,MigrationHelper,SquashedMigrationHelper,MigrationCache}.php` | Model schema inference and parsed-schema caching. `hypervel/schema-parser` replaces upstream's `src/SQL/` parsers. | | Larastan `extension.neon`, `src/Rules/`, `src/LarastanStubFilesExtension.php`, `stubs/`, `tests/`, `e2e/` | Complete configuration, rule, stub and test inventory; do not infer coverage from documentation alone. | | Components `docs/todo.md`, Static Analysis and Container sections of `AGENTS.md` | Cancellation examples, lifetime semantics, unmatched ignores and intended eventual legacy removals. | | `src/testbench/src/Foundation/{Application,Config}.php`, `src/testbench/src/Concerns/CreatesApplication.php` | `testbench.yaml`, `createFromConfig()`, discovery and the real per-process runtime skeleton. | @@ -155,10 +155,10 @@ PHPStan documentation: [extension testing](https://phpstan.org/developing-extens ## Package and repository wiring 1. Use `src/permission/{composer.json,README.md,LICENSE.md}` as the third-party-port skeleton, preserving Larastan's MIT attribution. Set `type: phpstan-extension`, namespace `Hypervel\PHPStan\`, PHP `^8.4`, normal Hypervel support links and branch alias. The README uses `Documentation: https://hypervel.org/docs/static-analysis` for the page authored in `src/docs/static-analysis.md`, states deliberate public differences, and ends with `Ported from: https://github.com/larastan/larastan`. -2. Preserve the upstream package-root layout: `bootstrap.php`, `extension.neon`, `src/`, and necessary `stubs/`. Use existing `Methods`, `Properties`, `Reflection`, `ReturnTypes`, `Rules`, `Support`, `Types`, `Collectors`, `SQL` and `Concerns` directories. Put new model rules at `Rules/` root beside upstream's model rules; use `Rules/Coroutine` and `Rules/Validation` for those genuinely new domains. Do not introduce separate engines for Hypervel additions. +2. Preserve the upstream package-root layout: `bootstrap.php`, `extension.neon`, `src/`, and necessary `stubs/`. Use existing `Methods`, `Properties`, `Reflection`, `ReturnTypes`, `Rules`, `Support`, `Types`, `Collectors` and `Concerns` directories. Put new model rules at `Rules/` root beside upstream's model rules; use `Rules/Coroutine` and `Rules/Validation` for those genuinely new domains. Do not introduce separate engines for Hypervel additions. 3. Declare direct production dependencies of the analyzer in its own manifest, even though consumers install the analyzer with `--dev`. Require `phpstan/phpstan-deprecation-rules`: included deprecation reporting is part of the supported installation contract, not an unrelated suggested tool. Check the latest compatible stable release on Packagist before adding it; the verified 2.0.5 release requires PHPStan `^2.1.39`, compatible with the planned analyzer range. Translate required Illuminate components to their actual Hypervel owners. Do not depend on `hypervel/components`, the entire framework metapackage, Head, Scout, private packages or every optional integration. Suggest `hypervel/testbench` for package analysis. Guard optional integrations using existing reflection/stub-extension mechanisms; preserve an absent-component installation test. 4. Add root autoloading and `replace` metadata for `hypervel/phpstan`. The root replaces the split package, so it must declare the analyzer's external dependencies itself; add `phpstan/phpstan-deprecation-rules` to root `require-dev` with `composer require --dev` at the selected compatible constraint. Use Composer commands for other root dependency changes; subpackage manifests without lockfiles may be edited directly. Do not add a runtime service provider just to register PHPStan services. Register them in PHPStan's container. -5. Require PHPStan `^2.3.5` in the root and analyzer manifests (Database and Foundation no longer use PHPStan directly once their extensions move), retaining current Larastan's supported APIs and DI registration. Use local identifier-specific ignores with adjacent code-comment `@TODO`s for confirmed PHPStan regressions: generic narrowing in `DataCollectionEloquentCast::set()` and factory native-return generics in Collection's `split()`/`chunkWhile()`. Do not add NEON ignores or `docs/todo.md` entries for them, alter correct runtime code/types, or add compatibility machinery for 2.3.1. Inspect new diagnostics individually and fix real defects before suppressing confirmed false positives. Check compatible stable versions for every added dependency, including `iamcal/sql-parser`, optional phpMyAdmin support and any other SQL parser. +5. Require PHPStan `^2.3.5` in the root and analyzer manifests (Database and Foundation no longer use PHPStan directly once their extensions move), retaining current Larastan's supported APIs and DI registration. Use local identifier-specific ignores with adjacent code-comment `@TODO`s for confirmed PHPStan regressions: generic narrowing in `DataCollectionEloquentCast::set()` and factory native-return generics in Collection's `split()`/`chunkWhile()`. Do not add NEON ignores or `docs/todo.md` entries for them, alter correct runtime code/types, or add compatibility machinery for 2.3.1. Inspect new diagnostics individually and fix real defects before suppressing confirmed false positives. Check compatible stable versions for every added dependency. Add `hypervel/schema-parser` to the analyzer manifest with the first code that imports it; the root already replaces it. 6. Components has no extension installer: in both `phpstan.neon.dist` and `phpstan.types.neon.dist`, replace the four old includes with explicit `src/phpstan/extension.neon` and `vendor/phpstan/phpstan-deprecation-rules/rules.neon` includes, and update type-fixture scan directories. Check each skeleton/private consumer's actual installer and plugin configuration before choosing its loading mode; use discovery or an explicit include for each extension without duplication. Keep source analysis and the dedicated `types/` configuration distinct; do not run normal source analysis over `tests/`. 7. Update the base skeleton, other maintained skeletons and Testbench package examples to install `hypervel/phpstan` as a dev dependency and use the standard setup. Inspect each current manifest and analysis script; preserve existing additional Carbon/Swoole support where still necessary. 8. `bin/split.sh` discovers packages from manifests. Ensure the new package passes existing publishing checks; do not introduce a second publishing system. Prepare the split repository/Packagist setup instructions. Creating external repositories, publishing and pushing require owner authorization at the actual publication step. @@ -300,13 +300,41 @@ Retain `configDirectories`, `viewDirectories`, `translationDirectories`, migrati ### Model schemas -Port migration aggregation, SQL parsing and `MigrationCache`. Cover MySQL, MariaDB, PostgreSQL and SQLite; do not present either upstream MySQL parser/converter as a PostgreSQL or SQLite implementation. No live database connection is required just to infer properties. Package schemas and schemas owned by other services can be supplied through the retained path settings. - -Use a maintained parser before writing dialect handling. Packagist identifies [iamcal/sql-parser](https://packagist.org/packages/iamcal/sql-parser) as a MySQL schema parser and [phpmyadmin/sql-parser](https://packagist.org/packages/phpmyadmin/sql-parser) as MySQL-focused. For PostgreSQL, evaluate [calebdw/pg-schema-parser](https://packagist.org/packages/calebdw/pg-schema-parser) first: it has a stable release and a dedicated `PgDumpParser` for plain-text dumps, using `sad_spirit/pg_builder` for type grammar. Its [documented supported statements](https://github.com/calebdw/pg-schema-parser) include CREATE TABLE plus separate primary-key/default declarations; verify the actual supported Hypervel dump fixtures before selecting the dependency and adapting its output through Larastan's existing SQL boundary. Once it passes, declare it in the analyzer package's `require`, not `suggest`: PostgreSQL inference must be available in a standard dev install. A failed evaluation needs a concrete replacement decision, not silent loss of that support. - -For SQLite, evaluate parser candidates against actual `SqliteSchemaState` output: quoted identifiers, defaults, generated columns, table constraints, STRICT and WITHOUT ROWID where emitted. [k-kinzal/sql-parser](https://packagist.org/packages/k-kinzal/sql-parser) exposes a SQLite grammar but currently lists only a development branch; [maghead/sqlite-parser](https://packagist.org/packages/maghead/sqlite-parser) has an old stable release and needs source/fixture verification before consideration. Do not silently add an unversioned dependency. If no suitable maintained parser passes, reach peer consensus on a bounded parser for the schema-producing statements emitted by the supported dump tools before implementation; preserve unknown schema state for unsupported constructs. No regex rewriting into MySQL syntax, executing dumps, or general-purpose SQL interpreter. Bring any unresolved dependency/coverage tradeoff to the owner rather than claiming full dialect support. - -Track connection/table identity where it changes the result, schema changes across migrations, nullability, defaults, enums/casts and supported custom driver boundaries. If a dynamic migration, raw statement or unsupported custom-driver construct prevents a complete schema, preserve known declarations and avoid reporting an unproven missing column. Do not turn all unknown properties into `mixed` globally or suppress unrelated mistakes. Preserve upstream extension points instead of inventing a driver plugin platform. +Port migration aggregation and `MigrationCache`, covering MySQL, MariaDB, PostgreSQL and SQLite without a live database connection. Dumps and literal migration SQL are parsed by `hypervel/schema-parser` ([docs](../../src/docs/schema-parser.md)), with the parser chosen by the connection's driver. Dialect parsing belongs to that package; the analyzer maps its results to PHP types. Real defects this work exposes in it are fixed in that package on this branch, through the normal second-opinion and review workflow. Package schemas and schemas owned by other services can be supplied through the retained path settings. Fixtures are real `schema:dump` output for each driver, generated from the fixture app's migrations. + +- **Identity.** + - A dump is `{connection}-schema.{sql,dump}`, for a booted `database.connections` key, in a `squashedMigrationsPath` directory. `.dump` wins over `.sql`, as in `MigrateCommand::schemaPath()`. + - Any other name keeps unknown identity: document the naming requirement and never assume the default connection. + - A raw migration statement belongs to its `DB::connection()` argument, else the migration's connection, else the default (`Migrator::runMethod()`). + - Index tables, views, and PostgreSQL enums and domains by connection, schema (or none) and name, keeping unqualified definitions distinct. Never use the package's unqualified lookups, which throw on ambiguity. + - MySQL/MariaDB: an unqualified definition belongs to the connection's configured `database` (`schema:dump` writes no `USE`, and unqualified DDL targets the current database). SQLite definitions carry `main` or the attached database's name. + - PostgreSQL tables: an unqualified model table resolves through `PostgresBuilder::getCurrentSchemaListing()`'s precedence (`search_path`, then `schema`, then `public`; `$user` is the configured username), read from config only, taking the first schema with a qualified definition. An unqualified definition (from literal migration SQL) matches when no qualified candidate exists, because unqualified creation lands in the first existing path schema. An unqualified definition beside a qualified candidate, or a qualified model table with only an unqualified definition of that name, is ambiguous. + - PostgreSQL types: an unqualified type name resolves through the same precedence, with `pg_catalog` searched first unless the configured path names it explicitly, as PostgreSQL does. A name that resolves to no known built-in or dump-defined type reads `mixed`. +- **Completeness.** + - A connection is incomplete when its dump throws `ParseException`, is a pg_restore custom archive (`PGDMP`), has schema-level issues, or its driver has no parser. + - A table is incomplete when it has issues, is a view (no column facts), is ambiguous, or is affected by the migration-side cases below. + - Known columns keep their types and casts, except where raw migration SQL makes them uncertain. An unknown column on an incomplete table or connection gets no missing-column claim, while complete tables still report one. +- **Raw migration SQL.** Each parse starts empty; its result does not describe changes to the accumulated migration schema. A recognized raw statement (`DB::statement()`/`unprepared()`, directly or through `DB::connection()`) makes earlier inferred facts on its connection uncertain: existence and types as well as missing-column claims. Keep only facts that remain justified; explicit casts and accessors stay authoritative. A colliding raw definition neither preserves nor replaces the earlier one automatically. Regressions: drop-and-recreate versus `CREATE TABLE IF NOT EXISTS` after a Blueprint table, and a raw drop or rename leaving no missing-column claim. +- **Diagnostics.** + - A collector records the dump identity and failure facts that a model-property lookup consulted. The data is serializable and loaded lazily. + - A `CollectedDataNode` rule (`hypervel.schemaDump`, under `checkModelProperties`) reports each unusable dump (a `ParseException` or `PGDMP` archive) once, at the dump file: `ParseException::$sqlLine`, else line 1. Issues and incomplete constructs are not diagnosed. + - The installed `AnalyserResultFinalizer` skips `CollectedDataNode` rules when nothing was collected, so a standalone rule cannot serve as the hook. +- **Fetched types.** + - One mapping, keyed by connection driver and column family and shared by the migration and dump paths, gives a read type (fetched) and a write type (accepted). Nullability applies next, then the fetch-option transformations. Explicit casts, the implicit incrementing-key cast (`HasAttributes::getCasts()`), dates and accessors take precedence. + - Upstream differences, each commented at its mapping: + - Uncast decimals read `numeric-string` on MySQL/MariaDB, `string` on pgsql (numeric holds NaN/Infinity) and `float|int` on SQLite. They are written as `float|int|numeric-string` (`float|int|string` on pgsql). + - Booleans read `int` (`tinyint(1)`) except `bool` on pgsql. + - pgsql `bytea` reads `resource`. + - MySQL/MariaDB `BIGINT UNSIGNED` reads `int<0, max>|numeric-string`, because values above `PHP_INT_MAX` fetch as strings. There is no foreign-key or auto-increment shortcut; the implicit key cast types `$model->id`. + - Booted connection `options`, merged as in `Connector::getOptions()`, adjust the read types: + - `ATTR_STRINGIFY_FETCHES`: int families, MySQL/MariaDB decimal and double, and integer-backed booleans become `numeric-string`. pgsql numeric and float and SQLite real become `string` (`'NaN'`, `'NAN'`, `'INF'`). pgsql bool becomes `'0'|'1'`. + - `ATTR_ORACLE_NULLS`: `NULL_TO_STRING` replaces null with `''`; `NULL_EMPTY_STRING` adds null where `''` can be read. + - `ATTR_CASE`: folds fetched attribute names at the property lookup, not the schema names, cast lookups or write targets. + - Drivers without a mapping read `mixed`. + - SQLite types describe the framework's standard writes, not engine enforcement; STRICT tables do enforce their types. Explicit casts give stable application types. +- **Assignment.** `ModelProperty::canChangeTypeAfterAssignment()` is true only for raw schema attributes: no cast (including the implicit key cast), not a date, and no accessor or mutator. PHPStan then narrows after assignment, while transforming attributes keep stable read types. + +Track connection/table identity where it changes the result, schema changes across migrations, nullability, defaults, enums/casts and supported custom driver boundaries. If a dynamic migration or unsupported custom-driver construct prevents a complete schema, preserve known declarations and avoid reporting an unproven missing column. Do not turn all unknown properties into `mixed` globally or suppress unrelated mistakes. Preserve upstream extension points instead of inventing a driver plugin platform. ## Result-cache correctness and analysis cost From cbb05783bf44efadec89adb807cc9498a696cac3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:40:02 +0000 Subject: [PATCH 099/303] Require patched Symfony 8.1.11 components Raise the filesystem, HTTP client, HTTP foundation, HTTP kernel, routing and serializer minimums to 8.1.11 throughout the root development manifest and affected split-package requirements and suggestions. These releases contain the published security fixes; unrelated Symfony components retain their existing available version constraints. Update 61 entries across 35 manifests. Composer validation passes for each changed manifest, and the resolved patched dependencies pass source and type analysis and the analyzer suite. The root suite exposed one stale queue mock, corrected and verified separately in this checkpoint. --- composer.json | 12 ++++++------ src/ai/composer.json | 2 +- src/auth/composer.json | 4 ++-- src/broadcasting/composer.json | 4 ++-- src/contracts/composer.json | 4 ++-- src/cookie/composer.json | 2 +- src/filesystem/composer.json | 6 +++--- src/fortify/composer.json | 2 +- src/foundation/composer.json | 4 ++-- src/grpc/composer.json | 4 ++-- src/head/composer.json | 4 ++-- src/horizon/composer.json | 4 ++-- src/http-server/composer.json | 2 +- src/http/composer.json | 4 ++-- src/inertia/composer.json | 2 +- src/mail/composer.json | 4 ++-- src/mcp/composer.json | 6 +++--- src/opentelemetry/composer.json | 2 +- src/passkeys/composer.json | 4 ++-- src/passport/composer.json | 2 +- src/permission/composer.json | 4 ++-- src/reverb/composer.json | 4 ++-- src/routing/composer.json | 6 +++--- src/saloon/composer.json | 2 +- src/sanctum/composer.json | 2 +- src/sentinel/composer.json | 2 +- src/sentry/composer.json | 2 +- src/session/composer.json | 2 +- src/support/composer.json | 2 +- src/telescope/composer.json | 4 ++-- src/testing/composer.json | 2 +- src/validation/composer.json | 2 +- src/view/composer.json | 4 ++-- src/websocket-server/composer.json | 4 ++-- src/workbench/composer.json | 2 +- 35 files changed, 61 insertions(+), 61 deletions(-) diff --git a/composer.json b/composer.json index ff3ab904a..048358588 100644 --- a/composer.json +++ b/composer.json @@ -98,8 +98,8 @@ "symfony/error-handler": "^8.1", "symfony/event-dispatcher": "^8.1", "symfony/finder": "^8.1", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/mailer": "^8.1", "symfony/mime": "^8.1", "symfony/polyfill-php85": "^1.36", @@ -107,8 +107,8 @@ "symfony/process": "^8.1", "symfony/property-access": "^8.1", "symfony/psr-http-message-bridge": "^8.1", - "symfony/routing": "^8.1", - "symfony/serializer": "^8.1", + "symfony/routing": "^8.1.11", + "symfony/serializer": "^8.1.11", "symfony/uid": "^8.1", "symfony/var-dumper": "^8.1", "symfony/yaml": "^8.1.11", @@ -140,8 +140,8 @@ "resend/resend-php": "^1.0", "spomky-labs/cbor-php": "^3.4.1", "swoole/ide-helper": "^6.0.2", - "symfony/filesystem": "^8.1", - "symfony/http-client": "^8.1", + "symfony/filesystem": "^8.1.11", + "symfony/http-client": "^8.1.11", "symfony/mailgun-mailer": "^8.1", "symfony/mercure": "^0.8", "symfony/postmark-mailer": "^8.1", diff --git a/src/ai/composer.json b/src/ai/composer.json index 496bc11fc..7c3d24915 100644 --- a/src/ai/composer.json +++ b/src/ai/composer.json @@ -61,7 +61,7 @@ "psr/http-message": "^2.0", "symfony/console": "^8.1.10", "symfony/finder": "^8.1", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/yaml": "^8.1.11" }, "conflict": { diff --git a/src/auth/composer.json b/src/auth/composer.json index 822d360f4..4e9aa7964 100644 --- a/src/auth/composer.json +++ b/src/auth/composer.json @@ -43,8 +43,8 @@ "hypervel/support": "^0.4", "nesbot/carbon": "^3.13.1", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "minimum-stability": "dev", "autoload": { diff --git a/src/broadcasting/composer.json b/src/broadcasting/composer.json index 21f39ac57..9e0c4a70c 100644 --- a/src/broadcasting/composer.json +++ b/src/broadcasting/composer.json @@ -43,13 +43,13 @@ "hypervel/support": "^0.4", "psr/http-message": "^2.0", "psr/log": "^3.0", - "symfony/http-kernel": "^8.1" + "symfony/http-kernel": "^8.1.11" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.1.10).", "hypervel/redis": "Required to use the Redis broadcast driver (^0.4).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^7.2).", - "symfony/http-client": "Required to use the Mercure broadcast driver (^8.1).", + "symfony/http-client": "Required to use the Mercure broadcast driver (^8.1.11).", "symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).", "web-token/jwt-library": "Required to use the Mercure broadcast driver (^4.2.3)." }, diff --git a/src/contracts/composer.json b/src/contracts/composer.json index 6bde73985..626c0b523 100644 --- a/src/contracts/composer.json +++ b/src/contracts/composer.json @@ -32,8 +32,8 @@ "psr/log": "^3.0", "psr/simple-cache": "^3.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/cookie/composer.json b/src/cookie/composer.json index 83f86ff92..b6469f2bf 100644 --- a/src/cookie/composer.json +++ b/src/cookie/composer.json @@ -31,7 +31,7 @@ "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", "hypervel/support": "^0.4", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/filesystem/composer.json b/src/filesystem/composer.json index d848a0c8d..05eb34d76 100644 --- a/src/filesystem/composer.json +++ b/src/filesystem/composer.json @@ -44,8 +44,8 @@ "league/flysystem-read-only": "^3.25.1", "psr/http-message": "^2.0", "symfony/finder": "^8.1", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "conflict": { "league/flysystem-ftp": "<3.13", @@ -57,7 +57,7 @@ "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", "league/flysystem-google-cloud-storage": "Required to use the Flysystem Google Cloud Storage driver (^3.25.1).", "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", - "symfony/filesystem": "Required to use relative symbolic links (^8.1).", + "symfony/filesystem": "Required to use relative symbolic links (^8.1.11).", "symfony/mime": "Required to enable support for guessing extensions (^8.1)." }, "autoload": { diff --git a/src/fortify/composer.json b/src/fortify/composer.json index 2140b0d52..dc00d849f 100644 --- a/src/fortify/composer.json +++ b/src/fortify/composer.json @@ -51,7 +51,7 @@ "psr/clock": "^1.0", "spomky-labs/otphp": "^11.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/foundation/composer.json b/src/foundation/composer.json index 38234b0af..6c178aa8e 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -38,8 +38,8 @@ "symfony/console": "^8.1.10", "symfony/error-handler": "^8.1", "symfony/finder": "^8.1", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/process": "^8.1", "symfony/var-dumper": "^8.1", "vlucas/phpdotenv": "^5.6.1", diff --git a/src/grpc/composer.json b/src/grpc/composer.json index 10248eee5..baae1f9a4 100644 --- a/src/grpc/composer.json +++ b/src/grpc/composer.json @@ -54,8 +54,8 @@ "hypervel/support": "^0.4", "nesbot/carbon": "^3.13.1", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "suggest": { "ext-protobuf": "Improves Protocol Buffers serialization performance." diff --git a/src/head/composer.json b/src/head/composer.json index a04792695..8ef40c78f 100644 --- a/src/head/composer.json +++ b/src/head/composer.json @@ -38,8 +38,8 @@ "hypervel/support": "^0.4", "hypervel/view": "^0.4", "psr/log": "^3.0", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "suggest": { "hypervel/inertia": "Required for Inertia head management (^0.4)." diff --git a/src/horizon/composer.json b/src/horizon/composer.json index bdf52e51c..6f4e49c32 100644 --- a/src/horizon/composer.json +++ b/src/horizon/composer.json @@ -51,8 +51,8 @@ "hypervel/support": "^0.4", "hypervel/watcher": "^0.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/process": "^8.1" }, "autoload": { diff --git a/src/http-server/composer.json b/src/http-server/composer.json index 55ca144eb..c2124c5bd 100644 --- a/src/http-server/composer.json +++ b/src/http-server/composer.json @@ -38,7 +38,7 @@ "hypervel/coroutine": "^0.4", "hypervel/http": "^0.4", "hypervel/server": "^0.4", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "config": { "sort-packages": true diff --git a/src/http/composer.json b/src/http/composer.json index 9cd107f48..ee510cded 100644 --- a/src/http/composer.json +++ b/src/http/composer.json @@ -47,8 +47,8 @@ "hypervel/support": "^0.4", "nesbot/carbon": "^3.13.1", "psr/http-message": "^2.0", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/mime": "^8.1" }, "suggest": { diff --git a/src/inertia/composer.json b/src/inertia/composer.json index 0fa3755d0..cf26ceeb3 100644 --- a/src/inertia/composer.json +++ b/src/inertia/composer.json @@ -34,7 +34,7 @@ "require": { "php": "^8.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/process": "^8.1", "hypervel/broadcasting": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/mail/composer.json b/src/mail/composer.json index 5ba192a8e..9227a7108 100644 --- a/src/mail/composer.json +++ b/src/mail/composer.json @@ -41,7 +41,7 @@ "hypervel/view": "^0.4", "league/commonmark": "^2.10.3", "psr/log": "^3.0", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/mailer": "^8.1", "symfony/mime": "^8.1", "tijsverkoyen/css-to-inline-styles": "^2.2.5" @@ -53,7 +53,7 @@ "hypervel/testing": "Required to use ordered Mailable assertions (^0.4).", "phpunit/phpunit": "Required to use Mailable assertion methods (^13.0).", "resend/resend-php": "Required to enable support for the Resend mail transport (^1.0).", - "symfony/http-client": "Required to use the Symfony API mail transports (^8.1).", + "symfony/http-client": "Required to use the Symfony API mail transports (^8.1.11).", "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^8.1).", "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^8.1)." }, diff --git a/src/mcp/composer.json b/src/mcp/composer.json index b304de5ef..e99409b93 100644 --- a/src/mcp/composer.json +++ b/src/mcp/composer.json @@ -55,10 +55,10 @@ "league/flysystem": "^3.25.1", "psr/http-message": "^2.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/process": "^8.1", - "symfony/routing": "^8.1" + "symfony/routing": "^8.1.11" }, "suggest": { "hypervel/passport": "Required to authorize MCP clients using OAuth (^0.4).", diff --git a/src/opentelemetry/composer.json b/src/opentelemetry/composer.json index 925e310ed..1cdbf7594 100644 --- a/src/opentelemetry/composer.json +++ b/src/opentelemetry/composer.json @@ -73,7 +73,7 @@ "psr/http-client": "^1.0", "psr/http-message": "^2.0", "psr/log": "^3.0", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "suggest": { "ext-protobuf": "Required when a built-in OTLP exporter uses the http/protobuf protocol (the default).", diff --git a/src/passkeys/composer.json b/src/passkeys/composer.json index 3f32f16d2..aa4be419c 100644 --- a/src/passkeys/composer.json +++ b/src/passkeys/composer.json @@ -45,8 +45,8 @@ "nesbot/carbon": "^3.13.1", "paragonie/constant_time_encoding": "^3.1", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/serializer": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/serializer": "^8.1.11", "web-auth/cose-lib": "^4.8.1", "web-auth/webauthn-lib": "^5.3" }, diff --git a/src/passport/composer.json b/src/passport/composer.json index 9f1294c96..04c4568f8 100644 --- a/src/passport/composer.json +++ b/src/passport/composer.json @@ -59,7 +59,7 @@ "psr/clock": "^1.0", "psr/http-message": "^2.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/psr-http-message-bridge": "^8.1" }, "autoload": { diff --git a/src/permission/composer.json b/src/permission/composer.json index 36de3682e..21691389e 100644 --- a/src/permission/composer.json +++ b/src/permission/composer.json @@ -52,8 +52,8 @@ "hypervel/view": "^0.4", "nesbot/carbon": "^3.13.1", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "config": { "sort-packages": true diff --git a/src/reverb/composer.json b/src/reverb/composer.json index 94eba6d55..d8ba16a59 100644 --- a/src/reverb/composer.json +++ b/src/reverb/composer.json @@ -58,8 +58,8 @@ "hypervel/validation": "^0.4", "hypervel/websocket-server": "^0.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "config": { "sort-packages": true diff --git a/src/routing/composer.json b/src/routing/composer.json index 79caa9066..3dc278aab 100644 --- a/src/routing/composer.json +++ b/src/routing/composer.json @@ -48,10 +48,10 @@ "laravel/serializable-closure": "^2.0.11", "psr/http-message": "^2.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/psr-http-message-bridge": "^8.1", - "symfony/routing": "^8.1" + "symfony/routing": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/saloon/composer.json b/src/saloon/composer.json index 169ec05ff..ce23d7da6 100644 --- a/src/saloon/composer.json +++ b/src/saloon/composer.json @@ -59,7 +59,7 @@ "psr/http-message": "^2.0", "symfony/console": "^8.1.10", "symfony/dom-crawler": "^8.1", - "symfony/filesystem": "^8.1", + "symfony/filesystem": "^8.1.11", "symfony/finder": "^8.1", "symfony/var-dumper": "^8.1" }, diff --git a/src/sanctum/composer.json b/src/sanctum/composer.json index 8d8002e86..2bcf6c2ac 100644 --- a/src/sanctum/composer.json +++ b/src/sanctum/composer.json @@ -44,7 +44,7 @@ "hypervel/session": "^0.4", "hypervel/support": "^0.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/sentinel/composer.json b/src/sentinel/composer.json index 345fe8478..61c73406b 100644 --- a/src/sentinel/composer.json +++ b/src/sentinel/composer.json @@ -31,7 +31,7 @@ "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", "hypervel/support": "^0.4", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/sentry/composer.json b/src/sentry/composer.json index 00ca5de71..f18ba39e9 100644 --- a/src/sentry/composer.json +++ b/src/sentry/composer.json @@ -64,7 +64,7 @@ "psr/log": "^3.0", "sentry/sentry": "^4.34", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "vlucas/phpdotenv": "^5.6.1" }, "config": { diff --git a/src/session/composer.json b/src/session/composer.json index 9aebda387..86236e8d8 100644 --- a/src/session/composer.json +++ b/src/session/composer.json @@ -46,7 +46,7 @@ "hypervel/support": "^0.4", "symfony/console": "^8.1.10", "symfony/finder": "^8.1", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "suggest": { "ext-redis": "Required to use the Redis session driver (^6.1); user session tracking requires ^6.3." diff --git a/src/support/composer.json b/src/support/composer.json index 3bdcaccf0..c541a1803 100644 --- a/src/support/composer.json +++ b/src/support/composer.json @@ -55,7 +55,7 @@ "nesbot/carbon": "^3.13.1", "phpoption/phpoption": "^1.9", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/process": "^8.1", "symfony/uid": "^8.1", "vlucas/phpdotenv": "^5.6.1", diff --git a/src/telescope/composer.json b/src/telescope/composer.json index b4654affe..3a62d170d 100644 --- a/src/telescope/composer.json +++ b/src/telescope/composer.json @@ -57,8 +57,8 @@ "psr/http-message": "^2.0", "psr/log": "^3.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/mime": "^8.1", "symfony/var-dumper": "^8.1" }, diff --git a/src/testing/composer.json b/src/testing/composer.json index 071808a25..34e0e78c9 100644 --- a/src/testing/composer.json +++ b/src/testing/composer.json @@ -47,7 +47,7 @@ "mockery/mockery": "^1.6.15", "nesbot/carbon": "^3.13.1", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/process": "^8.1", "vlucas/phpdotenv": "^5.6.1" }, diff --git a/src/validation/composer.json b/src/validation/composer.json index 18a24651b..e805d2b4c 100644 --- a/src/validation/composer.json +++ b/src/validation/composer.json @@ -42,7 +42,7 @@ "hypervel/support": "^0.4", "hypervel/translation": "^0.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "suggest": { "hypervel/foundation": "Required to use ValidatesWhenResolvedTrait with precognitive requests." diff --git a/src/view/composer.json b/src/view/composer.json index 7ac51091b..5fd3b495f 100644 --- a/src/view/composer.json +++ b/src/view/composer.json @@ -39,8 +39,8 @@ "hypervel/macroable": "^0.4", "hypervel/reflection": "^0.4", "hypervel/support": "^0.4", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "suggest": { "hypervel/foundation": "Required for the @vite, @viteReactRefresh, and @fonts directives." diff --git a/src/websocket-server/composer.json b/src/websocket-server/composer.json index 1f40a9ba9..0149be5d7 100644 --- a/src/websocket-server/composer.json +++ b/src/websocket-server/composer.json @@ -44,8 +44,8 @@ "hypervel/routing": "^0.4", "hypervel/server": "^0.4", "hypervel/support": "^0.4", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "config": { "sort-packages": true diff --git a/src/workbench/composer.json b/src/workbench/composer.json index f6c3b4083..1b9bf41c7 100644 --- a/src/workbench/composer.json +++ b/src/workbench/composer.json @@ -46,7 +46,7 @@ "hypervel/validation": "^0.4", "hypervel/view": "^0.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "autoload": { "psr-4": { From 5a56d207656341a734c8d843ce4e59603d80f83e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:40:02 +0000 Subject: [PATCH 100/303] Align queue worker test mocks with cache contract reads Expect get(array) when the queue manager checks paused queues, matching the contract-based implementation. Remove the sibling negative many() expectation, since the strict restart-key get expectation already rejects unexpected pause-state reads. Keep the worker restart and pause behavior assertions unchanged. The complete WorkCommandTest file passes all 21 tests with 75 assertions, and formatting is clean. --- tests/Integration/Queue/WorkCommandTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index 3474c8c68..8871ef0f0 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -302,7 +302,7 @@ public function testDisableLastRestartCheck(): void $cache = m::mock(Repository::class); $cache->shouldNotReceive('get')->with(Worker::RESTART_SIGNAL_CACHE_KEY); $cache->expects('get')->with('illuminate:queues:paused', false)->andReturn(false); - $cache->expects('many') + $cache->expects('get') ->with(['illuminate:queue:paused:database:default']) ->andReturn(['illuminate:queue:paused:database:default' => false]); @@ -332,7 +332,6 @@ public function testDisablePauseQueueCheck(): void $cache = m::mock(Repository::class); $cache->expects('get')->twice()->with(Worker::RESTART_SIGNAL_CACHE_KEY)->andReturn(null); - $cache->shouldNotReceive('many'); Cache::expects('store')->andReturn($cache); Cache::shouldNotReceive('driver'); From 9921adcce6b7f41ad7ef4236c9424dbea93dcd53 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:21:25 +0000 Subject: [PATCH 101/303] Describe relation and type changes in schema parser results Expose immutable impact metadata for altered, dropped and renamed relations and PostgreSQL user types. Distinguish named changes from statements whose effects cannot be bounded, while preserving the parser's snapshot and issue contracts. Cover the dialect-specific statement forms and CREATE TABLE AS handling, and document how consumers combine impact with existing schema facts. Validation: the schema-parser suite passed 570 tests during the checkpoint. --- src/docs/schema-parser.md | 28 +++++ src/schema-parser/src/Impact.php | 31 +++++ src/schema-parser/src/MySqlParser.php | 62 +++++++++- src/schema-parser/src/Parser.php | 46 +++++++- src/schema-parser/src/Parsing/ParseState.php | 30 +++++ src/schema-parser/src/PostgresParser.php | 117 +++++++++++++++++- src/schema-parser/src/SQLiteParser.php | 23 +++- src/schema-parser/src/Schema.php | 2 + src/schema-parser/src/TypeReference.php | 17 +++ tests/SchemaParser/MariaDbParserTest.php | 20 ++++ tests/SchemaParser/MySqlParserTest.php | 75 ++++++++++++ tests/SchemaParser/PostgresParserTest.php | 118 +++++++++++++++++++ tests/SchemaParser/SQLiteParserTest.php | 57 +++++++++ 13 files changed, 614 insertions(+), 12 deletions(-) create mode 100644 src/schema-parser/src/Impact.php create mode 100644 src/schema-parser/src/TypeReference.php diff --git a/src/docs/schema-parser.md b/src/docs/schema-parser.md index b9141e630..4329188cb 100644 --- a/src/docs/schema-parser.md +++ b/src/docs/schema-parser.md @@ -13,6 +13,7 @@ - [Completeness and Errors](#completeness-and-errors) - [Checking Completeness](#checking-completeness) - [Handling Parse Errors](#handling-parse-errors) +- [Changes to an Existing Schema](#changes-to-an-existing-schema) - [Supported Input](#supported-input) - [Credits](#credits) @@ -221,6 +222,33 @@ If the parser detects malformed input, it throws a `Hypervel\SchemaParser\ParseE A complete result means the parser understood the schema facts it represents. It does not guarantee that a database server would accept every statement in the input. + +## Changes to an Existing Schema + +A result only describes the definitions in its input. When the input is a script that runs against an existing schema, such as raw SQL in a migration, the schema's `impact` property describes how the script affects tables, views and PostgreSQL user types that existed before it: + +```php +use Hypervel\SchemaParser\PostgresParser; + +$impact = (new PostgresParser)->parse('ALTER TABLE users RENAME TO members')->impact; + +foreach ($impact->relations as $relation) { + echo $relation->name; // 'users', then 'members' +} + +$impact->unknown; // false +``` + +The `relations` list contains each table and view the script alters, drops or renames, including both names of a renamed relation, even when the script also defines it. These relations may no longer exist or have their earlier columns. Unqualified names use the schema the input selected, such as SQLite's `main` database or a database chosen by a MySQL `USE` statement. + +The `types` list contains each PostgreSQL enum and domain the script alters or drops without renaming it, such as an added enum label or a dropped domain, even when the script also defines it. These types may no longer exist or have their earlier definitions. + +The `unknown` property is `true` when a statement may have changed relations or types without naming them, such as a cascading drop, a renamed type or domain, a query that calls functions, or a procedure call. It is also `true` when a statement changes how later statements resolve names, such as a MySQL `USE` statement or a PostgreSQL `search_path` setting, because separately parsed input cannot see that change. + +Statements that only manage indexes, comments, routines, triggers or sequences, change rows, or change other session settings leave existing relations unchanged. The impact describes only the statements themselves: changing rows can still run triggers or functions on the server, and their effects are not included. + +Tables and views the script creates are available through the schema's usual properties. Since the parser does not know which relations existed before the script, a definition guarded by `IF NOT EXISTS` is included even if the existing schema already has that relation. + ## Supported Input diff --git a/src/schema-parser/src/Impact.php b/src/schema-parser/src/Impact.php new file mode 100644 index 000000000..bcaca732d --- /dev/null +++ b/src/schema-parser/src/Impact.php @@ -0,0 +1,31 @@ + $relations the tables and views the input alters, drops or renames, including both names of a rename, whether or not it also defines them + * @param bool $unknown whether a statement may have changed relations or types without naming them, such as a cascading drop, a type rename or a function call, or changed how later statements resolve names + * @param list $types the PostgreSQL enums and domains the input alters or drops without renaming them, whether or not it also defines them + */ + public function __construct( + public array $relations = [], + public bool $unknown = false, + public array $types = [], + ) { + } +} diff --git a/src/schema-parser/src/MySqlParser.php b/src/schema-parser/src/MySqlParser.php index 6c3fb0201..6bedec0a7 100644 --- a/src/schema-parser/src/MySqlParser.php +++ b/src/schema-parser/src/MySqlParser.php @@ -119,9 +119,13 @@ protected function parseStatement(TokenStream $tokens, ParseState $state): void } elseif ($tokens->accept('SET')) { $this->parseSet($tokens, $state); } elseif ($tokens->accept('USE')) { + // Statements parsed separately afterwards run in this database, but read as running in the original one. + $state->unknownImpact = true; $state->defaultSchema = $this->parseName($tokens, 'a database name'); - } elseif ($tokens->is('RENAME', 'TABLE')) { - $this->reportRename($state); + } elseif ($tokens->accept('RENAME', 'TABLE') || $tokens->accept('RENAME', 'TABLES')) { + $this->parseRenameTable($tokens, $state); + } else { + $this->recordUnreadStatement($tokens, $state); } } @@ -138,6 +142,11 @@ private function parseSet(TokenStream $tokens, ParseState $state): void $lexer = $tokens->lexer; $modes = [$lexer->noBackslashEscapes, $lexer->ansiQuotes]; + // MariaDB runs the statement after FOR with the given settings. + if ($tokens->is('STATEMENT')) { + $state->unknownImpact = true; + } + do { if (! $tokens->accept('SESSION')) { $tokens->accept('LOCAL'); @@ -232,6 +241,8 @@ private function parseCreate(TokenStream $tokens, ParseState $state): void $tokens->next(); $tokens->next(); $this->parseCreateIndex($tokens, $state, $replace, $kind); + } elseif (! isset(self::NON_RELATION_OBJECTS[$kind])) { + $state->unknownImpact = true; } } @@ -350,6 +361,8 @@ protected function parseTableTail(TokenStream $tokens, ParseState $state, TableD } if (! $tokens->atEnd()) { + // The query runs, and can change other relations. + $state->unknownImpact = true; $table->issues[] = $state->issue('CREATE TABLE SELECT can add columns that are not declared in the schema.'); return; @@ -894,7 +907,9 @@ private function parseAlter(TokenStream $tokens, ParseState $state): void // ALTER VIEW replaces the view's query. if ($tokens->accept('VIEW')) { - $this->parseCreateView($tokens, $state); + $state->affect($this->parseCreateView($tokens, $state)); + } elseif (! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; } } @@ -911,8 +926,17 @@ private function parseAlterTable(TokenStream $tokens, ParseState $state): void return; } + $state->affect($reference); + while (! $tokens->atEnd()) { if ($tokens->is('RENAME') && ! in_array($tokens->keyword(1), ['COLUMN', 'INDEX', 'KEY'], true)) { + $tokens->next(); + + if (! $tokens->accept('TO')) { + $tokens->accept('AS'); + } + + $state->affect($this->parseRelationName($tokens, $state, 'a new table name')); $this->reportRename($state); return; @@ -934,6 +958,31 @@ private function parseAlterTable(TokenStream $tokens, ParseState $state): void } } + /** + * Record both names of each table a RENAME TABLE statement renames. + */ + private function parseRenameTable(TokenStream $tokens, ParseState $state): void + { + // MariaDB accepts IF EXISTS and a lock wait option. + $tokens->accept('IF', 'EXISTS'); + + do { + $state->affect($this->parseRelationName($tokens, $state, 'a renamed table name')); + + if (! $tokens->accept('NOWAIT') && $tokens->accept('WAIT')) { + $tokens->next(); + } + + if (! $tokens->accept('TO')) { + throw $tokens->error('Expected the new table name'); + } + + $state->affect($this->parseRelationName($tokens, $state, 'a new table name')); + } while ($tokens->acceptSymbol(',')); + + $this->reportRename($state); + } + /** * Record that a renamed table is not replayed across the definitions that refer to it. */ @@ -957,8 +1006,12 @@ private function parseDrop(TokenStream $tokens, ParseState $state): void $view = $tokens->accept('VIEW'); - // Dropping a temporary table leaves the schema unchanged. + // Temporary tables are session objects, not part of the schema. if (! $view && ! $tokens->accept('TABLE')) { + if (! $tokens->is('TEMPORARY') && ! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; + } + return; } @@ -967,6 +1020,7 @@ private function parseDrop(TokenStream $tokens, ParseState $state): void // MySQL accepts but ignores a trailing RESTRICT or CASCADE. do { $reference = $this->parseRelationName($tokens, $state, 'a dropped object name'); + $state->affect($reference); $key = $state->key($reference->schema, $reference->name); if ($view && isset($state->views[$key])) { diff --git a/src/schema-parser/src/Parser.php b/src/schema-parser/src/Parser.php index 208f3431e..4025f7a5c 100644 --- a/src/schema-parser/src/Parser.php +++ b/src/schema-parser/src/Parser.php @@ -25,6 +25,23 @@ abstract class Parser implements ParserContract /** The texts of the punctuation and terminator that end a list entry. */ private const array ENTRY_ENDS = [',' => true, ')' => true, '' => true]; + /** Statements that change rows rather than relations. */ + private const array DATA_STATEMENTS = [ + 'INSERT' => true, + 'UPDATE' => true, + 'DELETE' => true, + 'REPLACE' => true, + 'TRUNCATE' => true, + ]; + + /** Kinds of objects that are not relations, which a relation only depends on in ways that stop a drop without CASCADE. */ + protected const array NON_RELATION_OBJECTS = [ + 'FUNCTION' => true, + 'PROCEDURE' => true, + 'TRIGGER' => true, + 'SEQUENCE' => true, + ]; + /** Keywords that end a column type or default by beginning the next column clause. */ protected const array COLUMN_CLAUSES = [ 'CONSTRAINT' => true, @@ -83,6 +100,18 @@ abstract protected function parseStatement(TokenStream $tokens, ParseState $stat */ abstract protected function parseType(TokenStream $tokens, ParseState $state): ColumnType; + /** + * Record that a statement the dialect does not read may change relations without naming them, unless it only changes rows. + * + * Row changes can still run server-side code, such as triggers, whose effects are outside the impact. + */ + protected function recordUnreadStatement(TokenStream $tokens, ParseState $state): void + { + if (! isset(self::DATA_STATEMENTS[$tokens->text()])) { + $state->unknownImpact = true; + } + } + /** * Read an identifier. */ @@ -472,15 +501,15 @@ protected function parseReferentialAction(TokenStream $tokens): ReferentialActio } /** - * Read a view from its name, without inferring the types its query produces. + * Read a view from its name, without inferring the types its query produces, and return its name. */ - protected function parseCreateView(TokenStream $tokens, ParseState $state, bool $materialized = false): void + protected function parseCreateView(TokenStream $tokens, ParseState $state, bool $materialized = false): TableReference { $conditional = $tokens->accept('IF', 'NOT', 'EXISTS'); $reference = $this->parseRelationName($tokens, $state, 'a view name'); if ($this->isTemporary($reference) || ($conditional && $this->relationExists($state, $reference->schema, $reference->name))) { - return; + return $reference; } // A column list and view options can precede the query. @@ -497,6 +526,8 @@ protected function parseCreateView(TokenStream $tokens, ParseState $state, bool } $state->addView(new View($reference->name, $this->parseViewQuery($tokens), $reference->schema, $materialized)); + + return $reference; } /** @@ -562,7 +593,14 @@ protected function freeze(ParseState $state): Schema $tables[] = $this->freezeTable($table, $state); } - return new Schema($tables, array_values($state->views), array_values($state->enums), array_values($state->domains), $state->issues); + return new Schema( + $tables, + array_values($state->views), + array_values($state->enums), + array_values($state->domains), + $state->issues, + new Impact(array_values($state->affectedRelations), $state->unknownImpact, array_values($state->affectedTypes)), + ); } /** diff --git a/src/schema-parser/src/Parsing/ParseState.php b/src/schema-parser/src/Parsing/ParseState.php index 026ea130c..8662c683f 100644 --- a/src/schema-parser/src/Parsing/ParseState.php +++ b/src/schema-parser/src/Parsing/ParseState.php @@ -10,6 +10,7 @@ use Hypervel\SchemaParser\Index; use Hypervel\SchemaParser\Issue; use Hypervel\SchemaParser\TableReference; +use Hypervel\SchemaParser\TypeReference; use Hypervel\SchemaParser\View; /** @@ -61,6 +62,15 @@ final class ParseState /** @var list */ public array $issues = []; + /** @var array the tables and views the input alters, drops or renames, by key */ + public array $affectedRelations = []; + + /** @var array the user types and domains the input alters or drops, by key */ + public array $affectedTypes = []; + + /** Whether a statement may have changed relations or types without naming them, or changed how later statements resolve names. */ + public bool $unknownImpact = false; + /** The schema of unqualified relation names: SQLite's main database, or the one a MySQL USE statement selects. */ public ?string $defaultSchema = null; @@ -96,6 +106,26 @@ public function issue(string $message): Issue return new Issue($message, $this->line); } + /** + * Record that the input alters, drops or renames a table or view, naming an unqualified one in the default schema. + */ + public function affect(TableReference $relation): void + { + if ($relation->schema === null && $this->defaultSchema !== null) { + $relation = new TableReference($relation->name, $this->defaultSchema); + } + + $this->affectedRelations[$this->key($relation->schema, $relation->name)] ??= $relation; + } + + /** + * Record that the input alters or drops a user type or domain. + */ + public function affectType(TypeReference $type): void + { + $this->affectedTypes[$this->key($type->schema, $type->name)] ??= $type; + } + /** * Get the identity of a name, without its ASCII case where names compare case-insensitively. */ diff --git a/src/schema-parser/src/PostgresParser.php b/src/schema-parser/src/PostgresParser.php index 6e306c84d..a12fac634 100644 --- a/src/schema-parser/src/PostgresParser.php +++ b/src/schema-parser/src/PostgresParser.php @@ -135,6 +135,26 @@ protected function parseStatement(TokenStream $tokens, ParseState $state): void $this->parseDrop($tokens, $state); } elseif ($tokens->accept('SET', 'STANDARD_CONFORMING_STRINGS') || $tokens->accept('SET', 'SESSION', 'STANDARD_CONFORMING_STRINGS')) { $this->parseStandardConformingStrings($tokens); + } elseif ($tokens->accept('SET')) { + $this->parseSet($tokens, $state); + } else { + $this->recordUnreadStatement($tokens, $state); + } + } + + /** + * Record a setting that changes how later statements resolve names, which other settings leave unchanged. + */ + private function parseSet(TokenStream $tokens, ParseState $state): void + { + if (! $tokens->accept('SESSION')) { + $tokens->accept('LOCAL'); + } + + // The role is the search path's $user schema. + if ($tokens->is('SEARCH_PATH') || $tokens->is('SCHEMA') || $tokens->is('ROLE') + || $tokens->is('AUTHORIZATION') || $tokens->is('SESSION', 'AUTHORIZATION')) { + $state->unknownImpact = true; } } @@ -201,6 +221,8 @@ private function parseCreate(TokenStream $tokens, ParseState $state): void $this->parseCreateEnum($tokens, $state); } elseif ($tokens->accept('DOMAIN')) { $this->parseCreateDomain($tokens, $state); + } elseif (! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; } } @@ -233,7 +255,7 @@ private function parseCreateTable(TokenStream $tokens, ParseState $state, bool $ } $table->issues[] = $state->issue('The partition declaration does not include its complete column and constraint definitions.'); - } elseif ($tokens->isSymbol('(')) { + } elseif ($tokens->isSymbol('(') && ! $this->isQueryColumnList($tokens)) { $this->parseTableBody($tokens, $state, $table); if ($tokens->accept('INHERITS')) { @@ -263,6 +285,19 @@ private function parseCreateTable(TokenStream $tokens, ParseState $state, bool $ } elseif ($tokens->atEnd()) { throw $tokens->error('Expected a table definition'); } else { + while (! $tokens->atEnd() && ! $tokens->is('AS')) { + if ($tokens->isSymbol('(')) { + $tokens->skipGroup(); + } else { + $tokens->next(); + } + } + + // CREATE TABLE AS runs its query, which can change other relations. + if (! $tokens->atEnd()) { + $state->unknownImpact = true; + } + $table->issues[] = $state->issue('The table declaration does not contain a complete column definition.'); } @@ -277,6 +312,17 @@ private function parseCreateTable(TokenStream $tokens, ParseState $state, bool $ } } + /** + * Determine whether a parenthesized list names the columns of a CREATE TABLE AS query rather than declaring them. + */ + private function isQueryColumnList(TokenStream $tokens): bool + { + $name = $tokens->type(1); + + return ($name === TokenType::Word || $name === TokenType::QuotedIdentifier) + && ($tokens->isSymbol(',', 2) || $tokens->isSymbol(')', 2)); + } + /** * Determine whether a name belongs to the session's temporary schema. */ @@ -1256,15 +1302,30 @@ private function parseAlter(TokenStream $tokens, ParseState $state): void { if ($tokens->accept('TABLE') || $tokens->accept('FOREIGN', 'TABLE')) { $this->parseAlterTable($tokens, $state); + } elseif ($tokens->accept('VIEW') || $tokens->accept('MATERIALIZED', 'VIEW')) { + $this->parseAlterView($tokens, $state); } elseif ($tokens->accept('DOMAIN')) { $this->parseAlterDomain($tokens, $state); } elseif ($tokens->accept('TYPE')) { - $this->parseQualifiedName($tokens, 'a type name'); + $reference = $this->parseQualifiedName($tokens, 'a type name'); - if ($this->isIdentityChange($tokens) || $tokens->is('ADD', 'VALUE') || $tokens->is('RENAME', 'VALUE')) { + if ($tokens->is('ADD', 'VALUE') || $tokens->is('RENAME', 'VALUE')) { + // Only the enum's labels change; its columns keep referring to it by name. + $state->affectType(new TypeReference($reference->name, $reference->schema)); $this->reportIdentityChange($state); + } else { + // A rename or schema move reaches the type's columns without naming them, as other changes can. + $state->unknownImpact = true; + + if ($this->isIdentityChange($tokens)) { + $this->reportIdentityChange($state); + } } } else { + if (! $tokens->is('INDEX') && ! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; + } + while (! $tokens->atEnd()) { if ($this->isIdentityChange($tokens)) { $this->reportIdentityChange($state); @@ -1296,6 +1357,22 @@ private function isIdentityChange(TokenStream $tokens): bool && $tokens->type(3) === TokenType::End; } + /** + * Read the new identity that a rename or schema change gives a relation. + */ + private function parseIdentityChange(TokenStream $tokens, TableReference $reference): TableReference + { + $rename = $tokens->accept('RENAME', 'TO'); + + if (! $rename) { + $tokens->accept('SET', 'SCHEMA'); + } + + $name = $this->parseName($tokens, 'a new identity'); + + return $rename ? new TableReference($name, $reference->schema) : new TableReference($reference->name, $name); + } + /** * Record that an identity or enum label change is not replayed across dependent definitions. */ @@ -1311,6 +1388,8 @@ private function reportIdentityChange(ParseState $state): void */ private function reportCascade(ParseState $state): void { + $state->unknownImpact = true; + if ($state->hasDefinitions()) { $state->issues[] = $state->issue('Cascading drops can change dependent definitions that are not replayed.'); } @@ -1325,8 +1404,10 @@ private function parseAlterTable(TokenStream $tokens, ParseState $state): void $only = $tokens->accept('ONLY'); $reference = $this->parseQualifiedName($tokens, 'a table name'); $tokens->acceptSymbol('*'); + $state->affect($reference); if ($this->isIdentityChange($tokens)) { + $state->affect($this->parseIdentityChange($tokens, $reference)); $this->reportIdentityChange($state); return; @@ -1340,6 +1421,21 @@ private function parseAlterTable(TokenStream $tokens, ParseState $state): void } while ($tokens->acceptSymbol(',')); } + /** + * Record the view or materialized view an ALTER statement changes, and its new identity after a rename. + */ + private function parseAlterView(TokenStream $tokens, ParseState $state): void + { + $tokens->accept('IF', 'EXISTS'); + $reference = $this->parseQualifiedName($tokens, 'a view name'); + $state->affect($reference); + + if ($this->isIdentityChange($tokens)) { + $state->affect($this->parseIdentityChange($tokens, $reference)); + $this->reportIdentityChange($state); + } + } + /** * Apply one ALTER TABLE action, or record what it leaves unknown. */ @@ -1834,11 +1930,16 @@ private function parseAlterDomain(TokenStream $tokens, ParseState $state): void $reference = $this->parseQualifiedName($tokens, 'a domain name'); if ($this->isIdentityChange($tokens)) { + // The domain's columns follow the rename or schema move without being named. + $state->unknownImpact = true; $this->reportIdentityChange($state); return; } + // ALTER DOMAIN cannot change the base type, so the named domain bounds the change. + $state->affectType(new TypeReference($reference->name, $reference->schema)); + if ($tokens->is('OWNER', 'TO')) { return; } @@ -1968,12 +2069,16 @@ private function parseDrop(TokenStream $tokens, ParseState $state): void }; if ($kind === null) { + $nonRelation = isset(self::NON_RELATION_OBJECTS[$tokens->text()]); + do { $cascade = $this->endsWithCascade($tokens); } while ($tokens->acceptSymbol(',')); if ($cascade) { $this->reportCascade($state); + } elseif (! $nonRelation) { + $state->unknownImpact = true; } return; @@ -2018,6 +2123,12 @@ private function drop(string $kind, TableReference $reference, bool $cascade, Pa return; } + if ($kind === 'table' || $kind === 'view') { + $state->affect($reference); + } elseif ($kind === 'type' || $kind === 'domain') { + $state->affectType(new TypeReference($reference->name, $reference->schema)); + } + if ($kind === 'schema') { $name = $reference->name; diff --git a/src/schema-parser/src/SQLiteParser.php b/src/schema-parser/src/SQLiteParser.php index ad22accea..725e570ce 100644 --- a/src/schema-parser/src/SQLiteParser.php +++ b/src/schema-parser/src/SQLiteParser.php @@ -80,6 +80,13 @@ protected function parseStatement(TokenStream $tokens, ParseState $state): void $this->parseDrop($tokens, $state); } elseif ($tokens->accept('ALTER', 'TABLE')) { $this->parseAlterTable($tokens, $state); + } elseif ($tokens->accept('PRAGMA')) { + // It lets later statements write the schema table, which changes relations without naming them. + if (strcasecmp($this->parseQualifiedName($tokens, 'a pragma name')->name, 'writable_schema') === 0) { + $state->unknownImpact = true; + } + } else { + $this->recordUnreadStatement($tokens, $state); } } @@ -100,6 +107,8 @@ private function parseCreate(TokenStream $tokens, ParseState $state): void $this->parseCreateIndex($tokens, $state, true); } elseif ($tokens->accept('VIRTUAL', 'TABLE')) { $this->parseCreateVirtualTable($tokens, $state); + } elseif (! $tokens->is('TEMP') && ! $tokens->is('TEMPORARY') && ! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; } } @@ -162,6 +171,8 @@ private function parseCreateTable(TokenStream $tokens, ParseState $state): void $table = new TableDefinition($reference->name, $reference->schema); if ($tokens->accept('AS')) { + // The query runs, and can change other relations. + $state->unknownImpact = true; $table->issues[] = $state->issue('The table declaration does not contain a complete column definition.'); } else { $this->parseTableBody($tokens, $state, $table); @@ -809,8 +820,13 @@ private function parseAlterTable(TokenStream $tokens, ParseState $state): void return; } + $state->affect($reference); + // A renamed table changes the relation names that other definitions and lookups rely on. - if ($tokens->is('RENAME', 'TO')) { + if ($tokens->accept('RENAME', 'TO')) { + // The table stays in its database. + $state->affect(new TableReference($this->parseName($tokens, 'a new table name'), $reference->schema)); + if ($state->hasDefinitions()) { $state->issues[] = $state->issue('A table rename changes table identities that are not replayed.'); } @@ -840,6 +856,10 @@ private function parseDrop(TokenStream $tokens, ParseState $state): void }; if ($kind === null) { + if (! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; + } + return; } @@ -856,6 +876,7 @@ private function parseDrop(TokenStream $tokens, ParseState $state): void return; } + $state->affect($reference); $view = $kind === 'view'; $key = $this->relationKey($state, $reference, $view); diff --git a/src/schema-parser/src/Schema.php b/src/schema-parser/src/Schema.php index e19b5edef..82fee35ad 100644 --- a/src/schema-parser/src/Schema.php +++ b/src/schema-parser/src/Schema.php @@ -30,6 +30,7 @@ * @param list $enums * @param list $domains * @param list $issues + * @param Impact $impact how the input affects relations that existed before it */ public function __construct( public array $tables = [], @@ -37,6 +38,7 @@ public function __construct( public array $enums = [], public array $domains = [], public array $issues = [], + public Impact $impact = new Impact, ) { $this->tablesByName = $this->indexDefinitions($tables); $this->viewsByName = $this->indexDefinitions($views); diff --git a/src/schema-parser/src/TypeReference.php b/src/schema-parser/src/TypeReference.php new file mode 100644 index 000000000..e54190c44 --- /dev/null +++ b/src/schema-parser/src/TypeReference.php @@ -0,0 +1,17 @@ +assertFalse($table->column('parent_id')->nullable); } + public function testMariaDbStatementFormsReportTheirImpact(): void + { + $parser = new MariaDbParser; + $impact = $parser->parse(<<<'SQL' + CREATE SEQUENCE invoice_numbers; + DROP SEQUENCE invoice_numbers; + RENAME TABLE IF EXISTS carts WAIT 5 TO baskets, orders NOWAIT TO purchases; + SQL)->impact; + + $this->assertEquals([ + new TableReference('carts'), + new TableReference('baskets'), + new TableReference('orders'), + new TableReference('purchases'), + ], $impact->relations); + $this->assertFalse($impact->unknown); + $this->assertTrue($parser->parse('SET STATEMENT max_statement_time = 60 FOR DROP TABLE users')->impact->unknown); + } + #[DataProvider('malformedDefinitionProvider')] public function testMalformedDefinitionsFail(string $sql, string $message): void { diff --git a/tests/SchemaParser/MySqlParserTest.php b/tests/SchemaParser/MySqlParserTest.php index f9fc72e36..1ca906f63 100644 --- a/tests/SchemaParser/MySqlParserTest.php +++ b/tests/SchemaParser/MySqlParserTest.php @@ -708,6 +708,81 @@ public function testUnappliedRenamesInvalidateTheTableNameInventory(): void $this->assertSame(2, $schema->issues[0]->line); } + #[DataProvider('impactProvider')] + public function testImpactNamesChangedRelationsAndFlagsUnnamedChanges(string $sql, array $relations, bool $unknown): void + { + $impact = (new MySqlParser)->parse($sql)->impact; + + $this->assertEquals($relations, $impact->relations); + $this->assertSame($unknown, $impact->unknown); + } + + /** + * Provide statements with the relations they change and whether they can change others without naming them. + */ + public static function impactProvider(): iterable + { + yield 'indexes, new definitions and temporary tables' => [<<<'SQL' + CREATE TABLE posts (id int); + CREATE VIEW recent AS SELECT id FROM posts; + CREATE UNIQUE INDEX users_email ON users (email); + DROP INDEX users_email ON users; + ALTER TABLE users DISABLE KEYS; + CREATE TEMPORARY TABLE scratch (id int); + DROP TEMPORARY TABLE scratch; + SQL, [], false]; + + yield 'rows, settings and routines' => [<<<'SQL' + SET FOREIGN_KEY_CHECKS = 0; + SET NAMES utf8mb4; + INSERT INTO users (email) VALUES ('a@example.com'); + UPDATE users SET email = lower(email); + DELETE FROM users WHERE email IS NULL; + REPLACE INTO settings (name) VALUES ('theme'); + TRUNCATE TABLE sessions; + CREATE DEFINER = CURRENT_USER TRIGGER touch_users BEFORE UPDATE ON users FOR EACH ROW SET NEW.updated_at = NOW(); + DROP TRIGGER IF EXISTS touch_users; + DELIMITER ;; + CREATE PROCEDURE cleanup() BEGIN DROP TABLE users; END;; + DELIMITER ; + ALTER PROCEDURE cleanup COMMENT 'removes users'; + DROP PROCEDURE cleanup; + DROP FUNCTION IF EXISTS slug; + SQL, [], false]; + + yield 'altered, dropped and renamed relations' => [<<<'SQL' + CREATE TABLE IF NOT EXISTS users (id int); + DROP TABLE users; + ALTER TABLE posts ADD COLUMN body text; + ALTER TABLE posts RENAME COLUMN body TO content; + ALTER TABLE accounts RENAME TO members; + ALTER TABLE logs ADD COLUMN level int, RENAME AS archive.logs; + RENAME TABLE carts TO app.baskets, a TO b; + DROP VIEW IF EXISTS recent, app.feed; + ALTER ALGORITHM = MERGE VIEW totals AS SELECT 1; + SQL, [ + new TableReference('users'), + new TableReference('posts'), + new TableReference('accounts'), + new TableReference('members'), + new TableReference('logs'), + new TableReference('logs', 'archive'), + new TableReference('carts'), + new TableReference('baskets', 'app'), + new TableReference('a'), + new TableReference('b'), + new TableReference('recent'), + new TableReference('feed', 'app'), + new TableReference('totals'), + ], false]; + + yield 'database selection' => ['USE app; DROP TABLE users;', [new TableReference('users', 'app')], true]; + yield 'query' => ['SELECT COUNT(*) FROM users', [], true]; + yield 'table from a query' => ['CREATE TABLE copied SELECT id FROM users', [], true]; + yield 'database drop' => ['DROP DATABASE app', [], true]; + yield 'database change' => ['ALTER DATABASE app CHARACTER SET utf8mb4', [], true]; + } + public function testIssuesReportTheLineOfTheirStatement(): void { $parser = new MySqlParser; diff --git a/tests/SchemaParser/PostgresParserTest.php b/tests/SchemaParser/PostgresParserTest.php index a0ac7eb9b..770eff471 100644 --- a/tests/SchemaParser/PostgresParserTest.php +++ b/tests/SchemaParser/PostgresParserTest.php @@ -12,6 +12,7 @@ use Hypervel\SchemaParser\PostgresParser; use Hypervel\SchemaParser\ReferentialAction; use Hypervel\SchemaParser\TableReference; +use Hypervel\SchemaParser\TypeReference; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -913,6 +914,19 @@ public function testForeignTableOptionsAndPartitionBoundsStaySeparate(): void $this->assertFalse($schema->table('remote')->isComplete()); } + public function testTablesCreatedFromQueriesAreIncompleteWithOrWithoutColumnNames(): void + { + $schema = (new PostgresParser)->parse(<<<'SQL' + CREATE TABLE copied AS SELECT id FROM users; + CREATE TABLE renamed (id, label) AS SELECT id, name FROM users WITH NO DATA; + SQL); + + $this->assertSame([], $schema->table('copied')->columns); + $this->assertFalse($schema->table('copied')->isComplete()); + $this->assertSame([], $schema->table('renamed')->columns); + $this->assertFalse($schema->table('renamed')->isComplete()); + } + #[DataProvider('unresolvedSchemaChanges')] public function testUnresolvedDependenciesInvalidateTheSchemaInventory(string $statement): void { @@ -1015,6 +1029,110 @@ public function testDropsDoNotChooseBetweenAmbiguousSchemas(): void $this->assertCount(1, $schema->table('t', 'b')->indexes); } + #[DataProvider('impactProvider')] + public function testImpactNamesChangedRelationsAndTypesAndFlagsUnnamedChanges(string $sql, array $relations, bool $unknown, array $types = []): void + { + $impact = (new PostgresParser)->parse($sql)->impact; + + $this->assertEquals($relations, $impact->relations); + $this->assertSame($unknown, $impact->unknown); + $this->assertEquals($types, $impact->types); + } + + /** + * Provide statements with the relations they change, whether they can change others without naming them, and the types they change. + */ + public static function impactProvider(): iterable + { + yield 'indexes, comments and new definitions' => [<<<'SQL' + CREATE TABLE posts (id integer, title text); + CREATE VIEW recent AS SELECT id FROM posts; + CREATE MATERIALIZED VIEW stats AS SELECT 1 AS total; + CREATE TYPE mood AS ENUM ('happy'); + CREATE DOMAIN code AS text; + CREATE UNIQUE INDEX users_email ON users (email); + ALTER INDEX users_email RENAME TO users_email_unique; + DROP INDEX users_email_unique; + COMMENT ON TABLE users IS 'members'; + COMMENT ON COLUMN users.email IS 'login'; + CREATE TEMP TABLE scratch (id integer); + SQL, [], false]; + + yield 'rows, settings, routines and drops without cascade' => [<<<'SQL' + SET lock_timeout = '5s'; + SET standard_conforming_strings = on; + INSERT INTO users (email) VALUES ('a@example.com'); + UPDATE users SET email = lower(email); + DELETE FROM users WHERE email IS NULL; + TRUNCATE sessions; + CREATE OR REPLACE FUNCTION touch() RETURNS trigger AS $$ BEGIN DROP TABLE users CASCADE; END $$ LANGUAGE plpgsql; + CREATE TRIGGER touch_users BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION touch(); + CREATE SEQUENCE invoice_numbers; + ALTER SEQUENCE users_id_seq RESTART WITH 1000; + DROP TRIGGER touch_users ON users; + DROP FUNCTION archive.cascade(integer), touch(); + DROP SEQUENCE invoice_numbers; + DROP SCHEMA archive; + SQL, [], false]; + + yield 'altered and dropped types' => [<<<'SQL' + ALTER TYPE mood ADD VALUE 'sad'; + ALTER TYPE public.mood RENAME VALUE 'sad' TO 'unhappy'; + ALTER DOMAIN code SET NOT NULL; + ALTER DOMAIN app.code OWNER TO admin; + DROP TYPE IF EXISTS mood, app.status; + DROP DOMAIN code; + SQL, [], false, [ + new TypeReference('mood'), + new TypeReference('mood', 'public'), + new TypeReference('code'), + new TypeReference('code', 'app'), + new TypeReference('status', 'app'), + ]]; + + yield 'altered, dropped and renamed relations' => [<<<'SQL' + CREATE TABLE IF NOT EXISTS users (id integer); + DROP TABLE users; + ALTER TABLE ONLY public.posts ADD COLUMN body text; + ALTER TABLE posts RENAME COLUMN body TO content; + ALTER TABLE public.posts DROP COLUMN body; + ALTER TABLE accounts RENAME TO members; + ALTER TABLE app.logs SET SCHEMA archive; + ALTER MATERIALIZED VIEW IF EXISTS stats RENAME TO totals; + DROP VIEW IF EXISTS recent, app.feed; + DROP FOREIGN TABLE remote; + SQL, [ + new TableReference('users'), + new TableReference('posts', 'public'), + new TableReference('posts'), + new TableReference('accounts'), + new TableReference('members'), + new TableReference('logs', 'app'), + new TableReference('logs', 'archive'), + new TableReference('stats'), + new TableReference('totals'), + new TableReference('recent'), + new TableReference('feed', 'app'), + new TableReference('remote'), + ], false]; + + yield 'cascading drop' => ['DROP TABLE users CASCADE', [new TableReference('users')], true]; + yield 'cascading column drop' => ['ALTER TABLE users DROP COLUMN id CASCADE', [new TableReference('users')], true]; + yield 'cascading routine drop' => ['DROP FUNCTION touch() CASCADE', [], true]; + yield 'cascading type drop' => ['DROP TYPE mood CASCADE', [], true, [new TypeReference('mood')]]; + yield 'type rename' => ['ALTER TYPE mood RENAME TO feeling', [], true]; + yield 'other type change' => ['ALTER TYPE address ADD ATTRIBUTE zip text', [], true]; + yield 'domain schema move' => ['ALTER DOMAIN code SET SCHEMA archive', [], true]; + yield 'schema rename' => ['ALTER SCHEMA app RENAME TO archive', [], true]; + yield 'search path' => ['SET search_path TO archive, public', [], true]; + yield 'role' => ['SET LOCAL ROLE admin', [], true]; + yield 'session authorization' => ['SET SESSION AUTHORIZATION admin', [], true]; + yield 'function call' => ["SELECT create_hypertable('metrics', 'time')", [], true]; + yield 'extension' => ['CREATE EXTENSION IF NOT EXISTS pg_trgm', [], true]; + yield 'table from a query' => ['CREATE TABLE copied (id) AS SELECT id FROM users', [], true]; + yield 'owned objects' => ['DROP OWNED BY admin', [], true]; + } + public function testIssueLinesAdvanceAcrossMultipleDiagnosticsAndResetForEachParse(): void { $parser = new PostgresParser; diff --git a/tests/SchemaParser/SQLiteParserTest.php b/tests/SchemaParser/SQLiteParserTest.php index 1b31cb942..a381d8da2 100644 --- a/tests/SchemaParser/SQLiteParserTest.php +++ b/tests/SchemaParser/SQLiteParserTest.php @@ -11,6 +11,7 @@ use Hypervel\SchemaParser\ParseException; use Hypervel\SchemaParser\ReferentialAction; use Hypervel\SchemaParser\SQLiteParser; +use Hypervel\SchemaParser\TableReference; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -342,6 +343,62 @@ public function testUnappliedAlterationsKeepTheSchemaHonest(): void $this->assertCount(1, (new SQLiteParser)->parse('ALTER TABLE missing ADD COLUMN b;')->issues); } + #[DataProvider('impactProvider')] + public function testImpactNamesChangedRelationsAndFlagsUnnamedChanges(string $sql, array $relations, bool $unknown): void + { + $impact = (new SQLiteParser)->parse($sql)->impact; + + $this->assertEquals($relations, $impact->relations); + $this->assertSame($unknown, $impact->unknown); + } + + /** + * Provide statements with the relations they change and whether they can change others without naming them. + */ + public static function impactProvider(): iterable + { + yield 'indexes, rows, pragmas, triggers and temporary objects' => [<<<'SQL' + PRAGMA foreign_keys = OFF; + CREATE TABLE posts (id integer); + CREATE VIEW recent AS SELECT id FROM posts; + CREATE UNIQUE INDEX users_email ON users (email); + DROP INDEX users_email; + INSERT INTO users (email) VALUES ('a@example.com'); + UPDATE users SET email = lower(email); + DELETE FROM users WHERE email IS NULL; + REPLACE INTO settings (name) VALUES ('theme'); + CREATE TRIGGER touch_users AFTER UPDATE ON users BEGIN + DROP TABLE posts; + END; + DROP TRIGGER touch_users; + CREATE TEMP TABLE scratch (id integer); + ALTER TABLE temp.scratch ADD COLUMN label text; + DROP TABLE temp.scratch; + SQL, [], false]; + + yield 'altered, dropped and renamed relations' => [<<<'SQL' + CREATE TABLE IF NOT EXISTS users (id integer); + DROP TABLE users; + ALTER TABLE Main.posts ADD COLUMN body text; + ALTER TABLE POSTS RENAME COLUMN body TO content; + ALTER TABLE accounts RENAME TO members; + DROP TABLE IF EXISTS archive.logs; + DROP VIEW recent; + SQL, [ + new TableReference('users', 'main'), + new TableReference('posts', 'main'), + new TableReference('accounts', 'main'), + new TableReference('members', 'main'), + new TableReference('logs', 'archive'), + new TableReference('recent', 'main'), + ], false]; + + yield 'writable schema' => ['PRAGMA main.writable_schema = ON', [], true]; + yield 'table from a query' => ['CREATE TABLE copied AS SELECT id FROM users', [], true]; + yield 'attached database' => ["ATTACH DATABASE 'archive.db' AS archive", [], true]; + yield 'query' => ['SELECT COUNT(*) FROM users', [], true]; + } + public function testIncompleteTablesDoNotHideOtherDeclarations(): void { $schema = (new SQLiteParser)->parse(<<<'SQL' From 5d6c5967a96a1bc0abf2ffc2409ec0c469a5c1f0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:21:25 +0000 Subject: [PATCH 102/303] Preserve inherited method templates in collection and Sentry overrides Repeat generic declarations at the eight overrides where PHPStan 2.3.5 loses inherited method-template metadata. Each localized declaration carries a removal TODO; runtime implementations are unchanged. Allow null callback keys in keyBy's template and describe their string normalization. Add inference regressions for unresolved callback results and ordinary typed keys. Validation: collection runtime suites, the types harness and the analyzer suite passed for the combined checkpoint. --- src/collections/src/Collection.php | 6 +++ src/collections/src/Enumerable.php | 4 +- src/collections/src/LazyCollection.php | 45 +++++++++++++++++++ src/sentry/src/Hub.php | 12 +++++ .../Type/data/inherited-method-templates.php | 29 ++++++++++++ types/Collections/Collection.php | 2 + types/Collections/LazyCollection.php | 2 + 7 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Type/data/inherited-method-templates.php diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index eb6685526..684d15b75 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -587,6 +587,12 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal /** * Key an associative array by a field or using a callback. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TNewKey of array-key|null|UnitEnum|BaseStringable + * + * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy + * @return static<($keyBy is (array|string) ? array-key : (TNewKey is array-key ? TNewKey : (TNewKey is (null|BaseStringable) ? string : array-key))), TValue> */ #[Override] public function keyBy(callable|array|string $keyBy): static diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php index b33c301fe..405fecfca 100644 --- a/src/collections/src/Enumerable.php +++ b/src/collections/src/Enumerable.php @@ -458,10 +458,10 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal /** * Key an associative array by a field or using a callback. * - * @template TNewKey of array-key|UnitEnum|BaseStringable + * @template TNewKey of array-key|null|UnitEnum|BaseStringable * * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy - * @return static<($keyBy is (array|string) ? array-key : (TNewKey is array-key ? TNewKey : (TNewKey is BaseStringable ? string : array-key))), TValue> + * @return static<($keyBy is (array|string) ? array-key : (TNewKey is array-key ? TNewKey : (TNewKey is (null|BaseStringable) ? string : array-key))), TValue> */ public function keyBy(callable|array|string $keyBy): static; diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index f5ed0753a..a7876e0b7 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -349,6 +349,15 @@ public function doesntContainStrict(mixed $key, mixed $operator = null, mixed $v return ! $this->containsStrict(...func_get_args()); } + /** + * Cross join with the given lists, returning all possible permutations. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TCrossJoinValue + * + * @param Arrayable>|iterable ...$arrays + * @return static> + */ #[Override] public function crossJoin(Arrayable|iterable ...$arrays): static { @@ -583,6 +592,12 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal /** * Key an associative array by a field or using a callback. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TNewKey of array-key|null|UnitEnum|BaseStringable + * + * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy + * @return static<($keyBy is (array|string) ? array-key : (TNewKey is array-key ? TNewKey : (TNewKey is (null|BaseStringable) ? string : array-key))), TValue> */ #[Override] public function keyBy(callable|array|string $keyBy): static @@ -802,6 +817,18 @@ public function mapSpread(callable $callback): static }); } + /** + * Run a dictionary map over the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TMapToDictionaryKey of array-key + * @template TMapToDictionaryValue + * + * @param callable(TValue, TKey): array $callback + * @return static> + */ #[Override] public function mapToDictionary(callable $callback): static { @@ -878,6 +905,15 @@ public function mapInto(string $class): static return $this->map(fn ($value, $key) => new $class($value, $key)); } + /** + * Merge the collection with the given items. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TMergeValue + * + * @param Arrayable>|iterable $items + * @return static<(array is array ? int : TKey), TMergeValue|TValue> + */ #[Override] public function merge(mixed $items): static { @@ -885,6 +921,15 @@ public function merge(mixed $items): static return $this->passthru(__FUNCTION__, func_get_args()); } + /** + * Recursively merge the collection with the given items. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TMergeRecursiveValue + * + * @param Arrayable>|iterable $items + * @return static<(array is array ? int : TKey), (TKey is int ? TMergeRecursiveValue|TValue : array|TMergeRecursiveValue|TValue)> + */ #[Override] public function mergeRecursive(mixed $items): static { diff --git a/src/sentry/src/Hub.php b/src/sentry/src/Hub.php index 976e5393c..d0b89bbae 100644 --- a/src/sentry/src/Hub.php +++ b/src/sentry/src/Hub.php @@ -457,6 +457,12 @@ public function getSpan(): ?Span /** * Run the callback with a new scope that is removed afterwards. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template T + * + * @param callable(Scope): T $callback + * @return T */ public function withScope(callable $callback): mixed { @@ -506,6 +512,12 @@ public function captureCheckIn( /** * Get the client's integration with the given class name. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template T of IntegrationInterface + * + * @param class-string $className + * @return null|T */ public function getIntegration(string $className): ?IntegrationInterface { diff --git a/tests/PHPStan/Type/data/inherited-method-templates.php b/tests/PHPStan/Type/data/inherited-method-templates.php new file mode 100644 index 000000000..716ebb7e7 --- /dev/null +++ b/tests/PHPStan/Type/data/inherited-method-templates.php @@ -0,0 +1,29 @@ + $eager + * @param LazyCollection $lazy + */ +function inheritedTemplates(Collection $eager, LazyCollection $lazy, Hub $hub, mixed $unknown, string $name): void +{ + // Hypervel: these overrides repeat their parent's method templates, which PHPStan 2.3.5 + // otherwise leaves unresolved when the arguments do not infer them. + assertType('Hypervel\Support\Collection<(int|string), string>', $eager->keyBy(fn (string $value) => $unknown)); + assertType('Hypervel\Support\LazyCollection<(int|string), string>', $lazy->keyBy(fn (string $value) => $unknown)); + assertType('Hypervel\Support\LazyCollection<(int|string), array>', $lazy->mapToDictionary(fn (string $value) => $unknown)); + assertType('Hypervel\Support\LazyCollection>', $lazy->crossJoin($unknown)); + assertType('Hypervel\Support\LazyCollection', $lazy->merge($unknown)); + assertType('Hypervel\Support\LazyCollection', $lazy->mergeRecursive($unknown)); + assertType('Sentry\Integration\IntegrationInterface|null', $hub->getIntegration($name)); + assertType('mixed', $hub->withScope($unknown)); +} diff --git a/types/Collections/Collection.php b/types/Collections/Collection.php index 1a7a6ae83..b5e70f949 100644 --- a/types/Collections/Collection.php +++ b/types/Collections/Collection.php @@ -570,6 +570,8 @@ function ($collection, $count) { assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => Digit::One)); assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => NamedDigit::One)); assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => NumberedDigit::One)); +assertType('Hypervel\Support\Collection', $collection->keyBy(static fn ($user): ?string => null)); +assertType('Hypervel\Support\Collection, User>', $collection->keyBy(static fn ($user): int => random_int(1, 10))); assertType('bool', $collection->has(0)); assertType('bool', $collection->has([0, 1])); diff --git a/types/Collections/LazyCollection.php b/types/Collections/LazyCollection.php index 8778b6859..f3c8c5fde 100644 --- a/types/Collections/LazyCollection.php +++ b/types/Collections/LazyCollection.php @@ -449,6 +449,8 @@ public function toArray(): array assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => Digit::One)); assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => NamedDigit::One)); assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => NumberedDigit::One)); +assertType('Hypervel\Support\LazyCollection', $collection->keyBy(static fn ($user): ?string => null)); +assertType('Hypervel\Support\LazyCollection, User>', $collection->keyBy(static fn ($user): int => random_int(1, 10))); assertType('bool', $collection->has(0)); assertType('bool', $collection->has([0, 1])); From 874336059e419195fc3c3ae9e3d407aa6a865510 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:21:25 +0000 Subject: [PATCH 103/303] Handle native SQLite JSON numbers and accurately declare cast inputs Return numeric values fetched from SQLite JSON columns unchanged in the default decoder, while preserving raw inputs for custom decoders. Permit those values in JSON hydration and dirty comparison so valid stored numbers no longer fail strict string arguments. Describe array-form cast definitions and integer arguments without weakening getCasts' normalized string output contract. Validation includes decoder unit cases, an SQLite hydration/save/dirty-check regression and declaration checks in the types harness. --- src/database/src/Eloquent/Casts/Json.php | 5 +++ .../src/Eloquent/Concerns/HasAttributes.php | 10 +++-- .../Database/DatabaseEloquentJsonCastTest.php | 16 +++++-- .../Database/EloquentModelJsonCastingTest.php | 44 +++++++++++++++++++ .../Eloquent/Casts/CastDeclarations.php | 33 ++++++++++++++ 5 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 types/Database/Eloquent/Casts/CastDeclarations.php diff --git a/src/database/src/Eloquent/Casts/Json.php b/src/database/src/Eloquent/Casts/Json.php index 28f5b4c93..70de1d0a7 100644 --- a/src/database/src/Eloquent/Casts/Json.php +++ b/src/database/src/Eloquent/Casts/Json.php @@ -44,6 +44,11 @@ public static function decode(mixed $value, ?bool $associative = true): mixed return (static::$decoder)($value, $associative); } + // SQLite returns a JSON number stored in a json or jsonb column as an int or float, which decodes to itself. + if (is_int($value) || is_float($value)) { + return $value; + } + // An empty string is Eloquent's established empty stored representation. return $value === '' ? null diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index f9cde313a..aa3536702 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -96,7 +96,9 @@ trait HasAttributes /** * The attributes that should be cast. * - * @var array + * A cast may be declared as [$cast, ...$arguments], which the model normalizes to a string when it is constructed. + * + * @var array|string> */ protected array $casts = []; @@ -1396,7 +1398,7 @@ protected function asJson(mixed $value, int $flags = 0): string|false /** * Decode the given JSON back into an array or object. */ - public function fromJson(?string $value, bool $asObject = false): mixed + public function fromJson(string|int|float|null $value, bool $asObject = false): mixed { if ($value === null || $value === '') { return null; @@ -1661,7 +1663,7 @@ public function getCasts(): array /** * Get the attributes that should be cast. * - * @return array|string|Stringable> + * @return array|string|Stringable> */ protected function casts(): array { @@ -2488,7 +2490,7 @@ public function originalIsEquivalent(string $key): bool /** * Compare JSON values using the structure exposed by their cast. */ - protected function jsonIsEquivalent(?string $attribute, ?string $original, bool $asObject): bool + protected function jsonIsEquivalent(string|int|float|null $attribute, string|int|float|null $original, bool $asObject): bool { $current = $this->fromJson($attribute, $asObject); diff --git a/tests/Database/DatabaseEloquentJsonCastTest.php b/tests/Database/DatabaseEloquentJsonCastTest.php index 9e54921ab..e82202617 100644 --- a/tests/Database/DatabaseEloquentJsonCastTest.php +++ b/tests/Database/DatabaseEloquentJsonCastTest.php @@ -51,19 +51,27 @@ public function testDefaultDecoderDistinguishesNullEmptyAndMalformedJson(): void $this->assertThrows(fn () => Json::decode('{invalid'), JsonException::class); } - public function testCustomDecoderReceivesTheStoredEmptyString(): void + public function testDefaultDecoderReturnsStoredNumbersUnchanged(): void { - $decoded = null; + $this->assertSame(5, Json::decode(5)); + $this->assertSame(1.5, Json::decode(1.5, false)); + $this->assertSame(0.30000000000000004, Json::decode(0.1 + 0.2)); + } + + public function testCustomDecoderReceivesTheStoredValueUnchanged(): void + { + $decoded = []; try { Json::decodeUsing(function (mixed $value) use (&$decoded): null { - $decoded = $value; + $decoded[] = $value; return null; }); $this->assertNull(Json::decode('')); - $this->assertSame('', $decoded); + $this->assertNull(Json::decode(5)); + $this->assertSame(['', 5], $decoded); } finally { Json::flushState(); } diff --git a/tests/Integration/Database/EloquentModelJsonCastingTest.php b/tests/Integration/Database/EloquentModelJsonCastingTest.php index 4acbde352..cdcb67331 100644 --- a/tests/Integration/Database/EloquentModelJsonCastingTest.php +++ b/tests/Integration/Database/EloquentModelJsonCastingTest.php @@ -10,7 +10,9 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Collection; +use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Tests\Integration\Database\DatabaseTestCase; use stdClass; @@ -76,6 +78,33 @@ public function testCollectionsAreCastable(): void $this->assertSame('value1', $user->collection_as_json_field->get('key1')); } + #[RequiresDatabase('sqlite')] + public function testNumbersReadFromNativeSqliteJsonColumnsAreCastable(): void + { + // Blueprint creates text columns unless use_native_json is set, and only json columns store numbers. + DB::statement('create table json_numbers (id integer primary key, array_field json, object_field json, array_object_field jsonb)'); + $id = JsonNumber::query()->insertGetId(['array_field' => '5', 'object_field' => '1.5', 'array_object_field' => '5']); + + $model = JsonNumber::query()->findOrFail($id); + + $this->assertSame(['id' => $id, 'array_field' => 5, 'object_field' => 1.5, 'array_object_field' => 5], $model->getRawOriginal()); + $this->assertSame(5, $model->array_field); + $this->assertSame(1.5, $model->object_field); + $this->assertNull($model->array_object_field); + + $model->array_field = 5; + $model->object_field = 1.5; + + $this->assertFalse($model->isDirty()); + + $model->array_field = 6; + $model->object_field = 2.5; + + $this->assertTrue($model->isDirty(['array_field', 'object_field'])); + $this->assertTrue($model->save()); + $this->assertSame(['id' => $id, 'array_field' => 6, 'object_field' => 2.5, 'array_object_field' => 5], JsonNumber::query()->findOrFail($id)->getRawOriginal()); + } + public function testValidAssignmentsCanReplaceMalformedStoredJson(): void { $casts = [ @@ -126,6 +155,21 @@ class JsonCast extends Model ]; } +class JsonNumber extends Model +{ + public ?string $table = 'json_numbers'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public array $casts = [ + 'array_field' => 'array', + 'object_field' => 'object', + 'array_object_field' => AsArrayObject::class, + ]; +} + enum JsonCastStatus: string { case Ready = 'ready'; diff --git a/types/Database/Eloquent/Casts/CastDeclarations.php b/types/Database/Eloquent/Casts/CastDeclarations.php new file mode 100644 index 000000000..3fceacdcd --- /dev/null +++ b/types/Database/Eloquent/Casts/CastDeclarations.php @@ -0,0 +1,33 @@ + [AsArrayObject::class], + 'price' => ['decimal', 2], + ]; + + /** + * Get the attributes that should be cast. + */ + protected function casts(): array + { + return [ + 'weight' => ['decimal', 3], + ]; + } +} + +assertType('array', (new Product)->getCasts()); From 7e864383b251a7dfb1346d0ac2b53c6284f60200 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:21:32 +0000 Subject: [PATCH 104/303] Declare Eloquent accessor variance and model serialization types Introduce covariant getter and contravariant setter templates for Attribute, with callback inference on its factories and PHPDoc readonly callback properties. Runtime callback assignment and Laravel factory implementations remain unchanged. Annotate framework accessors and Model::toArray with their public types. Document the intentional analysis restriction on replacing callbacks and cover ordinary getter/setter declarations and inference in the types harness. Validation: full source analysis and the Eloquent types checks passed. --- src/ai/src/Models/ConversationMessage.php | 6 +++ src/database/src/Eloquent/Casts/Attribute.php | 38 +++++++++++++++++- src/database/src/Eloquent/Model.php | 2 + src/passkeys/src/Passkey.php | 2 + src/passport/src/Client.php | 4 ++ types/Database/Eloquent/Casts/Attribute.php | 39 +++++++++++++++++++ 6 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 types/Database/Eloquent/Casts/Attribute.php diff --git a/src/ai/src/Models/ConversationMessage.php b/src/ai/src/Models/ConversationMessage.php index bb0ad4309..7ad1e6ed4 100644 --- a/src/ai/src/Models/ConversationMessage.php +++ b/src/ai/src/Models/ConversationMessage.php @@ -62,6 +62,8 @@ class ConversationMessage extends Model /** * The tool calls made across every step of the turn, in step order. + * + * @return Attribute */ protected function toolCalls(): Attribute { @@ -70,6 +72,8 @@ protected function toolCalls(): Attribute /** * The provider-hosted tool calls made across every step of the turn, in step order. + * + * @return Attribute */ protected function providerToolCalls(): Attribute { @@ -78,6 +82,8 @@ protected function providerToolCalls(): Attribute /** * The tool results recorded across every step of the turn, in step order. + * + * @return Attribute, never> */ protected function toolResults(): Attribute { diff --git a/src/database/src/Eloquent/Casts/Attribute.php b/src/database/src/Eloquent/Casts/Attribute.php index d5110093d..7a8956b54 100644 --- a/src/database/src/Eloquent/Casts/Attribute.php +++ b/src/database/src/Eloquent/Casts/Attribute.php @@ -4,19 +4,29 @@ namespace Hypervel\Database\Eloquent\Casts; +/** + * Unlike Laravel, the templates are variant, so an accessor can declare a wider type than its + * getter returns, such as Attribute for a getter returning a non-empty string. + * Variance requires the callbacks to be read-only to analysis; runtime assignment is unchanged. + * + * @template-covariant TGet + * @template-contravariant TSet + */ class Attribute { /** * The attribute accessor. * - * @var null|callable + * @var null|(callable(mixed, array): TGet) + * @readonly */ public mixed $get; /** * The attribute mutator. * - * @var null|callable + * @var null|(callable(TSet, array): mixed) + * @readonly */ public mixed $set; @@ -32,6 +42,11 @@ class Attribute /** * Create a new attribute accessor / mutator. + * + * @template TValue + * + * @param null|(callable(TValue, array): TGet) $get + * @param null|(callable(TSet, array): mixed) $set */ public function __construct(?callable $get = null, ?callable $set = null) { @@ -41,6 +56,14 @@ public function __construct(?callable $get = null, ?callable $set = null) /** * Create a new attribute accessor / mutator. + * + * @template TValue + * @template TMakeGet = never + * @template TMakeSet = never + * + * @param null|(callable(TValue, array): TMakeGet) $get + * @param null|(callable(TMakeSet, array): mixed) $set + * @return static */ public static function make(?callable $get = null, ?callable $set = null): static { @@ -49,6 +72,12 @@ public static function make(?callable $get = null, ?callable $set = null): stati /** * Create a new attribute accessor. + * + * @template TValue + * @template TMakeGet + * + * @param callable(TValue, array): TMakeGet $get + * @return static */ public static function get(callable $get): static { @@ -57,6 +86,11 @@ public static function get(callable $get): static /** * Create a new attribute mutator. + * + * @template TMakeSet + * + * @param callable(TMakeSet, array): mixed $set + * @return static */ public static function set(callable $set): static { diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index d5f0c43c4..8c7eaeb14 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -2253,6 +2253,8 @@ protected static function isLegacyScopeMethod(string $scope): bool /** * Convert the model instance to an array. + * + * @return array */ public function toArray(): array { diff --git a/src/passkeys/src/Passkey.php b/src/passkeys/src/Passkey.php index b9c7318e7..336586db7 100644 --- a/src/passkeys/src/Passkey.php +++ b/src/passkeys/src/Passkey.php @@ -74,6 +74,8 @@ public function user(): MorphTo /** * Get the authenticator name based on the AAGUID. + * + * @return Attribute */ protected function authenticator(): Attribute { diff --git a/src/passport/src/Client.php b/src/passport/src/Client.php index 4c23ed6b1..30062c404 100644 --- a/src/passport/src/Client.php +++ b/src/passport/src/Client.php @@ -152,6 +152,8 @@ public function tokens(): TokenRelation /** * Interact with the client's secret. + * + * @return Attribute */ protected function secret(): Attribute { @@ -167,6 +169,8 @@ protected function secret(): Attribute /** * Interact with the client's plain secret. + * + * @return Attribute */ protected function plainSecret(): Attribute { diff --git a/types/Database/Eloquent/Casts/Attribute.php b/types/Database/Eloquent/Casts/Attribute.php new file mode 100644 index 000000000..2f9e24378 --- /dev/null +++ b/types/Database/Eloquent/Casts/Attribute.php @@ -0,0 +1,39 @@ + + */ +function widerDeclaration(): Attribute +{ + return Attribute::make(fn () => new Carbon, fn (string|int|null $value) => $value); +} + +assertType('Hypervel\Database\Eloquent\Casts\Attribute', Attribute::make( + function ($value, $attributes) { + assertType('mixed', $value); + assertType('array', $attributes); + + return 5; + }, + fn (string $value) => strtolower($value), +)); +assertType('Hypervel\Database\Eloquent\Casts\Attribute', Attribute::make(get: fn (string $value) => ucfirst($value))); +assertType('Hypervel\Database\Eloquent\Casts\Attribute', Attribute::make(get: fn ($value) => $value)); +assertType('Hypervel\Database\Eloquent\Casts\Attribute', Attribute::make(set: fn (string $value) => $value)); +assertType('Hypervel\Database\Eloquent\Casts\Attribute', Attribute::get(fn (string $value) => ucfirst($value))); +assertType('Hypervel\Database\Eloquent\Casts\Attribute', Attribute::set(fn (string $value) => ['name' => $value])); +assertType('Hypervel\Database\Eloquent\Casts\Attribute', new Attribute(fn (string $value) => ucfirst($value), fn (string $value) => $value)); +assertType('Hypervel\Database\Eloquent\Casts\Attribute<5, mixed>', new Attribute(fn () => 5)); +assertType('Hypervel\Database\Eloquent\Casts\Attribute', Attribute::get(fn () => 5)->shouldCache()->withoutObjectCaching()); From 77e2336180f52a5882ad9eb31f92cab54394f777 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:21:33 +0000 Subject: [PATCH 105/303] Dump schemas from the configured migration connection Resolve schema:dump through the same migration-connection routing used by migrate, ensuring pooled connections dump the correct database and use the filename the migration loader expects. Document the routing behavior and cover both explicit and default connection selection. Validation: DatabaseSchemaDumpCommandTest passed as part of the reviewed checkpoint. --- src/database/src/Console/DumpCommand.php | 9 +++- src/docs/migrations.md | 2 +- .../DatabaseSchemaDumpCommandTest.php | 49 +++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/database/src/Console/DumpCommand.php b/src/database/src/Console/DumpCommand.php index b0dcb1a5c..6cff18416 100644 --- a/src/database/src/Console/DumpCommand.php +++ b/src/database/src/Console/DumpCommand.php @@ -11,6 +11,8 @@ use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Events\MigrationsPruned; use Hypervel\Database\Events\SchemaDumped; +use Hypervel\Database\Migrations\Migrator; +use Hypervel\Database\Schema\SchemaState; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\Config; use Symfony\Component\Console\Attribute\AsCommand; @@ -43,8 +45,11 @@ public function handle(ConnectionResolverInterface $connections, Dispatcher $dis return self::FAILURE; } + // Dump the connection that migrations run on, which `migrate` loads the dump into. /** @var Connection $connection */ - $connection = $connections->connection($database = $this->input->getOption('database')); + $connection = $connections->connection( + Migrator::resolveMigrationConnectionName($this->input->getOption('database')) + ); $this->schemaState($connection)->dump( $connection, @@ -78,7 +83,7 @@ public function handle(ConnectionResolverInterface $connections, Dispatcher $dis /** * Create a schema state instance for the given connection. */ - protected function schemaState(Connection $connection): mixed + protected function schemaState(Connection $connection): SchemaState { $migrationTable = Config::string('database.migrations.table'); diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 81e061720..998ed375c 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -66,7 +66,7 @@ To dump the schema without including rows from the migrations table, use the `-- php artisan schema:dump --without-migration-data ``` -When you execute this command, Hypervel will write a "schema" file to your application's `database/schema` directory. The schema file's name will correspond to the database connection. Now, when you attempt to migrate your database and no other migrations have been executed, Hypervel will first execute the SQL statements in the schema file of the database connection you are using. After executing the schema file's SQL statements, Hypervel will execute any remaining migrations that were not part of the schema dump. +When you execute this command, Hypervel will write a "schema" file to your application's `database/schema` directory. The schema file's name will correspond to the database connection. If the connection defines a [`migrations_connection`](#setting-the-migration-connection), the schema is dumped from that connection and named after it, since that is the connection the file is loaded into. Now, when you attempt to migrate your database and no other migrations have been executed, Hypervel will first execute the SQL statements in the schema file of the database connection you are using. After executing the schema file's SQL statements, Hypervel will execute any remaining migrations that were not part of the schema dump. If your application's tests use a different database connection than the one you typically use during local development, you should ensure you have dumped a schema file using that database connection so that your tests are able to build your database. You may wish to do this after dumping the database connection you typically use during local development: diff --git a/tests/Database/DatabaseSchemaDumpCommandTest.php b/tests/Database/DatabaseSchemaDumpCommandTest.php index b7da12f91..c7b28e008 100644 --- a/tests/Database/DatabaseSchemaDumpCommandTest.php +++ b/tests/Database/DatabaseSchemaDumpCommandTest.php @@ -4,8 +4,13 @@ namespace Hypervel\Tests\Database; +use Hypervel\Database\Connection; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Console\DumpCommand; +use Hypervel\Database\Schema\SchemaState; use Hypervel\Testbench\TestCase; +use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; class DatabaseSchemaDumpCommandTest extends TestCase { @@ -26,4 +31,48 @@ public function testDumpCommandExitsWhenProhibited(): void $this->assertFileDoesNotExist($path); $this->assertDirectoryExists(database_path('migrations')); } + + /** + * @param array $options + */ + #[DataProvider('connectionSelectionProvider')] + public function testDumpUsesAndIsNamedAfterTheMigrationConnection(array $options): void + { + $schemaPath = database_path('schema'); + + if (! is_dir($schemaPath)) { + $this->beforeApplicationDestroyed(fn () => $this->app->make('files')->deleteDirectory($schemaPath)); + } + + config([ + 'database.default' => 'pgsql-pooled', + 'database.connections.pgsql-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'pgsql'], + 'database.connections.pgsql' => ['driver' => 'pgsql'], + ]); + + $connection = m::mock(Connection::class); + $state = m::mock(SchemaState::class); + $connection->allows('getName')->andReturn('pgsql'); + $connection->allows('getSchemaState')->andReturn($state); + $state->allows('withMigrationTable')->andReturnSelf(); + $state->allows('handleOutputUsing')->andReturnSelf(); + $state->expects('dump')->with($connection, database_path('schema/pgsql-schema.sql')); + + $connections = m::mock(ConnectionResolverInterface::class); + $connections->expects('connection')->with('pgsql')->andReturn($connection); + $this->app->instance(ConnectionResolverInterface::class, $connections); + + $this->artisan('schema:dump', $options)->assertSuccessful(); + } + + /** + * Provide the default and explicit selection of a connection that routes its migrations elsewhere. + * + * @return iterable}> + */ + public static function connectionSelectionProvider(): iterable + { + yield 'default connection' => [[]]; + yield 'explicit connection' => [['--database' => 'pgsql-pooled']]; + } } From 9ab481a91a41a2428571020bdf1750ba8d4012bd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:21:33 +0000 Subject: [PATCH 106/303] Infer Eloquent properties from connection-aware schema and cast facts Port model property, accessor, relation and serialization analysis onto the first-party schema parser. Interpret supported migration flows without executing migrations, retain honest uncertainty for unsupported changes, and map database values through their connection's fetch options. Read casts statically, preserve explicit virtual attributes and custom collection casts, and report unusable schema dumps. Cache schema aggregation with content fingerprints and consumed configuration/source inputs. Track model metadata, migration guards and custom builder/query forwarding through PHPStan's declaration dependencies so warm runs notice relevant changes. Share configuration source discovery with existing config analysis and document the supported patterns and boundaries. Validation for the combined checkpoint: full PHPStan clean, Eloquent types clean, and 1464 analyzer tests passed. Shared boot-input provenance and final unchanged warm-run verification remain scheduled in the implementation plan. --- src/docs/static-analysis.md | 109 + src/phpstan/composer.json | 1 + src/phpstan/extension.neon | 7 + .../UnusableSchemaDumpCollector.php | 65 + .../src/Methods/BuilderDependencies.php | 84 + .../ForwardedBuilderMethodExtension.php | 7 + .../Methods/ForwardedModelMethodExtension.php | 6 + .../src/Properties/ConnectionConfig.php | 86 + .../Properties/ConnectionConfigResolver.php | 296 +++ .../src/Properties/DirectoryResolver.php | 54 + src/phpstan/src/Properties/MigrationCache.php | 282 +++ .../src/Properties/MigrationHelper.php | 171 ++ .../src/Properties/ModelAccessorExtension.php | 39 + .../src/Properties/ModelCastHelper.php | 545 ++++ src/phpstan/src/Properties/ModelHelper.php | 87 + src/phpstan/src/Properties/ModelProperty.php | 132 + .../src/Properties/ModelPropertyExtension.php | 50 + .../src/Properties/ModelPropertyHelper.php | 664 +++++ .../Properties/ModelRelationsExtension.php | 123 + .../Schema/DataTypeToPhpTypeConverter.php | 344 +++ .../src/Properties/SchemaAggregator.php | 2192 +++++++++++++++++ src/phpstan/src/Properties/SchemaColumn.php | 28 + .../src/Properties/SchemaConnection.php | 244 ++ src/phpstan/src/Properties/SchemaTable.php | 69 + .../Properties/SquashedMigrationHelper.php | 181 ++ src/phpstan/src/Reflection/SourceFiles.php | 163 ++ ...DynamicStaticMethodReturnTypeExtension.php | 169 ++ ...zationDynamicMethodReturnTypeExtension.php | 267 ++ .../src/Rules/UnusableSchemaDumpRule.php | 66 + src/phpstan/src/Support/ConfigParser.php | 118 +- .../src/Support/ConfigSourceResolver.php | 109 +- .../GenericModelPropertyType.php | 248 ++ ...ModelPropertyTypeNodeResolverExtension.php | 83 + 33 files changed, 7013 insertions(+), 76 deletions(-) create mode 100644 src/phpstan/src/Collectors/UnusableSchemaDumpCollector.php create mode 100644 src/phpstan/src/Methods/BuilderDependencies.php create mode 100644 src/phpstan/src/Properties/ConnectionConfig.php create mode 100644 src/phpstan/src/Properties/ConnectionConfigResolver.php create mode 100644 src/phpstan/src/Properties/DirectoryResolver.php create mode 100644 src/phpstan/src/Properties/MigrationCache.php create mode 100644 src/phpstan/src/Properties/MigrationHelper.php create mode 100644 src/phpstan/src/Properties/ModelAccessorExtension.php create mode 100644 src/phpstan/src/Properties/ModelCastHelper.php create mode 100644 src/phpstan/src/Properties/ModelHelper.php create mode 100644 src/phpstan/src/Properties/ModelProperty.php create mode 100644 src/phpstan/src/Properties/ModelPropertyExtension.php create mode 100644 src/phpstan/src/Properties/ModelPropertyHelper.php create mode 100644 src/phpstan/src/Properties/ModelRelationsExtension.php create mode 100644 src/phpstan/src/Properties/Schema/DataTypeToPhpTypeConverter.php create mode 100644 src/phpstan/src/Properties/SchemaAggregator.php create mode 100644 src/phpstan/src/Properties/SchemaColumn.php create mode 100644 src/phpstan/src/Properties/SchemaConnection.php create mode 100644 src/phpstan/src/Properties/SchemaTable.php create mode 100644 src/phpstan/src/Properties/SquashedMigrationHelper.php create mode 100644 src/phpstan/src/Reflection/SourceFiles.php create mode 100644 src/phpstan/src/ReturnTypes/CastDefinitionDynamicStaticMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/ModelSerializationDynamicMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/Rules/UnusableSchemaDumpRule.php create mode 100644 src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php create mode 100644 src/phpstan/src/Types/ModelProperty/ModelPropertyTypeNodeResolverExtension.php diff --git a/src/docs/static-analysis.md b/src/docs/static-analysis.md index 5bb793bc8..90a7d5d01 100644 --- a/src/docs/static-analysis.md +++ b/src/docs/static-analysis.md @@ -6,6 +6,10 @@ - [Runtime Requirements](#runtime-requirements) - [What the Analysis Knows](#what-the-analysis-knows) - [Collection Types](#collection-types) + - [Model Properties](#model-properties) + - [Migrations and Schema Dumps](#migrations-and-schema-dumps) + - [Accessor Types](#accessor-types) + - [Cast Types](#cast-types) - [Fixing and Ignoring Diagnostics](#fixing-and-ignoring-diagnostics) - [Environment Variables](#environment-variables) - [Configuration Values](#configuration-values) @@ -17,6 +21,7 @@ - [Console Input](#console-input) - [Authentication in Requests](#authentication-in-requests) - [Redis Connections](#redis-connections) + - [Schema Dumps](#schema-dumps) - [Deprecations](#deprecations) @@ -111,6 +116,97 @@ Declare the type the collection is meant to hold when you create it: $ids = collect([1, 2, 3]); ``` + +### Model Properties + +Model attributes are typed from the database schema your migrations and schema dumps describe, without connecting to a database. Reading an attribute the model's table does not have is reported: + +```php +public function greeting(User $user): string +{ + return "Hello, {$user->nmae}"; // Access to an undefined property App\Models\User::$nmae... +} +``` + +Each model reads the schema of its own connection. A pooled connection, such as `pgsql-pooled`, reads the schema of the connection its migrations run on. + +Attributes are typed as your database driver returns them. For example, a `decimal` column reads as a numeric string on MySQL, since decimals are fetched as strings to keep their precision. The connection's PDO options, such as `PDO::ATTR_STRINGIFY_FETCHES`, are taken into account. On SQLite, the types describe the values Hypervel writes, since SQLite only enforces column types on `STRICT` tables. [Casts](/docs/{{version}}/eloquent-mutators#attribute-casting), dates and accessors take precedence over the column's type, so cast an attribute when your code needs a specific type. + +When the analysis cannot know whether a table has a column, such as after a migration it cannot follow, the attribute reads as `mixed` and is not reported as missing. + + +### Migrations and Schema Dumps + +The analysis finds migrations as the `migrate` command does: the `*_*.php` files directly in the directories, and the files, your service providers register with `loadMigrationsFrom`, then in your `database/migrations` directory. A migration with the same name as an earlier one replaces it, so a published migration takes the place of its package's, and migrations apply in name order. It also reads the [schema dumps](/docs/{{version}}/migrations#squashing-migrations) in your `database/schema` directory. To read them from other directories, list the directories using the `databaseMigrationsPath` or `squashedMigrationsPath` parameter. Each replaces its defaults and accepts glob patterns, and every `.php` file below a listed migrations directory is read, including those in subdirectories: + +```neon +parameters: + databaseMigrationsPath: + - database/migrations + - modules/*/migrations + squashedMigrationsPath: + - database/schema +``` + +A schema dump is only read when it has the name the `schema:dump` Artisan command gives it: `{connection}-schema.sql`, or `{connection}-schema.dump`, where `{connection}` is a connection in your `database` configuration. When both exist, the `.dump` file is read, as the `migrate` command does. The schema of a connection with no dump and no migrations is unknown. + +Migrations are read as they are written, without running them. Each statement applies to the connection it runs on: the connection given to `Schema::connection()` or `DB::connection()`, otherwise the migration's connection. The analysis follows schema builder calls, `if` and `switch` statements, `Schema::hasTable()` and `Schema::hasColumn()` checks, and literal SQL passed to `DB::statement()` and `DB::unprepared()`. A column that only some branches create reads as `mixed`. Schema builders stored in local variables are followed too: + +```php +$schema = Schema::connection('analytics'); + +$schema->create('events', function (Blueprint $table) { + // ... +}); +``` + +Code the analysis cannot follow makes what it could change unknown. Blueprint macros, loops and helpers that receive the `$table` blueprint make the table unknown, and a column name held in a variable means the table may have columns the analysis does not know. A table name the analysis cannot work out makes its connection's schema unknown, and a connection name it cannot work out, such as one built at runtime, makes the schema of every connection unknown. + +Schema changes made through a connection the migration gets any other way, such as `$this->db` or `app('db')`, are not seen, so make them through the `Schema` and `DB` facades. Each `DB::statement()` call is also read on its own, so a session change that one call makes for later calls, such as `USE` or `SET search_path`, is not tracked. Instead, define a connection for the other database or schema in your `database` configuration and select it. + +Migrations and schema dumps are cached in PHPStan's temporary directory and read again when they, or the classes and configuration they read, change. You may disable the cache using the `enableMigrationCache` parameter, or stop reading migrations or schema dumps using the `disableMigrationScan` or `disableSchemaScan` parameter. + + +### Accessor Types + +An accessor's `Attribute` declares the type its getter returns and the type its setter accepts. The declared getter type may be wider than what the getter returns, and the declared setter type narrower than what the setter accepts: + +```php +/** + * @return Attribute + */ +protected function fullName(): Attribute +{ + return Attribute::make( + get: fn () => "{$this->first_name} {$this->last_name}", + ); +} +``` + +To keep these declarations sound, the analysis treats an attribute's `get` and `set` callbacks as read-only once the attribute is created, so assigning `$attribute->get` or `$attribute->set` afterwards is reported as `property.readOnlyByPhpDocAssignOutOfClass`. Reading and writing the model attribute itself is not affected. If your code must replace a callback, ignore the diagnostic with a reason. + + +### Cast Types + +The analysis reads a model's `casts` method without running it. It understands a single `return` of an array whose casts are strings, class constants, the `[$cast, ...$arguments]` array form, and helpers such as `AsCollection::of` and `AsEnumCollection::of` given constant arguments. + +An attribute whose cast the analysis cannot work out, such as one built from a variable, reads and writes as `mixed`. When the method does not return such an array, for example because it merges the parent's casts or returns from several places, any attribute of the model may be cast, so all of them read as `mixed`. Declare the casts the method returns with an array shape to type them: + +```php +/** + * @return array{options: 'array', status: 'App\\Enums\\Status'} + */ +protected function casts(): array +{ + return array_merge(parent::casts(), [ + 'options' => 'array', + 'status' => Status::class, + ]); +} +``` + +The shape must list every cast the method can return, including inherited ones, since an attribute it leaves out is treated as not cast by the method. If you cannot list them all, end the shape with `...`, and the attributes it does not list keep reading as `mixed`. + ## Fixing and Ignoring Diagnostics @@ -136,6 +232,8 @@ The following table summarizes the preferred pattern for each of Hypervel's diag | [Views](#views) | Use view names the application can find. | `hypervel.unusedViews`, `argument.type` for view names | | [Console Input](#console-input) | Read the arguments and options the command defines. | `hypervel.console.undefinedArgument`, `hypervel.console.undefinedOption` | | [Authentication in Requests](#authentication-in-requests) | Use `$request->user()` where a request is available. | `hypervel.noAuthFacadeInRequestScope`, `hypervel.noAuthHelperInRequestScope` | +| [Redis Connections](#redis-connections) | Call connection-bound methods on a held connection. | `hypervel.redisConnectionBoundMethod` | +| [Schema Dumps](#schema-dumps) | Keep schema dumps in the plain SQL `schema:dump` writes. | `hypervel.schemaDump` | | [Deprecations](#deprecations) | Use the replacement the deprecation names. | `method.deprecated` and the other deprecation identifiers | @@ -284,6 +382,17 @@ Redis::withConnection(function (RedisConnection $connection) { These checks may be disabled using the `checkRedisConnectionBoundMethods` parameter. + +### Schema Dumps + +When a schema dump your models use cannot be read, such as a PostgreSQL dump in `pg_dump`'s custom archive format or a dump with a syntax error, the analysis reports it once, at the dump: + +```text +database/schema/pgsql-schema.dump:1: Model property types cannot use this schema dump: PostgreSQL archives must be exported as plain SQL before parsing. +``` + +Until the dump is fixed, the tables it defines are unknown, so their attributes read as `mixed`. Regenerate the dump using the `schema:dump` Artisan command, which writes plain SQL. A dump is only reported when the analysed code reads a model attribute on its connection. You may stop reading schema dumps using the `disableSchemaScan` parameter. + ### Deprecations diff --git a/src/phpstan/composer.json b/src/phpstan/composer.json index 3bd9a653c..6215557fc 100644 --- a/src/phpstan/composer.json +++ b/src/phpstan/composer.json @@ -41,6 +41,7 @@ "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", "hypervel/routing": "^0.4", + "hypervel/schema-parser": "^0.4", "hypervel/support": "^0.4", "hypervel/translation": "^0.4", "hypervel/view": "^0.4", diff --git a/src/phpstan/extension.neon b/src/phpstan/extension.neon index feffaddce..92cee466b 100644 --- a/src/phpstan/extension.neon +++ b/src/phpstan/extension.neon @@ -163,3 +163,10 @@ services: - class: Hypervel\Filesystem\Filesystem autowired: self + + migrationsParser: + class: PHPStan\Parser\CachedParser + arguments: + originalParser: @currentPhpVersionSimpleDirectParser + cachedNodesByStringCountMax: %cache.nodesByStringCountMax% + autowired: false diff --git a/src/phpstan/src/Collectors/UnusableSchemaDumpCollector.php b/src/phpstan/src/Collectors/UnusableSchemaDumpCollector.php new file mode 100644 index 000000000..3cb2f961f --- /dev/null +++ b/src/phpstan/src/Collectors/UnusableSchemaDumpCollector.php @@ -0,0 +1,65 @@ +> + */ +#[AutowiredService] +class UnusableSchemaDumpCollector implements Collector +{ + /** + * Create a new collector instance. + */ + public function __construct(private ModelPropertyHelper $modelPropertyHelper) + { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return PropertyFetch::class; + } + + /** + * Collect the unusable schema dumps of the models whose attributes the fetch looks up. + * + * @param PropertyFetch $node + * + * @return null|list + */ + public function processNode(Node $node, Scope $scope): ?array + { + if (! $node->name instanceof Identifier) { + return null; + } + + $dumps = []; + + foreach ($scope->getType($node->var)->getObjectClassReflections() as $classReflection) { + // A declared property is read directly, without consulting the schema. + if (! $classReflection->hasNativeProperty($node->name->name)) { + $dumps = [...$dumps, ...$this->modelPropertyHelper->getUnusableDumps($classReflection)]; + } + } + + return $dumps === [] ? null : $dumps; + } +} diff --git a/src/phpstan/src/Methods/BuilderDependencies.php b/src/phpstan/src/Methods/BuilderDependencies.php new file mode 100644 index 000000000..e3524fbc0 --- /dev/null +++ b/src/phpstan/src/Methods/BuilderDependencies.php @@ -0,0 +1,84 @@ +, ?string}> */ + private array $inputs = []; + + /** + * Create a builder dependency tracker. + */ + public function __construct( + private readonly DeclarationDependencyTracker $declarationDependencyTracker, + ) { + } + + /** + * Declare that the owner's forwarded methods depend on the builder's custom declarations and passthru methods. + */ + public function track(ClassReflection $owner, ClassReflection $builder): void + { + // Keyed by instantiation: the query class can come from the builder's active template types. + [$classNames, $passthruFile] = $this->inputs[$builder->getCacheKey()] ??= $this->resolveInputs($builder); + + foreach ($classNames as $className) { + if ($className !== $owner->getName()) { + $this->declarationDependencyTracker->trackClassDependency($owner, $className); + } + } + + if ($passthruFile !== null) { + $this->declarationDependencyTracker->trackFileDependency($owner, $passthruFile); + } + } + + /** + * Resolve the custom builder and query classes, and the file declaring a custom passthru list. + * + * @return array{list, ?string} + */ + private function resolveInputs(ClassReflection $builder): array + { + $classNames = []; + + if ($builder->getName() !== EloquentBuilder::class) { + $classNames[] = $builder->getName(); + } + + $queryType = $builder->getNativeMethod('getQuery')->getVariants()[0]->getReturnType(); + + foreach ($queryType->getObjectClassNames() as $queryClassName) { + if ($queryClassName !== QueryBuilder::class) { + $classNames[] = $queryClassName; + } + } + + // A property default is not part of a class's declarations. + $property = $builder->getNativeProperty('passthru'); + $source = $property->getDeclaringTrait() ?? $property->getDeclaringClass(); + $passthruFile = $source->getName() === EloquentBuilder::class ? null : $source->getFileName(); + + return [$classNames, $passthruFile]; + } +} diff --git a/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php b/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php index 9571d9dc1..e36de4c2d 100644 --- a/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php +++ b/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php @@ -52,6 +52,7 @@ public function __construct( private readonly ReflectionProvider $reflectionProvider, private readonly ModelScopeMethodResolver $scopeMethods, private readonly BuilderMacroResolver $builderMacros, + private readonly BuilderDependencies $builderDependencies, ) { $this->scope = new OutOfClassScope; } @@ -103,6 +104,7 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN if (! $this->hasNativeOrDocumentedMethod($classReflection, $methodName)) { if ($isEloquentHost) { + $this->builderDependencies->track($classReflection, $classReflection); $method = $this->resolveEloquentBuilderMethod($classReflection, $methodName); } else { $method = $this->resolveRelationMethod($classReflection, $methodName); @@ -171,6 +173,11 @@ private function resolveRelationMethod(ClassReflection $classReflection, string $eloquentBuilder = $builderClasses[0]; + // Relation callers depend on the related model, not on its builder. + foreach ($relatedType->getObjectClassReflections() as $relatedClass) { + $this->builderDependencies->track($relatedClass, $eloquentBuilder); + } + if ($eloquentBuilder->hasNativeMethod($methodName)) { $method = $builderType->getMethod($methodName, $this->scope); diff --git a/src/phpstan/src/Methods/ForwardedModelMethodExtension.php b/src/phpstan/src/Methods/ForwardedModelMethodExtension.php index d3601c37d..cb8b7f5f5 100644 --- a/src/phpstan/src/Methods/ForwardedModelMethodExtension.php +++ b/src/phpstan/src/Methods/ForwardedModelMethodExtension.php @@ -33,6 +33,7 @@ public function __construct( private readonly ReflectionProvider $reflectionProvider, private readonly ModelScopeMethodResolver $scopeMethods, private readonly BuilderMacroResolver $builderMacros, + private readonly BuilderDependencies $builderDependencies, ) { $this->scope = new OutOfClassScope; } @@ -80,6 +81,11 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN if (! $this->hasNativeOrDocumentedMethod($classReflection, $methodName)) { $builderType = $this->activeBuilderType($classReflection); + + foreach ($builderType->getObjectClassReflections() as $builderClass) { + $this->builderDependencies->track($classReflection, $builderClass); + } + $scopeMethod = $this->scopeMethods->resolve($classReflection, $methodName); // Native builder methods and local macros run before named scopes. diff --git a/src/phpstan/src/Properties/ConnectionConfig.php b/src/phpstan/src/Properties/ConnectionConfig.php new file mode 100644 index 000000000..4e0b527bf --- /dev/null +++ b/src/phpstan/src/Properties/ConnectionConfig.php @@ -0,0 +1,86 @@ + $searchPath the schemas an unqualified PostgreSQL name resolves through, in order + * @param bool $nativeJson whether SQLite's schema grammar creates Blueprint json columns as json rather than text + * @param bool $nativeJsonb whether SQLite's schema grammar creates Blueprint jsonb columns as jsonb rather than text + * @param list> $fetchOptions each distinct set of the PDO options that change fetched values + * @param bool $customized whether a DB::extend() resolver or a custom connector creates the connection, so fetched values are unknown + */ + public function __construct( + public string $owner, + public string $driver, + public string $prefix, + public ?string $defaultSchema, + public array $searchPath, + public bool $nativeJson, + public bool $nativeJsonb, + public array $fetchOptions, + public bool $customized, + ) { + } + + /** + * Create the schema parser for the connection's driver, or return null when the driver has none. + */ + public function createParser(): ?Parser + { + return match ($this->driver) { + 'mysql' => new MySqlParser, + 'mariadb' => new MariaDbParser, + 'pgsql' => new PostgresParser, + 'sqlite' => new SQLiteParser, + default => null, + }; + } + + /** + * Get the schema an unqualified definition belongs to: the MySQL database or SQLite's main database. + * + * PostgreSQL definitions stay unqualified with the connection's search path. + */ + public function definitionSchema(): ?string + { + return $this->driver === 'pgsql' ? null : $this->defaultSchema; + } + + /** + * Get the schema and physical name of a logical table name. + * + * The prefix applies to the final segment, as Grammar::wrapTable() applies it. + * + * @return array{?string, string} + */ + public function physicalTable(string $table): array + { + $separator = strrpos($table, '.'); + + return $separator === false + ? [null, $this->prefix . $table] + : [substr($table, 0, $separator), $this->prefix . substr($table, $separator + 1)]; + } +} diff --git a/src/phpstan/src/Properties/ConnectionConfigResolver.php b/src/phpstan/src/Properties/ConnectionConfigResolver.php new file mode 100644 index 000000000..10e02d6f8 --- /dev/null +++ b/src/phpstan/src/Properties/ConnectionConfigResolver.php @@ -0,0 +1,296 @@ + true, + PDO::ATTR_ORACLE_NULLS => true, + PDO::ATTR_STRINGIFY_FETCHES => true, + ]; + + /** @var array */ + private array $connections = []; + + /** @var array the config keys read, whose sources the schema facts depend on */ + private array $consumedKeys = []; + + /** @var array each config key read as a connection name, with its state */ + private array $configuredNames = []; + + /** + * Get the facts of a connection name, or null when its configuration cannot be resolved. + */ + public function connection(string $name): ?ConnectionConfig + { + if (array_key_exists($name, $this->connections)) { + return $this->connections[$name]; + } + + return $this->connections[$name] = $this->resolveConnection($name); + } + + /** + * Get the name of the default connection. + */ + public function getDefaultConnectionName(): ?string + { + $name = $this->config('database.default'); + + return is_string($name) ? $name : null; + } + + /** + * Get the names of the configured connections. + * + * @return list + */ + public function getConnectionNames(): array + { + $connections = $this->config('database.connections'); + + return is_array($connections) ? array_map('strval', array_keys($connections)) : []; + } + + /** + * Get whether a config key holding a connection name is present, and its value when it + * is a string or null, or false for any other value. + * + * @return array{bool, null|false|string} + */ + public function getConfiguredName(string $key): array + { + $value = $this->config($key); + + return $this->configuredNames[$key] = [ + $this->getContainer()->make('config')->has($key), + is_string($value) || $value === null ? $value : false, + ]; + } + + /** + * Get each config key read as a connection name, with its state. + * + * @return array + */ + public function getConfiguredNames(): array + { + return $this->configuredNames; + } + + /** + * Get the config keys read so far. + * + * @return list + */ + public function getConsumedKeys(): array + { + return array_keys($this->consumedKeys); + } + + /** + * Get the normalized configuration that schema aggregation reads. + * + * Fetch options and custom resolvers only affect lookups, so changing them keeps the aggregated schema. + * + * @return array + */ + public function getSchemaConfiguration(): array + { + $names = $this->getConnectionNames(); + + return [ + 'default' => $this->getDefaultConnectionName(), + 'connections' => array_combine($names, array_map(function (string $name): ?array { + $connection = $this->connection($name); + + return $connection === null ? null : [ + $connection->owner, + $connection->driver, + $connection->prefix, + $connection->defaultSchema, + $connection->searchPath, + $connection->nativeJson, + $connection->nativeJsonb, + ]; + }, $names)), + 'morphKeyType' => Builder::$defaultMorphKeyType, + ]; + } + + /** + * Resolve the facts of a connection name. + */ + private function resolveConnection(string $name): ?ConnectionConfig + { + try { + $connectionName = ConnectionName::parse($name); + $owner = Migrator::resolveMigrationConnectionName($connectionName->base); + } catch (InvalidArgumentException) { + return null; + } + + $config = $this->config("database.connections.{$connectionName->base}"); + + if (! is_array($config)) { + return null; + } + + /** @var ConnectionFactory $factory */ + $factory = $this->getContainer()->make('db.factory'); + $config = $factory->parseConfig($config, $connectionName->base); + $records = $this->getRecords($factory, $config, $connectionName); + $identities = array_unique(array_map(static fn (array $record): string => serialize([ + $record['driver'] ?? null, + $record['prefix'] ?? null, + $record['database'] ?? null, + $record['search_path'] ?? $record['schema'] ?? null, + $record['username'] ?? null, + (bool) ($record['use_native_json'] ?? false), + (bool) ($record['use_native_jsonb'] ?? false), + ]), $records)); + + // Lookups cannot tell which of several differing endpoints a query reaches. + if (count($identities) !== 1 || ! is_string($driver = $records[0]['driver'] ?? null)) { + return null; + } + + $record = $records[0]; + $customized = $factory->getExtension($config, $connectionName->base) !== null; + $fetchOptions = []; + + try { + $connector = $factory->createConnector($record); + } catch (InvalidArgumentException) { + $connector = null; + } + + if ($connector instanceof Connector) { + foreach ($records as $endpoint) { + $options = array_intersect_key($connector->getOptions($endpoint), self::FETCH_OPTIONS); + + if (! in_array($options, $fetchOptions, true)) { + $fetchOptions[] = $options; + } + } + } else { + $customized = true; + } + + return new ConnectionConfig( + owner: $owner, + driver: $driver, + prefix: (string) $record['prefix'], + defaultSchema: match ($driver) { + 'mysql', 'mariadb' => is_string($record['database'] ?? null) ? $record['database'] : null, + 'sqlite' => 'main', + default => null, + }, + searchPath: $driver === 'pgsql' ? $this->getSearchPath($record) : [], + nativeJson: (bool) ($record['use_native_json'] ?? false), + nativeJsonb: (bool) ($record['use_native_jsonb'] ?? false), + fetchOptions: $fetchOptions, + customized: $customized, + ); + } + + /** + * Get every endpoint configuration a connection name can select. + * + * A base name reads from either side, so it can reach every read and write record. + * + * @param array $config + * + * @return non-empty-list> + */ + private function getRecords(ConnectionFactory $factory, array $config, ConnectionName $name): array + { + if (! $factory->hasReadConfig($config)) { + return [$config]; + } + + $roles = match (true) { + $name->isRead() => ['read'], + $name->isWrite() => ['write'], + default => ['read', 'write'], + }; + + $records = []; + + // Mirrors DatabasePool::roleEndpoints(), which builds each record a role may select. + foreach ($roles as $role) { + foreach (isset($config[$role][0]) ? $config[$role] : [$config[$role]] as $record) { + $endpoint = array_replace($config, [$role => $record]); + $records[] = $role === 'read' ? $factory->configForRead($endpoint) : $factory->configForWrite($endpoint); + } + } + + return $records; + } + + /** + * Get the schemas an unqualified name resolves through, as PostgresBuilder::getCurrentSchemaListing() reads them. + * + * @param array $record + * + * @return list + */ + private function getSearchPath(array $record): array + { + return array_values(array_map( + static fn (string $schema): string => $schema === '$user' ? (string) ($record['username'] ?? '') : $schema, + $this->parseSearchPath($record['search_path'] ?? $record['schema'] ?? 'public'), + )); + } + + /** + * Read a config key from the booted application, recording it as consumed. + */ + private function config(string $key): mixed + { + $this->consumedKeys[$key] = true; + + return $this->getContainer()->make('config')->get($key); + } +} diff --git a/src/phpstan/src/Properties/DirectoryResolver.php b/src/phpstan/src/Properties/DirectoryResolver.php new file mode 100644 index 000000000..4295b37e9 --- /dev/null +++ b/src/phpstan/src/Properties/DirectoryResolver.php @@ -0,0 +1,54 @@ + + */ + public static function resolve(array $paths, FileHelper $fileHelper): array + { + $directories = []; + + foreach ($paths as $path) { + $path = $fileHelper->absolutizePath($path); + + $matches = is_dir($path) || strpbrk($path, '*?[') === false + ? [$path] + : (glob($path, GLOB_ONLYDIR) ?: []); + + foreach ($matches as $directory) { + $directory = rtrim($fileHelper->normalizePath($directory), '/\\'); + $directories[$directory] = $directory; + } + } + + return array_values($directories); + } +} diff --git a/src/phpstan/src/Properties/MigrationCache.php b/src/phpstan/src/Properties/MigrationCache.php new file mode 100644 index 000000000..3ed687dc0 --- /dev/null +++ b/src/phpstan/src/Properties/MigrationCache.php @@ -0,0 +1,282 @@ + $configuration the normalized configuration the aggregation reads + * @param callable(): array{connections: array, sources: array} $callback + * + * @return array{connections: array, sources: array} + */ + public function remember(array $migrationFiles, array $schemaFiles, array $configuration, callable $callback): array + { + if (! $this->enabled) { + return $callback(); + } + + $fingerprint = $this->generateFingerprint($migrationFiles, $schemaFiles, $configuration); + $cachePath = $this->getCachePath($fingerprint); + + $cached = file_exists($cachePath) ? $this->readFromCache($cachePath) : null; + + if ($cached !== null) { + return $cached; + } + + $lockHandle = $this->acquireExclusiveLock(); + + if ($lockHandle === false) { + return $callback(); + } + + try { + $cached = file_exists($cachePath) ? $this->readFromCache($cachePath) : null; + + if ($cached !== null) { + return $cached; + } + + $schema = $callback(); + $sources = $schema['sources']; + + foreach ($schema['connections'] as $connection) { + foreach ($connection->tables as $table) { + $sources += $table->sources; + } + } + + $sourceHashes = []; + + foreach (array_keys($sources) as $source) { + $sourceHashes[$source] = self::hashSource($source); + } + + $tempPath = sprintf('%s.tmp.%d', $cachePath, getmypid()); + + file_put_contents($tempPath, serialize([ + 'configuredNames' => $this->connectionConfigResolver->getConfiguredNames(), + 'sourceHashes' => $sourceHashes, + ...$schema, + ])); + rename($tempPath, $cachePath); + + $this->cleanupOldCacheFiles($fingerprint); + + return $schema; + } finally { + $this->releaseLock($lockHandle); + } + } + + /** + * Read a cached schema, or return null when it is missing or the config keys or PHP files it read have changed. + * + * @return null|array{connections: array, sources: array} + */ + private function readFromCache(string $path): ?array + { + $content = file_get_contents($path); + + if ($content === false) { + return null; + } + + /** @var array{configuredNames: array, sourceHashes: array, connections: array, sources: array}|false $data */ + $data = @unserialize($content); + + if (! is_array($data)) { + return null; + } + + foreach ($data['configuredNames'] as $key => $state) { + if ($this->connectionConfigResolver->getConfiguredName($key) !== $state) { + return null; + } + } + + foreach ($data['sourceHashes'] as $source => $hash) { + if (self::hashSource($source) !== $hash) { + return null; + } + } + + return ['connections' => $data['connections'], 'sources' => $data['sources']]; + } + + /** + * Get the content hash of a PHP file the aggregation read, or false when it no longer exists. + */ + private static function hashSource(string $path): false|string + { + return is_file($path) ? hash_file('xxh128', $path) : false; + } + + /** + * Delete the cached schemas of earlier inputs. + */ + private function cleanupOldCacheFiles(string $currentFingerprint): void + { + $currentCacheFile = sprintf('%s%s.cache', self::CACHE_PREFIX, $currentFingerprint); + $files = glob(sprintf('%s/%s*.cache', $this->cacheDirectory, self::CACHE_PREFIX)) ?: []; + + foreach (array_filter($files, static fn (string $file): bool => ! str_ends_with($file, $currentCacheFile)) as $file) { + @unlink($file); + } + } + + /** + * Generate the fingerprint of everything the aggregated schema depends on. + * + * @param SplFileInfo[] $migrationFiles + * @param SplFileInfo[] $schemaFiles + * @param array $configuration + */ + private function generateFingerprint(array $migrationFiles, array $schemaFiles, array $configuration): string + { + // The modification time has whole-second resolution, so two edits within one second + // would reuse a stale schema without the content digest. + $metadata = array_merge( + array_map(static fn (SplFileInfo $file): string => sprintf('M:%s:%d:%s', $file->getPathname(), $file->getMTime(), hash_file('xxh128', $file->getPathname())), $migrationFiles), + array_map(static fn (SplFileInfo $file): string => sprintf('S:%s:%d:%s', $file->getPathname(), $file->getMTime(), hash_file('xxh128', $file->getPathname())), $schemaFiles), + ); + + sort($metadata); + + return hash('xxh128', implode('|', [ + self::CACHE_FORMAT_VERSION, + PHP_VERSION, + self::packageVersion('hypervel/phpstan'), + self::packageVersion('hypervel/schema-parser'), + serialize($configuration), + ...$metadata, + ])); + } + + /** + * Get the installed version and reference of a package. + */ + private static function packageVersion(string $package): string + { + try { + return (InstalledVersions::getVersion($package) ?? 'unknown') + . '@' . (InstalledVersions::getReference($package) ?? 'unknown'); + } catch (OutOfBoundsException) { + return 'unknown'; + } + } + + /** + * Get the cache file of a fingerprint. + */ + private function getCachePath(string $fingerprint): string + { + return sprintf('%s/%s%s.cache', $this->cacheDirectory, self::CACHE_PREFIX, $fingerprint); + } + + /** + * Get the file that serializes cache writes. + */ + private function getLockPath(): string + { + return sprintf('%s/hypervel_migrations.lock', $this->cacheDirectory); + } + + /** + * Acquire the exclusive cache write lock. + * + * @return false|resource + */ + private function acquireExclusiveLock(): mixed + { + $handle = @fopen($this->getLockPath(), 'c'); + + if ($handle === false) { + return false; + } + + if (! flock($handle, LOCK_EX)) { + fclose($handle); + + return false; + } + + return $handle; + } + + /** + * Release the cache write lock. + * + * @param resource $handle + */ + private function releaseLock(mixed $handle): void + { + flock($handle, LOCK_UN); + fclose($handle); + } +} diff --git a/src/phpstan/src/Properties/MigrationHelper.php b/src/phpstan/src/Properties/MigrationHelper.php new file mode 100644 index 000000000..14edee829 --- /dev/null +++ b/src/phpstan/src/Properties/MigrationHelper.php @@ -0,0 +1,171 @@ + */ + private ?array $migrationPaths = null; + + /** @var null|list */ + private ?array $migrationFiles = null; + + /** + * Create a new migration helper instance. + */ + public function __construct( + #[AutowiredParameter(ref: '@migrationsParser')] + private Parser $parser, + /** @var string[] */ + #[AutowiredParameter(ref: '%databaseMigrationsPath%')] + private array $databaseMigrationPath, + private FileHelper $fileHelper, + #[AutowiredParameter] + private bool $disableMigrationScan, + #[AutowiredParameter(ref: '@reflectionProvider')] + private ReflectionProvider $reflectionProvider, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ConnectionConfigResolver $connectionConfigResolver, + ) { + } + + /** + * Apply the migrations to the schemas of the connections they run on. + * + * @param array $connections the schemas loaded from dumps, by owning connection + * + * @return array + */ + public function initializeConnections(array $connections = []): array + { + return $this->aggregate($connections)->connections; + } + + /** + * Apply the migrations to the schemas of the connections they run on, keeping the PHP files they read. + * + * @param array $connections the schemas loaded from dumps, by owning connection + */ + public function aggregate(array $connections = []): SchemaAggregator + { + $schemaAggregator = new SchemaAggregator( + $this->reflectionProvider, + $this->initializerExprTypeResolver, + $this->connectionConfigResolver, + $connections, + ); + + foreach ($this->getMigrationFiles() as $file) { + try { + $schemaAggregator->addStatements($this->parser->parseFile($file->getPathname())); + } catch (ParserErrorsException) { + // Unlike upstream, which skipped the file, its changes are unknown. + $schemaAggregator->invalidate(); + } + } + + return $schemaAggregator; + } + + /** + * Get the migration files, in the order they run. + * + * @return list + */ + public function getMigrationFiles(): array + { + if ($this->migrationFiles !== null) { + return $this->migrationFiles; + } + + $paths = $this->getMigrationPaths(); + + if ($this->databaseMigrationPath === [] && $paths !== []) { + // Unlike upstream, default discovery is migrate's: a later path's migration replaces an + // earlier one of the same name, so a published copy replaces its package's original. + return $this->migrationFiles = array_map( + static fn (string $file): SplFileInfo => new SplFileInfo($file), + array_values($this->getContainer()->make('migrator')->getMigrationFiles(array_keys($paths))), + ); + } + + $migrationFiles = []; + + foreach (array_keys($paths) as $directory) { + if (is_dir($directory)) { + $migrationFiles += iterator_to_array( + new RegexIterator( + new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)), + '/\.php$/i', + ), + ); + } + } + + uasort($migrationFiles, static fn (SplFileInfo $a, SplFileInfo $b): int => $a->getFilename() <=> $b->getFilename()); + + return $this->migrationFiles = array_values($migrationFiles); + } + + /** + * Get the paths migrations are discovered in, each mapped to the pattern its migration file names + * match, or to null for a migration file. + * + * By default these are the paths migrate reads: those providers register through loadMigrationsFrom(), + * then the application's migration directory, each holding its *_*.php files. Configured paths + * replace them and are directories scanned recursively for .php files, as upstream does; see + * DirectoryResolver::resolve(). + * + * @return array + */ + public function getMigrationPaths(): array + { + if ($this->disableMigrationScan) { + return []; + } + + if ($this->migrationPaths !== null) { + return $this->migrationPaths; + } + + if ($this->databaseMigrationPath !== []) { + return $this->migrationPaths = array_fill_keys(DirectoryResolver::resolve($this->databaseMigrationPath, $this->fileHelper), '*.[pP][hH][pP]'); + } + + $paths = []; + + foreach ([...$this->getContainer()->make('migrator')->paths(), database_path('migrations')] as $path) { + $paths[$path] = str_ends_with($path, '.php') ? null : '*_*.php'; + } + + return $this->migrationPaths = $paths; + } +} diff --git a/src/phpstan/src/Properties/ModelAccessorExtension.php b/src/phpstan/src/Properties/ModelAccessorExtension.php new file mode 100644 index 000000000..70f095cf2 --- /dev/null +++ b/src/phpstan/src/Properties/ModelAccessorExtension.php @@ -0,0 +1,39 @@ +modelPropertyHelper->hasAccessor($classReflection, $propertyName); + } + + /** + * Get the accessor property. + */ + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return $this->modelPropertyHelper->getAccessor($classReflection, $propertyName); + } +} diff --git a/src/phpstan/src/Properties/ModelCastHelper.php b/src/phpstan/src/Properties/ModelCastHelper.php new file mode 100644 index 000000000..5477a87b3 --- /dev/null +++ b/src/phpstan/src/Properties/ModelCastHelper.php @@ -0,0 +1,545 @@ +, complete: bool}> */ + private array $modelCasts = []; + + /** + * Create a new model cast helper instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + // Unlike upstream, the cached parser analysis uses, which keeps method bodies for every file. + #[AutowiredParameter(ref: '@cachedRichParser')] + private Parser $parser, + #[AutowiredParameter] + private bool $parseModelCastsMethod, + private ScopeFactory $scopeFactory, + private DeclarationDependencyTracker $declarationDependencyTracker, + private ModelHelper $modelHelper, + ) { + } + + /** + * Get the type a cast attribute reads as. + */ + public function getReadableType(string $cast, Type $originalType): Type + { + $arguments = $this->parseCastArguments($cast); + $cast = $this->parseCast($cast); + + $attributeType = match ($cast) { + 'int', 'integer', 'timestamp' => $originalType->isInteger()->yes() ? $originalType : new IntegerType, + 'real', 'float', 'double' => new FloatType, + 'decimal' => TypeCombinator::intersect(new StringType, new AccessoryNumericStringType), + 'string' => new StringType, + 'bool', 'boolean' => new BooleanType, + 'object' => new ObjectType(stdClass::class), + 'array', 'json' => new ArrayType(new BenevolentUnionType([new IntegerType, new StringType]), new MixedType), + 'collection' => new ObjectType(Collection::class), + 'date', 'datetime' => $this->getDateType(), + 'immutable_date', 'immutable_datetime' => new ObjectType(CarbonImmutable::class), + AsArrayObject::class, AsEncryptedArrayObject::class => new ObjectType(ArrayObject::class), + AsCollection::class, AsEncryptedCollection::class => new BenevolentUnionType([ + $this->getCollectionCastType($arguments), + new NullType, + ]), + AsStringable::class => new ObjectType(HypervelStringable::class), + default => null, + }; + + if ($attributeType) { + return $attributeType; + } + + if (! $this->reflectionProvider->hasClass($cast)) { + return $originalType; + } + + $classReflection = $this->reflectionProvider->getClass($cast); + + if ($classReflection->isEnum()) { + return new ObjectType($cast); + } + + if ($classReflection->is(Castable::class)) { + $methodReflection = $classReflection->getNativeMethod('castUsing'); + $castUsingReturn = ParametersAcceptorSelector::selectFromTypes( + [ConstantTypeHelper::getTypeFromValue($arguments)], + $methodReflection->getVariants(), + false, + )->getReturnType(); + + if ($castUsingReturn->getObjectClassReflections() !== []) { + $classReflection = $castUsingReturn->getObjectClassReflections()[0]; + } + } + + if ($classReflection->is(CastsAttributes::class)) { + $methodReflection = $classReflection->getNativeMethod('get'); + + return $methodReflection->getVariants()[0]->getReturnType(); + } + + if ($classReflection->is(CastsInboundAttributes::class)) { + return $originalType; + } + + return new MixedType; + } + + /** + * Get the type a cast attribute accepts. + */ + public function getWriteableType(string $cast, Type $originalType): Type + { + $arguments = $this->parseCastArguments($cast); + $cast = $this->parseCast($cast); + + $attributeType = match ($cast) { + 'int', 'integer', 'timestamp' => $originalType->isInteger()->yes() ? $originalType : new IntegerType, + 'real', 'float', 'double' => new FloatType, + 'decimal' => TypeCombinator::union(TypeCombinator::intersect(new StringType, new AccessoryNumericStringType), new IntegerType, new FloatType), + 'string' => new StringType, + 'bool', 'boolean' => TypeCombinator::union(new BooleanType, new ConstantIntegerType(0), new ConstantIntegerType(1)), + 'object' => new ObjectType(stdClass::class), + 'array', 'json' => new ArrayType(new BenevolentUnionType([new IntegerType, new StringType]), new MixedType), + 'collection' => new ObjectType(Collection::class), + 'date', 'datetime', 'immutable_date', 'immutable_datetime' => $this->getDateWriteableType(), + AsArrayObject::class, AsCollection::class, + AsEncryptedArrayObject::class, AsEncryptedCollection::class => new MixedType, + AsStringable::class => TypeCombinator::union(new StringType, new ObjectType(Stringable::class)), + default => null, + }; + + if ($attributeType) { + return $attributeType; + } + + if (! $this->reflectionProvider->hasClass($cast)) { + return $originalType; + } + + $classReflection = $this->reflectionProvider->getClass($cast); + + if ($classReflection->isEnum()) { + return new ObjectType($cast); + } + + if ($classReflection->is(Castable::class)) { + $methodReflection = $classReflection->getNativeMethod('castUsing'); + $castUsingReturn = ParametersAcceptorSelector::selectFromTypes( + [ConstantTypeHelper::getTypeFromValue($arguments)], + $methodReflection->getVariants(), + false, + )->getReturnType(); + + if ($castUsingReturn->getObjectClassReflections() !== []) { + $classReflection = $castUsingReturn->getObjectClassReflections()[0]; + } + } + + if ( + $classReflection->is(CastsAttributes::class) + || $classReflection->is(CastsInboundAttributes::class) + ) { + $methodReflection = $classReflection->getNativeMethod('set'); + $parameters = $methodReflection->getVariants()[0]->getParameters(); + + $valueParameter = Arr::first($parameters, static fn (ParameterReflection $parameterReflection) => $parameterReflection->getName() === 'value'); + + if ($valueParameter) { + return $valueParameter->getType(); + } + } + + return new MixedType; + } + + /** + * Get the collection a collection cast reads as: the class using() selects, with its templates at their bounds, + * or the base collection. + * + * Unlike upstream, the selected class is kept. With a mapper, map() and mapInto() can return another class. + * + * @param list $arguments + */ + private function getCollectionCastType(array $arguments): Type + { + $class = $arguments[0] ?? ''; + $collection = $class !== '' && ($arguments[1] ?? '') === '' && $this->reflectionProvider->hasClass($class) + ? $this->reflectionProvider->getClass($class) + : null; + + if ($collection === null || ! $collection->is(Collection::class) || $collection->getName() === Collection::class) { + return new GenericObjectType(Collection::class, [new BenevolentUnionType([new IntegerType, new StringType]), new MixedType]); + } + + $templateTypes = array_values($collection->getTemplateTypeMap()->resolveToBounds()->getTypes()); + + return $templateTypes === [] ? new ObjectType($collection->getName()) : new GenericObjectType($collection->getName(), $templateTypes); + } + + /** + * Get the type a date attribute reads as. + */ + public function getDateType(): Type + { + // Unlike upstream, the booted date factory's class is not used: applications can + // configure it, so asDateTime() only guarantees the interface. + return new ObjectType(CarbonInterface::class); + } + + /** + * Get the type a date attribute accepts, which fromDateTime() converts. + */ + public function getDateWriteableType(): Type + { + return TypeCombinator::union(new ObjectType(DateTimeInterface::class), new StringType, new IntegerType, new FloatType); + } + + /** + * Get the cast type without its encryption prefix and arguments. + */ + private function parseCast(string $cast): string + { + foreach (explode(':', $cast) as $part) { + // If the cast is prefixed with `encrypted:` we need to skip to the next + if ($part === 'encrypted') { + continue; + } + + return $part; + } + + return $cast; + } + + /** + * Get the arguments of a cast. + * + * @return list + */ + private function parseCastArguments(string $cast): array + { + if (! str_contains($cast, ':')) { + return []; + } + + return explode(',', explode(':', $cast, 2)[1]); + } + + /** + * Determine whether the model casts the property, including with a cast that cannot be determined. + */ + public function hasCastForProperty(ClassReflection $modelClassReflection, string $propertyName): bool + { + $modelCasts = $this->getModelCasts($modelClassReflection); + + return array_key_exists($propertyName, $modelCasts['casts']) || ! $modelCasts['complete']; + } + + /** + * Determine whether the model's casts name the property, which makes it an attribute even without a column. + */ + public function hasCastEntry(ClassReflection $modelClassReflection, string $propertyName): bool + { + return array_key_exists($propertyName, $this->getModelCasts($modelClassReflection)['casts']); + } + + /** + * Get the model's cast for the property, or null when it has none or its cast cannot be determined. + */ + public function getCastForProperty(ClassReflection $modelClassReflection, string $propertyName): ?string + { + return $this->getModelCasts($modelClassReflection)['casts'][$propertyName] ?? null; + } + + /** + * Get the model's casts, including those its casts() method returns. + * + * A null cast is present but cannot be determined. Unless the casts are complete, any other + * property may have such a cast too. + * + * @return array{casts: array, complete: bool} + * + * @throws ShouldNotHappenException + */ + private function getModelCasts(ClassReflection $modelClassReflection): array + { + $className = $modelClassReflection->getName(); + + if (array_key_exists($className, $this->modelCasts)) { + return $this->modelCasts[$className]; + } + + try { + $modelInstance = $this->modelHelper->getInstance($modelClassReflection); + } catch (ReflectionException) { + throw new ShouldNotHappenException; + } + + // The instance skips the constructor, so these are the $casts property's casts, without casts() + // and without the normalization of [$cast, ...$arguments] that initializeHasAttributes() runs. + $propertyCasts = array_map( + fn (mixed $cast): ?string => $this->getCastFromType(ConstantTypeHelper::getTypeFromValue($cast)), + $modelInstance->getCasts(), + ); + $castsMethod = $modelClassReflection->getNativeMethod('casts'); + + if ($castsMethod->getDeclaringClass()->getName() === Model::class) { + return $this->modelCasts[$className] = ['casts' => $propertyCasts, 'complete' => true]; + } + + $declaredCasts = $this->getCastsFromType($castsMethod->getVariants()[0]->getReturnType()); + $returnedType = $this->parseModelCastsMethod ? $this->parseCastsMethod($modelClassReflection, $castsMethod) : null; + $returnedCasts = $returnedType === null ? null : $this->getCastsFromType($returnedType); + $methodCasts = $returnedCasts ?? $declaredCasts; + + if ($returnedCasts !== null && $declaredCasts !== null) { + // A cast the method body builds at runtime may still be declared in its return type. + foreach ($returnedCasts['casts'] as $attribute => $cast) { + $methodCasts['casts'][$attribute] = $cast ?? $declaredCasts['casts'][$attribute] ?? null; + } + } + + // Unknown method casts can replace the property's casts, but its keys still name attributes. + return $this->modelCasts[$className] = $methodCasts !== null && $methodCasts['closed'] + ? ['casts' => array_merge($propertyCasts, $methodCasts['casts']), 'complete' => true] + : ['casts' => array_merge(array_fill_keys(array_keys($propertyCasts), null), $methodCasts['casts'] ?? []), 'complete' => false]; + } + + /** + * Get the casts a casts() return type sets, with null for those that cannot be determined, and + * whether it sets no others; or null when the type is not a single array shape. + * + * @return null|array{casts: array, closed: bool} + */ + private function getCastsFromType(Type $type): ?array + { + $arrays = $type->getConstantArrays(); + + if (count($arrays) !== 1 || ! $type->isConstantArray()->yes()) { + return null; + } + + $casts = []; + + foreach ($arrays[0]->getKeyTypes() as $index => $keyType) { + // An optional cast may leave the $casts property's cast, or none, in place. + $casts[(string) $keyType->getValue()] = $arrays[0]->isOptionalKey($index) + ? null + : $this->getCastFromType($arrays[0]->getValueTypes()[$index]); + } + + // Outside bleeding edge, PHPStan leaves the sealedness of shapes without "..." undecided, + // though it treats their missing keys as absent. + return ['casts' => $casts, 'closed' => ! $arrays[0]->isUnsealed()->yes()]; + } + + /** + * Get the cast a casts() value defines, with array definitions normalized as Eloquent does, or null + * when the value is not a single constant definition. + */ + private function getCastFromType(Type $type): ?string + { + $strings = $type->getConstantStrings(); + + if (count($strings) === 1) { + return $strings[0]->getValue(); + } + + $arrays = $type->getConstantArrays(); + + if (count($arrays) !== 1 || ! $type->isConstantArray()->yes() || ! $arrays[0]->isList()->yes()) { + return null; + } + + $definition = []; + + foreach ($arrays[0]->getValueTypes() as $index => $valueType) { + $values = $valueType->getConstantScalarValues(); + + if (count($values) !== 1 || $arrays[0]->isOptionalKey($index)) { + return null; + } + + $definition[] = $values[0]; + } + + // As HasAttributes::ensureCastsAreStringValues() normalizes [$cast, ...$arguments]. + $cast = array_shift($definition); + + if (! is_string($cast)) { + return null; + } + + return $definition === [] ? $cast : $cast . ':' . implode(',', $definition); + } + + /** + * Get the type of the array an overridden casts() method returns, or null when its body has no + * single unconditional array return to read. + */ + private function parseCastsMethod(ClassReflection $modelClassReflection, ExtendedMethodReflection $castsMethod): ?Type + { + // The class using a trait declares its methods, which is also what self:: refers to in them. + $declaringClass = $castsMethod->getDeclaringClass(); + // The native method carries the file and line of a trait's method. + $nativeMethod = $declaringClass->getNativeReflection()->getMethod('casts'); + $fileName = (string) $nativeMethod->getFileName(); + $nodeFinder = new NodeFinder; + + /** @var ClassMethod $methodNode */ + $methodNode = $nodeFinder->findFirst( + $this->parser->parseFile($fileName), + static fn (Node $node): bool => $node instanceof ClassMethod + && $node->name->toLowerString() === 'casts' + && $node->getStartLine() === $nativeMethod->getStartLine(), + ); + $statements = $methodNode->stmts ?? []; + + // Returns inside nested closures and classes belong to them. + $nestedReturns = $nodeFinder->findInstanceOf( + $nodeFinder->find($statements, static fn (Node $node): bool => $node instanceof FunctionLike || $node instanceof ClassLike), + Return_::class, + ); + $returns = array_filter( + $nodeFinder->findInstanceOf($statements, Return_::class), + static fn (Return_ $return): bool => ! in_array($return, $nestedReturns, true), + ); + $return = array_find($statements, static fn (Node $statement): bool => $statement instanceof Return_); + + if (count($returns) !== 1 || ! $return instanceof Return_ || ! $return->expr instanceof Array_) { + return null; + } + + $scope = $this->scopeFactory->create(ScopeContext::create($fileName))->enterClass($declaringClass); + + $this->trackReferencedSources($modelClassReflection, $return->expr, $scope); + + return $scope->getType($return->expr); + } + + /** + * Track the files declaring the constants and static methods the returned casts use. + * + * Dependents of the model are otherwise analysed again only when the model's own declarations change. + */ + private function trackReferencedSources(ClassReflection $modelClassReflection, Array_ $casts, Scope $scope): void + { + $references = (new NodeFinder)->find( + $casts, + static fn (Node $node): bool => ($node instanceof ClassConstFetch || $node instanceof StaticCall) + && $node->class instanceof Name + && $node->name instanceof Identifier, + ); + + foreach ($references as $reference) { + /** @var ClassConstFetch|StaticCall $reference */ + $className = $scope->resolveName($reference->class); + $name = $reference->name->toString(); + + if (! $this->reflectionProvider->hasClass($className)) { + continue; + } + + $class = $this->reflectionProvider->getClass($className); + + // A class below the declaring one can override the member. + $files = match (true) { + $reference instanceof StaticCall => $class->hasNativeMethod($name) + ? SourceFiles::forClass($class, $class->getNativeMethod($name)->getDeclaringClass()->getParentClass()?->getName()) + : [], + $name === 'class' => [], + default => $class->hasConstant($name) ? SourceFiles::forConstant($this->reflectionProvider, $class, $name) : [], + }; + + foreach ($files as $file) { + $this->declarationDependencyTracker->trackFileDependency($modelClassReflection, $file); + } + } + } +} diff --git a/src/phpstan/src/Properties/ModelHelper.php b/src/phpstan/src/Properties/ModelHelper.php new file mode 100644 index 000000000..f8abb7d2a --- /dev/null +++ b/src/phpstan/src/Properties/ModelHelper.php @@ -0,0 +1,87 @@ + */ + private array $instances = []; + + /** + * Create a new model helper instance. + */ + public function __construct( + private DeclarationDependencyTracker $declarationDependencyTracker, + private ReflectionProvider $reflectionProvider, + ) { + } + + /** + * Get the model's instance, created without running its constructor and memoized per class, so callers must + * treat it as read-only. + * + * What the instance reads, such as its connection, table, key and casts, comes from property defaults and + * method bodies that are not part of the model's declarations, so their files become dependencies of the + * files depending on the model. + * + * @throws ReflectionException + */ + public function getInstance(ClassReflection $classReflection): Model + { + $className = $classReflection->getName(); + + if (isset($this->instances[$className])) { + return $this->instances[$className]; + } + + $model = self::newInstanceWithoutConstructor($classReflection); + + foreach (SourceFiles::forInstance($this->reflectionProvider, $classReflection, Model::class) as $file) { + $this->declarationDependencyTracker->trackFileDependency($classReflection, $file); + } + + return $this->instances[$className] = $model; + } + + /** + * Create a model instance without running its constructor. + * + * @throws ReflectionException + */ + public static function newInstanceWithoutConstructor(ClassReflection $classReflection): Model + { + try { + /** @var Model $model */ + $model = $classReflection->getNativeReflection()->newInstanceWithoutConstructor(); + } catch (Error $error) { + throw new ReflectionException($error->getMessage(), 0, $error); + } + + // The trait initializers run before the class attributes, as in the constructor. Unique + // string ids decide the key type and incrementing, so they must not depend on call order. + $model->initializeHasTimestamps(); + + if (method_exists($model, 'initializeHasUniqueStringIds')) { + $model->initializeHasUniqueStringIds(); + } + + $model->initializeModelAttributes(); + + return $model; + } +} diff --git a/src/phpstan/src/Properties/ModelProperty.php b/src/phpstan/src/Properties/ModelProperty.php new file mode 100644 index 000000000..c145a44ae --- /dev/null +++ b/src/phpstan/src/Properties/ModelProperty.php @@ -0,0 +1,132 @@ +declaringClass; + } + + /** + * Determine whether the property is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the property is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the property is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Determine whether the property is readable. + */ + public function isReadable(): bool + { + return true; + } + + /** + * Determine whether the property is writable. + */ + public function isWritable(): bool + { + return $this->writeable; + } + + /** + * Get the property's doc comment. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Get the type the property reads as. + */ + public function getReadableType(): Type + { + return $this->readableType; + } + + /** + * Get the type the property accepts. + */ + public function getWritableType(): Type + { + return $this->writableType; + } + + /** + * Determine whether an assignment narrows the property's type. + */ + public function canChangeTypeAfterAssignment(): bool + { + // Unlike upstream, a raw attribute narrows: reading it returns the assigned value. + return $this->canChangeTypeAfterAssignment; + } + + /** + * Determine whether the property is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Get the property's deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return null; + } + + /** + * Determine whether the property is internal. + */ + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } +} diff --git a/src/phpstan/src/Properties/ModelPropertyExtension.php b/src/phpstan/src/Properties/ModelPropertyExtension.php new file mode 100644 index 000000000..f4e5b0e6a --- /dev/null +++ b/src/phpstan/src/Properties/ModelPropertyExtension.php @@ -0,0 +1,50 @@ +modelPropertyHelper->hasAccessor($classReflection, $propertyName)) { + return false; + } + + $hasProperty = $this->modelPropertyHelper->hasDatabaseProperty($classReflection, $propertyName); + + return $hasProperty->yes() + || ($hasProperty->maybe() && ! $this->modelRelationsExtension->hasProperty($classReflection, $propertyName)); + } + + /** + * Get the database property. + */ + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return $this->modelPropertyHelper->getDatabaseProperty($classReflection, $propertyName); + } +} diff --git a/src/phpstan/src/Properties/ModelPropertyHelper.php b/src/phpstan/src/Properties/ModelPropertyHelper.php new file mode 100644 index 000000000..28bd2aaba --- /dev/null +++ b/src/phpstan/src/Properties/ModelPropertyHelper.php @@ -0,0 +1,664 @@ + */ + private array $connections = []; + + private bool $migrationsLoaded = false; + + /** @var array the PHP files besides the migrations that the schema facts were read from, except tables' own sources */ + private array $schemaSources = []; + + /** @var null|array the config sources of the schema facts, each mapped to whether it is a directory */ + private ?array $schemaConfigSources = null; + + /** @var null|array the sources of every booted config value, each mapped to whether it is a directory */ + private ?array $configSources = null; + + /** @var array the connection names of models, by class */ + private array $connectionNames = []; + + /** @var array the declared return types of Attribute accessor methods, null when there is none */ + private array $attributeTypes = []; + + /** @var array> the column names by fetched attribute name, per table and ATTR_CASE */ + private array $fetchedNames = []; + + /** + * Create a new model property helper instance. + */ + public function __construct( + private TypeStringResolver $stringResolver, + private MigrationHelper $migrationHelper, + private SquashedMigrationHelper $squashedMigrationHelper, + private ModelCastHelper $modelCastHelper, + private ModelHelper $modelHelper, + private MigrationCache $migrationCache, + private DeclarationDependencyTracker $declarationDependencyTracker, + private ReflectionProvider $reflectionProvider, + private ConnectionConfigResolver $connectionConfigResolver, + private DataTypeToPhpTypeConverter $converter, + private ConfigSourceResolver $configSourceResolver, + ) { + } + + /** + * Determine if the model has a database property, or maybe has one when its schema facts are unknown. + */ + public function hasDatabaseProperty(ClassReflection $classReflection, string $propertyName): TrinaryLogic + { + if (! $classReflection->is(Model::class)) { + return TrinaryLogic::createNo(); + } + + if ($classReflection->isAbstract()) { + return TrinaryLogic::createNo(); + } + + if (ReflectionHelper::hasPropertyTag($classReflection, $propertyName)) { + return TrinaryLogic::createNo(); + } + + try { + $modelInstance = $this->modelHelper->getInstance($classReflection); + } catch (ReflectionException) { + return TrinaryLogic::createNo(); + } + + // HasAttributes::hasAttribute() accepts a cast key without a column, such as a class cast built from other columns. + if ($propertyName === $modelInstance->getKeyName() || $this->modelCastHelper->hasCastEntry($classReflection, $propertyName)) { + return TrinaryLogic::createYes(); + } + + $lookup = $this->findTable($classReflection, $modelInstance); + + // Without established facts, a property is not claimed to be missing. + return $lookup === null ? TrinaryLogic::createMaybe() : $this->hasColumn($lookup, $propertyName); + } + + /** + * Determine if a table on the model's connection has a column, or whether it is unknown. + * + * Unlike upstream, the table is looked up on the model's connection rather than by name alone, + * and a table missing from the facts is not claimed, since a query can name a table by an alias. + */ + public function hasTableColumn(ClassReflection $classReflection, string $table, string $column): bool + { + try { + $modelInstance = $this->modelHelper->getInstance($classReflection); + } catch (ReflectionException) { + return true; + } + + $lookup = $this->findTable($classReflection, $modelInstance, $table); + + return $lookup === null || $lookup['table'] === null || ! $this->hasColumn($lookup, $column)->no(); + } + + /** + * Get the schema dumps of the model's connection that could not be read. + * + * @return list + */ + public function getUnusableDumps(ClassReflection $classReflection): array + { + if (! $classReflection->is(Model::class)) { + return []; + } + + try { + $modelInstance = $this->modelHelper->getInstance($classReflection); + } catch (ReflectionException) { + return []; + } + + return $this->findConnection($classReflection, $modelInstance)['schema']->unusableDumps ?? []; + } + + /** + * Get the names of the attributes a model fetches from its table, or null when they are not all known. + * + * @return null|list + */ + public function getDatabasePropertyNames(ClassReflection $classReflection): ?array + { + try { + $model = $this->modelHelper->getInstance($classReflection); + } catch (ReflectionException) { + return null; + } + + $lookup = $this->findTable($classReflection, $model); + $table = $lookup['table'] ?? null; + + if ($table === null || ! $table->complete || $lookup['config']->customized) { + return null; + } + + $names = []; + + foreach ($lookup['config']->fetchOptions as $options) { + $names = [...$names, ...array_keys($this->getFetchedNames($table, $options))]; + } + + return array_values(array_unique($names)); + } + + /** + * Get the database property of a model, which also types the directions its accessors and mutators leave to Eloquent. + */ + public function getDatabaseProperty(ClassReflection $classReflection, string $propertyName): ModelProperty + { + try { + $modelInstance = $this->modelHelper->getInstance($classReflection); + } catch (ReflectionException) { + // Reachable through an accessor on an abstract or uninstantiable model, whose attributes are unknown. + return new ModelProperty($classReflection, new MixedType, new MixedType); + } + + $lookup = $this->findTable($classReflection, $modelInstance); + $table = $lookup['table'] ?? null; + + if ($propertyName === $modelInstance->getKeyName() && ! isset($table->columns[$propertyName])) { + $keyType = $this->stringResolver->resolve($modelInstance->getKeyType()); + [$readableType, $writableType, $nullable] = [$keyType, $keyType, false]; + } else { + // A virtual attribute behind an accessor has no column, so its raw value is whatever was assigned. + [$readableType, $writableType, $nullable] = $lookup === null || $table === null || $this->hasColumn($lookup, $propertyName)->no() + ? [new MixedType, new MixedType, false] + : $this->getColumnTypes($lookup, $table, $propertyName); + } + + $hasCast = $this->modelCastHelper->hasCastForProperty($classReflection, $propertyName); + $cast = $this->modelCastHelper->getCastForProperty($classReflection, $propertyName); + $date = $this->hasDate($modelInstance, $propertyName); + + if ($hasCast && $cast === null) { + // A cast that cannot be determined may turn the attribute into anything. + $readableType = new MixedType; + $writableType = $date ? $this->modelCastHelper->getDateWriteableType() : new MixedType; + } elseif ($date) { + $readableType = $cast !== null + ? $this->modelCastHelper->getReadableType($cast, $readableType) + : $this->modelCastHelper->getDateType(); + // Eloquent runs fromDateTime() on date columns before applying any cast, + // so the cast's setter type does not apply to assignments. + $writableType = $this->modelCastHelper->getDateWriteableType(); + } elseif ($cast !== null) { + $readableType = $this->modelCastHelper->getReadableType($cast, $readableType); + $writableType = $this->modelCastHelper->getWriteableType($cast, $writableType); + } + + if (($date || $cast !== null) && $nullable) { + $readableType = TypeCombinator::addNull($readableType); + $writableType = TypeCombinator::addNull($writableType); + } + + return new ModelProperty( + $classReflection, + $readableType, + $writableType, + canChangeTypeAfterAssignment: ! $date && ! $hasCast, + ); + } + + /** + * Get the types a column attribute reads as and accepts, with whether the column is nullable. + * + * Raw types include null and the connection's fetch options; the attribute reads the column + * each option set's ATTR_CASE fetches under that name, or null when none does. + * + * @param array{config: ConnectionConfig, schema: SchemaConnection, table: null|SchemaTable} $lookup + * + * @return array{Type, Type, bool} + */ + private function getColumnTypes(array $lookup, SchemaTable $table, string $propertyName): array + { + $config = $lookup['config']; + + if ($config->customized) { + return [new MixedType, new MixedType, false]; + } + + $schema = $lookup['schema']; + $searchPath = $table->searchPath ?? $config->searchPath; + $writeColumn = $table->columns[$propertyName] ?? null; + $nullable = $writeColumn !== null && $writeColumn->nullable; + $readableTypes = []; + + foreach ($config->fetchOptions as $options) { + $name = $this->getFetchedNames($table, $options)[$propertyName] ?? null; + $column = $name === null ? null : $table->columns[$name]; + + if ($column === null || $column->type === null) { + $readableTypes[] = $column === null && $table->complete ? new NullType : new MixedType; + + continue; + } + + $nullable = $nullable || $column->nullable; + $readableType = $this->converter->convert($schema->driver, $column->type, $column->options, $schema, $searchPath)['readable']; + $readableType = $column->nullable ? TypeCombinator::addNull($readableType) : $readableType; + $readableTypes[] = $this->converter->applyFetchOptions($readableType, $schema->driver, [$options]); + } + + if ($writeColumn?->type === null) { + $writableType = new MixedType; + } else { + $writableType = $this->converter->convert($schema->driver, $writeColumn->type, $writeColumn->options, $schema, $searchPath)['writable']; + $writableType = $writeColumn->nullable ? TypeCombinator::addNull($writableType) : $writableType; + } + + return [TypeCombinator::union(...$readableTypes), $writableType, $nullable]; + } + + /** + * Determine whether a found table has a column, either as written or as an option set fetches it, + * or maybe has it when the table's columns are not all known. + * + * @param array{config: ConnectionConfig, schema: SchemaConnection, table: null|SchemaTable} $lookup + */ + private function hasColumn(array $lookup, string $column): TrinaryLogic + { + $table = $lookup['table']; + + if ($table === null) { + return TrinaryLogic::createNo(); + } + + if (isset($table->columns[$column])) { + return TrinaryLogic::createYes(); + } + + foreach ($lookup['config']->fetchOptions as $options) { + if (isset($this->getFetchedNames($table, $options)[$column])) { + return TrinaryLogic::createYes(); + } + } + + return $table->complete ? TrinaryLogic::createNo() : TrinaryLogic::createMaybe(); + } + + /** + * Get a table's column names by the attribute names an option set's ATTR_CASE fetches them as. + * + * @param array $options + * + * @return array + */ + private function getFetchedNames(SchemaTable $table, array $options): array + { + $case = $options[PDO::ATTR_CASE] ?? PDO::CASE_NATURAL; + $key = spl_object_id($table) . ':' . $case; + + if (isset($this->fetchedNames[$key])) { + return $this->fetchedNames[$key]; + } + + $names = array_keys($table->columns); + + return $this->fetchedNames[$key] = array_combine( + array_map(static fn (string $name): string => match ($case) { + PDO::CASE_LOWER => strtolower($name), + PDO::CASE_UPPER => strtoupper($name), + default => $name, + }, $names), + $names, + ); + } + + /** + * Find the configuration and schema facts of a model's connection, or return null when they are unknown. + * + * @return null|array{config: ConnectionConfig, schema: SchemaConnection} + */ + private function findConnection(ClassReflection $classReflection, Model $model): ?array + { + if (! $this->migrationsLoaded()) { + $this->loadMigrations(); + } + + $connectionName = $this->getConnectionName($classReflection, $model) ?? $this->connectionConfigResolver->getDefaultConnectionName(); + $config = $connectionName === null ? null : $this->connectionConfigResolver->connection($connectionName); + $schema = $config === null ? null : $this->connections[$config->owner] ?? null; + + return $config === null || $schema === null ? null : ['config' => $config, 'schema' => $schema]; + } + + /** + * Get the name of the model's connection, or null for the default one. + * + * An overriding getConnectionName() can read any config value, so every booted config source + * becomes a dependency of the files depending on the model. + */ + private function getConnectionName(ClassReflection $classReflection, Model $model): ?string + { + $className = $classReflection->getName(); + + if (array_key_exists($className, $this->connectionNames)) { + return $this->connectionNames[$className]; + } + + if ($classReflection->getNativeMethod('getConnectionName')->getDeclaringClass()->getName() !== Model::class) { + $this->trackConfigSources( + $classReflection, + $this->configSources ??= $this->configSourceResolver->getAllSources($this->configSourceResolver->getBootedConfigDirectories()), + ); + } + + return $this->connectionNames[$className] = $model->getConnectionName(); + } + + /** + * Find a model's table in the schema facts of its connection, or return null when the facts are unknown. + * + * A null table means the table does not exist on a connection whose relations are all known. A found + * table's sources become dependencies of the model, since its column facts were read from them. + * + * @return null|array{config: ConnectionConfig, schema: SchemaConnection, table: null|SchemaTable} + */ + private function findTable(ClassReflection $classReflection, Model $model, ?string $table = null): ?array + { + $connection = $this->findConnection($classReflection, $model); + + if ($connection === null) { + return null; + } + + ['config' => $config, 'schema' => $schema] = $connection; + [$tableSchema, $name] = $config->physicalTable($table ?? $model->getTable()); + $key = $schema->findTableKey($tableSchema, $name, $config->defaultSchema, $config->searchPath); + + if ($key === false || ($key === null && ! $schema->complete)) { + return null; + } + + $tableFacts = $key === null ? null : $schema->tables[$key]; + + foreach (array_keys($tableFacts->sources ?? []) as $source) { + $this->declarationDependencyTracker->trackFileDependency($classReflection, $source); + } + + return ['config' => $config, 'schema' => $schema, 'table' => $tableFacts]; + } + + /** + * Determine if an accessor or mutator handles the property, as a get{Name}Attribute() or set{Name}Attribute() method or an Attribute method. + * + * Unlike upstream, public Attribute methods and those without generics count, as Eloquent uses them too. + */ + public function hasAccessor(ClassReflection $classReflection, string $propertyName): bool + { + return $classReflection->is(Model::class) + && ($this->getAttributeType($classReflection, $propertyName) !== null + || $classReflection->hasNativeMethod('get' . Str::studly($propertyName) . 'Attribute') + || $classReflection->hasNativeMethod('set' . Str::studly($propertyName) . 'Attribute')); + } + + /** + * Get the property an accessor or mutator provides. + * + * Each direction follows Eloquent's precedence: the get{Name}Attribute() or set{Name}Attribute() method, + * then the Attribute callback, then the database property with its casts. Unlike upstream, a direction + * without a callback is not refused, since Eloquent then reads or writes the attribute itself. + */ + public function getAccessor(ClassReflection $classReflection, string $propertyName): ModelProperty + { + $readableType = $this->getGetMutatorType($classReflection, $propertyName) + ?? $this->getAttributeGetMutatorType($classReflection, $propertyName); + $writableType = $this->getSetMutatorType($classReflection, $propertyName) + ?? $this->getAttributeSetMutatorType($classReflection, $propertyName); + + if ($readableType !== null && $writableType !== null) { + return new ModelProperty($classReflection, $readableType, $writableType); + } + + $databaseProperty = $this->getDatabaseProperty($classReflection, $propertyName); + + return new ModelProperty( + $classReflection, + $readableType ?? $databaseProperty->getReadableType(), + $writableType ?? $databaseProperty->getWritableType(), + canChangeTypeAfterAssignment: $readableType === null && $writableType === null + && $databaseProperty->canChangeTypeAfterAssignment(), + ); + } + + /** + * Get the type a get{Name}Attribute() accessor returns, or null when there is none. + */ + public function getGetMutatorType(ClassReflection $classReflection, string $propertyName): ?Type + { + $method = 'get' . Str::studly($propertyName) . 'Attribute'; + + return $classReflection->hasNativeMethod($method) + ? $classReflection->getNativeMethod($method)->getVariants()[0]->getReturnType() + : null; + } + + /** + * Get the type an Attribute accessor's get callback returns, or null when it has none. + */ + public function getAttributeGetMutatorType(ClassReflection $classReflection, string $propertyName): ?Type + { + $type = $this->getAttributeType($classReflection, $propertyName)?->getTemplateType(Attribute::class, 'TGet'); + + return $type instanceof NeverType ? null : $type; + } + + /** + * Get the type a set{Name}Attribute() mutator accepts, or null when there is none. + */ + private function getSetMutatorType(ClassReflection $classReflection, string $propertyName): ?Type + { + $method = 'set' . Str::studly($propertyName) . 'Attribute'; + + if (! $classReflection->hasNativeMethod($method)) { + return null; + } + + return ($classReflection->getNativeMethod($method)->getVariants()[0]->getParameters()[0] ?? null)?->getType() + ?? new MixedType; + } + + /** + * Get the type an Attribute mutator's set callback accepts, or null when it has none. + */ + private function getAttributeSetMutatorType(ClassReflection $classReflection, string $propertyName): ?Type + { + $type = $this->getAttributeType($classReflection, $propertyName)?->getTemplateType(Attribute::class, 'TSet'); + + return $type instanceof NeverType ? null : $type; + } + + /** + * Get the declared return type of the property's Attribute method, or null when Eloquent would not use one. + * + * Like HasAttributes::hasAttributeMutator(), the method's native return type must name Attribute itself. + * Private methods are excluded, since Eloquent cannot call them. + */ + private function getAttributeType(ClassReflection $classReflection, string $propertyName): ?Type + { + $cacheKey = $classReflection->getCacheKey() . '-' . $propertyName; + + if (array_key_exists($cacheKey, $this->attributeTypes)) { + return $this->attributeTypes[$cacheKey]; + } + + $method = Str::camel($propertyName); + + if (! $classReflection->hasNativeMethod($method) || $classReflection->getNativeMethod($method)->isPrivate()) { + return $this->attributeTypes[$cacheKey] = null; + } + + $returnType = $classReflection->getNativeReflection()->getMethod($method)->getReturnType(); + + return $this->attributeTypes[$cacheKey] = $returnType instanceof ReflectionNamedType && $returnType->getName() === Attribute::class + ? $classReflection->getNativeMethod($method)->getVariants()[0]->getReturnType() + : null; + } + + /** + * Determine whether the schema facts are loaded. + */ + private function migrationsLoaded(): bool + { + return $this->migrationsLoaded; + } + + /** + * Load the schema facts from the schema dumps and migrations. + */ + private function loadMigrations(): void + { + $this->migrationsLoaded = true; + + $migrationFiles = $this->migrationHelper->getMigrationFiles(); + $schemaFiles = $this->squashedMigrationHelper->getSchemaFiles(); + + ['connections' => $this->connections, 'sources' => $this->schemaSources] = $this->migrationCache->remember( + $migrationFiles, + $schemaFiles, + $this->connectionConfigResolver->getSchemaConfiguration(), + function (): array { + // Migrations apply on top of the schema dumps, as migrate loads a dump first. + $aggregator = $this->migrationHelper->aggregate($this->squashedMigrationHelper->initializeConnections()); + + return ['connections' => $aggregator->connections, 'sources' => $aggregator->sources]; + }, + ); + + // Every file using a model depends on Model, so the dependency is declared once, on Model itself. + // The config keys the facts read are known only now. + $this->trackSchemaDependency($this->reflectionProvider->getClass(Model::class)); + } + + /** + * Track the schema inputs as dependencies of the class. + * + * What the class declares depends on the database schema, which comes from + * the migrations, the schema dumps, the config they read and the PHP files + * naming their tables and connections. Each file depending on the class is + * analysed again when one of them is created, changed or deleted. The files + * a table's columns were read from are tracked on the models reading it. + */ + public function trackSchemaDependency(ClassReflection $classReflection): void + { + if (! $this->migrationsLoaded()) { + $this->loadMigrations(); + } + + foreach ($this->migrationHelper->getMigrationPaths() as $path => $pattern) { + if ($pattern === null) { + $this->declarationDependencyTracker->trackFileDependency($classReflection, $path); + } else { + $this->declarationDependencyTracker->trackDirectoryDependency($classReflection, $path, $pattern); + } + } + + foreach ($this->squashedMigrationHelper->getSchemaDirectories() as $directory) { + $this->declarationDependencyTracker->trackDirectoryDependency($classReflection, $directory); + } + + foreach (array_keys($this->schemaSources) as $source) { + $this->declarationDependencyTracker->trackFileDependency($classReflection, $source); + } + + $this->trackConfigSources($classReflection, $this->getSchemaConfigSources()); + } + + /** + * Track config sources, each mapped to whether it is a directory of config files, as dependencies of the class. + * + * @param array $sources + */ + private function trackConfigSources(ClassReflection $classReflection, array $sources): void + { + foreach ($sources as $path => $directory) { + if ($directory) { + $this->declarationDependencyTracker->trackDirectoryDependency($classReflection, $path, ConfigSourceResolver::CONFIG_FILE_PATTERN); + } else { + $this->declarationDependencyTracker->trackFileDependency($classReflection, $path); + } + } + } + + /** + * Get the paths whose contents can change the config the schema facts read, each mapped to + * whether it is a directory. + * + * The facts read the booted configuration, so its sources are the booted config directories, + * even when the analysis types config from others. + * + * @return array + */ + private function getSchemaConfigSources(): array + { + if ($this->schemaConfigSources !== null) { + return $this->schemaConfigSources; + } + + $sources = []; + $directories = $this->configSourceResolver->getBootedConfigDirectories(); + + foreach ($this->connectionConfigResolver->getConsumedKeys() as $key) { + $sources += $this->configSourceResolver->getKeySources($key, $directories); + } + + return $this->schemaConfigSources = $sources; + } + + /** + * Determine whether the model treats the attribute as a date. + */ + private function hasDate(Model $modelInstance, string $propertyName): bool + { + $dates = $modelInstance->getDates(); + + // In order to support SoftDeletes + if (method_exists($modelInstance, 'getDeletedAtColumn')) { + $dates[] = $modelInstance->getDeletedAtColumn(); + } + + return in_array($propertyName, $dates, true); + } +} diff --git a/src/phpstan/src/Properties/ModelRelationsExtension.php b/src/phpstan/src/Properties/ModelRelationsExtension.php new file mode 100644 index 000000000..946a9aa1c --- /dev/null +++ b/src/phpstan/src/Properties/ModelRelationsExtension.php @@ -0,0 +1,123 @@ + */ + private array $hasPropertyCache = []; + + /** + * Determine whether the property is a relation or a relation count. + */ + public function hasProperty(ClassReflection $classReflection, string $propertyName): bool + { + if (! $classReflection->is(Model::class)) { + return false; + } + + $cacheKey = $classReflection->getCacheKey() . '-' . $propertyName; + + if (array_key_exists($cacheKey, $this->hasPropertyCache)) { + return $this->hasPropertyCache[$cacheKey]; + } + + return $this->hasPropertyCache[$cacheKey] = $this->resolveHasProperty($classReflection, $propertyName); + } + + /** + * Determine whether the property is a relation or a relation count, without the memoization. + */ + private function resolveHasProperty(ClassReflection $classReflection, string $propertyName): bool + { + if (ReflectionHelper::hasPropertyTag($classReflection, $propertyName)) { + return false; + } + + if (str_ends_with($propertyName, '_count')) { + // Unlike upstream's Str::before(), only the suffix is removed, so stock_count_items_count names stock_count_items(). + $propertyName = substr($propertyName, 0, -6); + + $methodNames = [Str::camel($propertyName), $propertyName]; + } else { + $methodNames = [$propertyName]; + } + + foreach ($methodNames as $methodName) { + if (! $classReflection->hasNativeMethod($methodName)) { + continue; + } + + $returnType = $classReflection->getNativeMethod($methodName)->getVariants()[0]->getReturnType(); + + if ((new ObjectType(Relation::class))->isSuperTypeOf($returnType)->yes()) { + return true; + } + } + + return false; + } + + /** + * Get the relation's result or count property. + */ + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + if (str_ends_with($propertyName, '_count')) { + return new ModelProperty($classReflection, IntegerRangeType::createAllGreaterThanOrEqualTo(0), new NeverType, false); + } + + $returnType = $classReflection->getMethod($propertyName, new OutOfClassScope) + ->getVariants()[0] + ->getReturnType(); + + $relationType = TypeTraverser::map($returnType, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if (! (new ObjectType(Relation::class))->isSuperTypeOf($type)->yes()) { + return $type; + } + + return $type->getTemplateType(Relation::class, 'TResult'); + }); + + $relationType = $this->collectionHelper->replaceCollectionsInType($relationType); + + return new ModelProperty($classReflection, $relationType, new NeverType, false); + } +} diff --git a/src/phpstan/src/Properties/Schema/DataTypeToPhpTypeConverter.php b/src/phpstan/src/Properties/Schema/DataTypeToPhpTypeConverter.php new file mode 100644 index 000000000..937f51fe5 --- /dev/null +++ b/src/phpstan/src/Properties/Schema/DataTypeToPhpTypeConverter.php @@ -0,0 +1,344 @@ + $options the values a CHECK constraint from Blueprint's enum() allows + * @param list $searchPath the schemas an unqualified PostgreSQL type name resolves through + * + * @return array{readable: Type, writable: Type} + */ + public function convert(string $driver, ColumnType $type, array $options, SchemaConnection $schema, array $searchPath): array + { + if ($options !== []) { + $labels = $this->labels($options); + + return ['readable' => $labels, 'writable' => $labels]; + } + + return match ($driver) { + 'mysql', 'mariadb' => $this->convertMySqlType($type), + 'pgsql' => $this->convertPostgresType($type, $schema, $searchPath), + 'sqlite' => $this->convertSQLiteType($type), + default => $this->unknown(), + }; + } + + /** + * Apply the connection's fetch options to a fetched type, as the union over its distinct option sets. + * + * @param list> $fetchOptions + */ + public function applyFetchOptions(Type $type, string $driver, array $fetchOptions): Type + { + $types = []; + + foreach ($fetchOptions as $options) { + $fetched = $type; + + if (($options[PDO::ATTR_STRINGIFY_FETCHES] ?? false) === true) { + $fetched = $this->stringify($fetched, $driver); + } + + $fetched = match ($options[PDO::ATTR_ORACLE_NULLS] ?? PDO::NULL_NATURAL) { + PDO::NULL_TO_STRING => $fetched->isNull()->no() + ? $fetched + : TypeCombinator::union(TypeCombinator::removeNull($fetched), new ConstantStringType('')), + PDO::NULL_EMPTY_STRING => $fetched->isSuperTypeOf(new ConstantStringType(''))->no() + ? $fetched + : TypeCombinator::addNull($fetched), + default => $fetched, + }; + + $types[] = $fetched; + } + + return $types === [] ? $type : TypeCombinator::union(...$types); + } + + /** + * Get the types of a MySQL or MariaDB column. + * + * @return array{readable: Type, writable: Type} + */ + private function convertMySqlType(ColumnType $type): array + { + $unsignedInteger = IntegerRangeType::fromInterval(0, null); + $unsignedBigInteger = TypeCombinator::union($unsignedInteger, $this->numericString()); + + $readable = match ($type->name) { + 'tinyint', 'smallint', 'mediumint', 'int', 'integer' => $type->unsigned ? $unsignedInteger : new IntegerType, + // Values above PHP_INT_MAX fetch as strings. + 'bigint' => $type->unsigned ? $unsignedBigInteger : new IntegerType, + // Unlike upstream's string type, BIT columns fetch as integers. + 'bit' => ($type->modifiers[0] ?? '1') === '64' ? $unsignedBigInteger : $unsignedInteger, + 'bool', 'boolean', 'year' => new IntegerType, + // Unlike upstream's float type, decimals fetch as strings to keep their precision. + 'decimal', 'dec', 'numeric', 'fixed' => $this->numericString(), + 'float', 'double', 'double precision', 'real' => new FloatType, + 'enum' => $this->labels($type->values), + // Unlike upstream's labels, a set holds a comma-joined combination of them. + 'char', 'varchar', 'character', 'character varying', 'nchar', 'nvarchar', 'tinytext', 'text', + 'mediumtext', 'longtext', 'binary', 'varbinary', 'tinyblob', 'blob', 'mediumblob', 'longblob', 'json', + 'date', 'datetime', 'timestamp', 'time', 'set', 'uuid', 'inet4', 'inet6' => new StringType, + default => new MixedType, + }; + + return [ + 'readable' => $readable, + 'writable' => match (true) { + in_array($type->name, ['decimal', 'dec', 'numeric', 'fixed'], true) => $this->decimalInput($this->numericString()), + $this->isBooleanColumn($type) => TypeCombinator::union(new BooleanType, new IntegerType), + default => $readable, + }, + ]; + } + + /** + * Get the types of a PostgreSQL column, resolving user enums and domains. + * + * A domain resolves through the connection's type facts, not the base the parser embedded, + * so a definition the facts hold as uncertain is never bypassed. + * + * @param list $searchPath + * + * @return array{readable: Type, writable: Type} + */ + private function convertPostgresType(ColumnType $type, SchemaConnection $schema, array $searchPath): array + { + // PDO fetches arrays, including arrays of a domain, as their text representation. + if ($type->isArray()) { + return ['readable' => new StringType, 'writable' => new StringType]; + } + + // pg_catalog is searched first unless the search path names it explicitly. + $schemas = $type->schema !== null + ? [$type->schema] + : (in_array('pg_catalog', $searchPath, true) ? $searchPath : ['pg_catalog', ...$searchPath]); + + // Raw migration SQL can define a type without a schema, in whichever path schema existed first. + $unqualifiedKey = $schema->key(null, $type->name); + $hasUnqualified = $type->schema === null && array_key_exists($unqualifiedKey, $schema->types); + + foreach ($schemas as $candidate) { + if ($candidate === 'pg_catalog' && ($builtIn = $this->convertPostgresBuiltInType($type->name)) !== null) { + return $builtIn; + } + + $key = $schema->key($candidate, $type->name); + + // An uncertain definition ends the search, since the type may exist in this schema. + if (array_key_exists($key, $schema->types)) { + $userType = $schema->types[$key]; + + return $userType === null || $hasUnqualified ? $this->unknown() : $this->convertPostgresUserType($userType, $schema, $searchPath); + } + } + + $unqualified = $hasUnqualified ? $schema->types[$unqualifiedKey] : null; + + return $unqualified === null ? $this->unknown() : $this->convertPostgresUserType($unqualified, $schema, $searchPath); + } + + /** + * Get the types of a PostgreSQL enum or domain. + * + * @param list $searchPath + * + * @return array{readable: Type, writable: Type} + */ + private function convertPostgresUserType(Domain|EnumType $userType, SchemaConnection $schema, array $searchPath): array + { + if ($userType instanceof EnumType) { + $labels = $this->labels($userType->values); + + return ['readable' => $labels, 'writable' => $labels]; + } + + // A domain's NOT NULL does not stop outer joins from reading null, so only its base type is used. + return $this->convertPostgresType($userType->type, $schema, $searchPath); + } + + /** + * Get the types of a PostgreSQL built-in type, or null when it is not one. + * + * @return null|array{readable: Type, writable: Type} + */ + private function convertPostgresBuiltInType(string $name): ?array + { + $readable = match (true) { + $name === 'bool' => new BooleanType, + in_array($name, ['int2', 'int4', 'int8'], true) => new IntegerType, + in_array($name, ['float4', 'float8'], true) => new FloatType, + // Unlike upstream's float type, numeric fetches as a string, which can hold NaN and Infinity. + $name === 'numeric' => new StringType, + // Unlike upstream's string type, PDO fetches bytea as a stream. + $name === 'bytea' => new ResourceType, + in_array($name, self::POSTGRES_TEXT_TYPES, true) => new StringType, + default => null, + }; + + return $readable === null ? null : [ + 'readable' => $readable, + 'writable' => match ($name) { + 'numeric' => $this->decimalInput(new StringType), + 'bytea' => new StringType, + default => $readable, + }, + ]; + } + + /** + * Get the types of a SQLite column from its declared type name. + * + * The types describe the values the framework writes, since SQLite stores any value in + * a column unless the table is STRICT. + * + * @return array{readable: Type, writable: Type} + */ + private function convertSQLiteType(ColumnType $type): array + { + $name = $type->name; + + // Type names follow SQLite's affinity rules, except those whose framework writes are strings or integers. + $readable = match (true) { + in_array($name, ['date', 'datetime', 'time', 'timestamp'], true) => new StringType, + // Numeric affinity stores a JSON number as an integer or a real, which fetches as a PHP number. + in_array($name, ['json', 'jsonb'], true) => TypeCombinator::union(new FloatType, new IntegerType, new StringType), + in_array($name, ['bool', 'boolean'], true) || str_contains($name, 'int') => new IntegerType, + str_contains($name, 'char') || str_contains($name, 'clob') || str_contains($name, 'text') => new StringType, + str_contains($name, 'blob') => new StringType, + str_contains($name, 'real') || str_contains($name, 'floa') || str_contains($name, 'doub') => new FloatType, + // Unlike upstream's float type, numeric affinity stores whole numbers as integers. + in_array($name, ['numeric', 'decimal'], true) => null, + // Other names, and a column without a type, keep whatever value was written. + default => new MixedType, + }; + + if ($readable === null) { + return [ + 'readable' => TypeCombinator::union(new FloatType, new IntegerType), + 'writable' => $this->decimalInput($this->numericString()), + ]; + } + + return [ + 'readable' => $readable, + 'writable' => $this->isBooleanColumn($type) ? TypeCombinator::union(new BooleanType, new IntegerType) : $readable, + ]; + } + + /** + * Determine whether an integer column holds a boolean, as Blueprint's boolean() declares it. + */ + private function isBooleanColumn(ColumnType $type): bool + { + return in_array($type->name, ['bool', 'boolean'], true) + || ($type->name === 'tinyint' && $type->modifiers === ['1']); + } + + /** + * Convert a fetched type to the strings ATTR_STRINGIFY_FETCHES returns. + */ + private function stringify(Type $type, string $driver): Type + { + return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($driver): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + return match (true) { + $type->isInteger()->yes() => $this->numericString(), + // MySQL doubles cannot hold NaN or infinity, unlike PostgreSQL and SQLite floats. + $type->isFloat()->yes() => in_array($driver, ['mysql', 'mariadb'], true) ? $this->numericString() : new StringType, + $type->isBoolean()->yes() => TypeCombinator::union(new ConstantStringType('0'), new ConstantStringType('1')), + default => $type, + }; + }); + } + + /** + * Get the literal union of a column's allowed values. + * + * @param list $values + */ + private function labels(array $values): Type + { + return $values === [] + ? new StringType + : TypeCombinator::union(...array_map(static fn (string $value): Type => new ConstantStringType($value), $values)); + } + + /** + * Get the types a decimal column accepts besides the string it fetches. + */ + private function decimalInput(Type $string): Type + { + return TypeCombinator::union($string, new IntegerType, new FloatType); + } + + /** + * Get the types of a column whose values are unknown. + * + * @return array{readable: Type, writable: Type} + */ + private function unknown(): array + { + return ['readable' => new MixedType, 'writable' => new MixedType]; + } + + /** + * Get the numeric-string type. + */ + private function numericString(): Type + { + return new IntersectionType([new StringType, new AccessoryNumericStringType]); + } +} diff --git a/src/phpstan/src/Properties/SchemaAggregator.php b/src/phpstan/src/Properties/SchemaAggregator.php new file mode 100644 index 000000000..3c30f20b2 --- /dev/null +++ b/src/phpstan/src/Properties/SchemaAggregator.php @@ -0,0 +1,2192 @@ +, bool, ?string} + * @phpstan-type TableScope array{connection: ConnectionConfig, key: string, variable: string} + * @phpstan-type BuilderVariables array{builders: array, escaped: array>} + * @phpstan-type Scope array{class: PhpParser\Node\Stmt\Class_, connection: ?ConnectionConfig, table: ?TableScope, builders: array, escaped: array>} + * + * @see https://github.com/psalm/laravel-psalm-plugin/blob/master/src/SchemaAggregator.php + */ +final class SchemaAggregator +{ + /** Schema builder methods that change no relations or column types. withoutForeignKeyConstraints() runs a callback that is applied on its own. */ + private const array IGNORED_SCHEMA_METHODS = [ + 'createdatabase', 'defaultstringlength', 'defaulttimeprecision', 'disableforeignkeyconstraints', + 'enableforeignkeyconstraints', 'getcolumn', 'getcolumnlisting', 'getcolumns', 'getcolumntype', 'getconnection', + 'getcurrentschemalisting', 'getcurrentschemaname', 'getforeignkeys', 'getindexes', 'getindexlisting', + 'getpartitions', 'getschemas', 'gettablelisting', 'gettables', 'gettypes', 'getviews', 'hascolumn', + 'hascolumns', 'hasforeignkey', 'hasindex', 'hastable', 'hasview', 'parseschemaandtable', 'qualifyindexname', + 'truncatetables', 'withoutcoveredpartitions', 'withoutforeignkeyconstraints', + ]; + + /** Schema builder methods that change one table, by the position and name of their table parameter. */ + private const array TABLE_SCHEMA_METHODS = [ + 'create' => [0, 'table'], 'createrangepartition' => [1, 'partition'], 'drop' => [0, 'table'], + 'dropcolumns' => [0, 'table'], 'dropifexists' => [0, 'table'], 'table' => [0, 'table'], + 'whentabledoesnthavecolumn' => [0, 'table'], 'whentabledoesnthaveindex' => [0, 'table'], + 'whentablehascolumn' => [0, 'table'], 'whentablehasindex' => [0, 'table'], + ]; + + /** Schema builder methods that change the key type morphs() adds. */ + private const array MORPH_KEY_SCHEMA_METHODS = ['defaultmorphkeytype', 'morphusingulids', 'morphusinguuids']; + + /** Blueprint methods that add one column, with the Blueprint type they add. */ + private const array COLUMN_METHODS = [ + 'bigincrements' => 'biginteger', 'biginteger' => 'biginteger', 'binary' => 'binary', 'boolean' => 'boolean', + 'char' => 'char', 'date' => 'date', 'datetime' => 'datetime', 'datetimetz' => 'datetimetz', + 'decimal' => 'decimal', 'double' => 'double', 'enum' => 'enum', 'float' => 'float', 'foreignid' => 'biginteger', + 'foreignulid' => 'char', 'foreignuuid' => 'uuid', 'geography' => 'geography', 'geometry' => 'geometry', + 'id' => 'biginteger', 'increments' => 'integer', 'integer' => 'integer', 'integerincrements' => 'integer', + 'ipaddress' => 'ipaddress', 'json' => 'json', 'jsonb' => 'jsonb', 'longbinary' => 'longbinary', + 'longtext' => 'longtext', 'macaddress' => 'macaddress', 'mediumbinary' => 'mediumbinary', + 'mediumincrements' => 'mediuminteger', 'mediuminteger' => 'mediuminteger', 'mediumtext' => 'mediumtext', + 'rawcolumn' => 'raw', 'remembertoken' => 'string', 'set' => 'set', 'smallincrements' => 'smallinteger', + 'smallinteger' => 'smallinteger', 'softdeletes' => 'timestamp', 'softdeletesdatetime' => 'datetime', + 'softdeletestz' => 'timestamptz', 'string' => 'string', 'text' => 'text', 'time' => 'time', + 'timestamp' => 'timestamp', 'timestamptz' => 'timestamptz', 'timetz' => 'timetz', + 'tinyincrements' => 'tinyinteger', 'tinyinteger' => 'tinyinteger', 'tinytext' => 'tinytext', + 'tsvector' => 'tsvector', 'ulid' => 'char', 'unsignedbiginteger' => 'biginteger', + 'unsignedinteger' => 'integer', 'unsignedmediuminteger' => 'mediuminteger', + 'unsignedsmallinteger' => 'smallinteger', 'unsignedtinyinteger' => 'tinyinteger', 'uuid' => 'uuid', + 'vector' => 'vector', 'year' => 'year', + ]; + + /** The column names Blueprint methods use when none is given. */ + private const array DEFAULT_COLUMN_NAMES = [ + 'id' => 'id', 'ipaddress' => 'ip_address', 'macaddress' => 'mac_address', 'remembertoken' => 'remember_token', + 'softdeletes' => 'deleted_at', 'softdeletesdatetime' => 'deleted_at', 'softdeletestz' => 'deleted_at', + 'ulid' => 'ulid', 'uuid' => 'uuid', + ]; + + /** Blueprint methods that add an unsigned column. */ + private const array UNSIGNED_COLUMN_METHODS = [ + 'bigincrements', 'foreignid', 'id', 'increments', 'integerincrements', 'mediumincrements', 'smallincrements', + 'tinyincrements', 'unsignedbiginteger', 'unsignedinteger', 'unsignedmediuminteger', 'unsignedsmallinteger', + 'unsignedtinyinteger', + ]; + + /** Blueprint methods whose third parameter decides whether the column is unsigned. */ + private const array SIGNABLE_COLUMN_METHODS = ['biginteger', 'integer', 'mediuminteger', 'smallinteger', 'tinyinteger']; + + /** Blueprint methods that add a nullable column. */ + private const array NULLABLE_COLUMN_METHODS = ['remembertoken', 'softdeletes', 'softdeletesdatetime', 'softdeletestz']; + + /** Blueprint methods that change no columns. */ + private const array IGNORED_BLUEPRINT_METHODS = [ + 'charset', 'collation', 'comment', 'create', 'dropforeign', 'dropfulltext', 'dropindex', 'dropprimary', + 'dropspatialindex', 'dropunique', 'dropvectorindex', 'engine', 'foreign', 'fulltext', 'index', 'innodb', + 'partitionbyrange', 'primary', 'rawindex', 'renameindex', 'spatialindex', 'temporary', 'unique', 'vectorindex', + ]; + + /** + * The type names each driver's schema grammar creates Blueprint types with, by Blueprint type. + * + * The grammar is not run: MySQL's date and year types read the live server version. Spatial, + * vector and raw types have no fixed name, so their columns are unknown. + */ + private const array GRAMMAR_TYPES = [ + 'mysql' => [ + 'biginteger' => 'bigint', 'binary' => 'blob', 'boolean' => 'tinyint', 'char' => 'char', 'date' => 'date', + 'datetime' => 'datetime', 'datetimetz' => 'datetime', 'decimal' => 'decimal', 'double' => 'double', + 'enum' => 'enum', 'float' => 'float', 'integer' => 'int', 'ipaddress' => 'varchar', 'json' => 'json', + 'jsonb' => 'json', 'longbinary' => 'longblob', 'longtext' => 'longtext', 'macaddress' => 'varchar', + 'mediumbinary' => 'mediumblob', 'mediuminteger' => 'mediumint', 'mediumtext' => 'mediumtext', 'set' => 'set', + 'smallinteger' => 'smallint', 'string' => 'varchar', 'text' => 'text', 'time' => 'time', 'timetz' => 'time', + 'timestamp' => 'timestamp', 'timestamptz' => 'timestamp', 'tinyinteger' => 'tinyint', + 'tinytext' => 'tinytext', 'uuid' => 'char', 'year' => 'year', + ], + 'pgsql' => [ + 'biginteger' => 'int8', 'binary' => 'bytea', 'boolean' => 'bool', 'char' => 'bpchar', 'date' => 'date', + 'datetime' => 'timestamp', 'datetimetz' => 'timestamptz', 'decimal' => 'numeric', 'double' => 'float8', + 'enum' => 'varchar', 'float' => 'float8', 'integer' => 'int4', 'ipaddress' => 'inet', 'json' => 'json', + 'jsonb' => 'jsonb', 'longbinary' => 'bytea', 'longtext' => 'text', 'macaddress' => 'macaddr', + 'mediumbinary' => 'bytea', 'mediuminteger' => 'int4', 'mediumtext' => 'text', 'smallinteger' => 'int2', + 'string' => 'varchar', 'text' => 'text', 'time' => 'time', 'timetz' => 'timetz', 'timestamp' => 'timestamp', + 'timestamptz' => 'timestamptz', 'tinyinteger' => 'int2', 'tinytext' => 'varchar', 'tsvector' => 'tsvector', + 'uuid' => 'uuid', 'year' => 'int4', + ], + 'sqlite' => [ + 'biginteger' => 'integer', 'binary' => 'blob', 'boolean' => 'tinyint', 'char' => 'varchar', 'date' => 'date', + 'datetime' => 'datetime', 'datetimetz' => 'datetime', 'decimal' => 'numeric', 'double' => 'double', + 'enum' => 'varchar', 'float' => 'float', 'integer' => 'integer', 'ipaddress' => 'varchar', 'json' => 'text', + 'jsonb' => 'text', 'longbinary' => 'blob', 'longtext' => 'text', 'macaddress' => 'varchar', + 'mediumbinary' => 'blob', 'mediuminteger' => 'integer', 'mediumtext' => 'text', 'smallinteger' => 'integer', + 'string' => 'varchar', 'text' => 'text', 'time' => 'time', 'timetz' => 'time', 'timestamp' => 'datetime', + 'timestamptz' => 'datetime', 'tinyinteger' => 'integer', 'tinytext' => 'text', 'uuid' => 'varchar', + 'year' => 'integer', + ], + ]; + + /** Whether an earlier migration could have changed any connection, so connections first seen later are incomplete. */ + private bool $invalidated = false; + + /** The type of the key columns morphs() adds, or null when a migration changed it to an unknown value. */ + private ?string $morphKeyType; + + /** @var array the helper methods being applied, to stop recursion */ + private array $appliedMethods = []; + + /** @var WeakMap the builder variables of each function applied */ + private WeakMap $builderVariables; + + /** + * @var array the PHP files besides the migrations that the facts were read from, such as + * a constant naming a table; those read for one table's columns are its sources + */ + public array $sources = []; + + /** + * Create a new schema aggregator instance. + * + * @param array $connections the schema facts, by owning connection + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ConnectionConfigResolver $connectionConfigResolver, + public array $connections = [], + ) { + $this->morphKeyType = Builder::$defaultMorphKeyType; + $this->builderVariables = new WeakMap; + } + + /** + * Apply the migration classes a file declares. + * + * @param array $stmts + */ + public function addStatements(array $stmts): void + { + $nodeFinder = new NodeFinder; + + /** @var PhpParser\Node\Stmt\Class_[] $classes */ + $classes = $nodeFinder->findInstanceOf($stmts, PhpParser\Node\Stmt\Class_::class); + + foreach ($classes as $stmt) { + $this->addClassStatements($stmt); + } + } + + /** + * Make every connection's facts unknown, for a migration that cannot be read. + */ + public function invalidate(): void + { + foreach ($this->connections as $connection) { + $connection->forget(); + } + + $this->invalidated = true; + } + + /** + * Apply a migration class's up() method. + * + * Unlike upstream, which applied every method but down() in declaration order, helper methods + * are applied where up() calls them. + */ + private function addClassStatements(PhpParser\Node\Stmt\Class_ $class): void + { + $up = $class->getMethod('up'); + + if ($up === null || $up->stmts === null) { + return; + } + + $scope = ['class' => $class, 'connection' => $this->getMigrationConnection($class), 'table' => null, 'builders' => [], 'escaped' => []]; + + $this->applyBody($up->stmts, $this->getFunctionScope($up, $scope)); + } + + /** + * Apply a whole body, joining the outcomes that returned early with the one that reached its end. + * + * @param PhpParser\Node\Stmt[] $stmts + * @param Scope $scope + */ + private function applyBody(array $stmts, array $scope): void + { + [$continues, $returned] = $this->applyStatements($stmts, $scope); + + // Without an early return, the outcome that reached the end is already current. + if ($returned !== null) { + $this->restore($continues ? $this->mergeStates($returned, $this->snapshot()) : $returned); + } + } + + /** + * Get the connection a migration's statements run on by default, or null when it is unknown. + * + * Migrator::runMethod() makes the routed connection of the migration's own connection, or the + * default connection, the default while the migration runs. + */ + private function getMigrationConnection(PhpParser\Node\Stmt\Class_ $class): ?ConnectionConfig + { + [$known, $name] = $this->evaluateGetConnection($class); + + if (! $known) { + return null; + } + + // The migrator takes an empty name for the default connection. + $name = $name === null || $name === '' ? $this->connectionConfigResolver->getDefaultConnectionName() : $name; + $connection = $name === null ? null : $this->connectionConfigResolver->connection($name); + + return $connection === null ? null : $this->connectionConfigResolver->connection($connection->owner); + } + + /** + * Evaluate the connection name a migration's getConnection() returns: whether it is known, and the name. + * + * Only an override that returns one expression is read. An inherited method is read only when it + * is Migration's own, which returns the connection property. + * + * @return array{bool, ?string} + */ + private function evaluateGetConnection(PhpParser\Node\Stmt\Class_ $class): array + { + $method = $class->getMethod('getConnection'); + + if ($method === null) { + return $this->inheritsFromMigration($class, 'getConnection') ? $this->evaluateConnectionProperty($class) : [false, null]; + } + + $stmts = $method->stmts ?? []; + + if (count($stmts) !== 1 || ! $stmts[0] instanceof PhpParser\Node\Stmt\Return_ || $stmts[0]->expr === null) { + return [false, null]; + } + + [$known, $name] = $this->evaluateConnectionName($stmts[0]->expr, $class, false); + + // A string return type throws on null rather than selecting the default connection. + if ($name === null && $method->returnType instanceof PhpParser\Node\Identifier && $method->returnType->toLowerString() === 'string') { + return [false, null]; + } + + return [$known, $name]; + } + + /** + * Evaluate a migration's connection property: whether its value is known, and the value. + * + * Its declared default is read, or Migration's own null default when it is inherited from Migration. + * + * @return array{bool, ?string} + */ + private function evaluateConnectionProperty(PhpParser\Node\Stmt\Class_ $class): array + { + foreach ($class->getProperties() as $property) { + foreach ($property->props as $prop) { + if ($prop->name->toString() === 'connection') { + return match (true) { + $prop->default === null || $this->isNullLiteral($prop->default) => [true, null], + $prop->default instanceof PhpParser\Node\Scalar\String_ => [true, $prop->default->value], + default => [false, null], + }; + } + } + } + + return $this->inheritsFromMigration($class, 'connection') ? [true, null] : [false, null]; + } + + /** + * Determine whether a class inherits a method, or the property when no method is named, from + * Migration itself rather than from a class between them. + */ + private function inheritsFromMigration(PhpParser\Node\Stmt\Class_ $class, string $member): bool + { + if ($class->extends === null || ! $this->reflectionProvider->hasClass($class->extends->toString())) { + return false; + } + + $parent = $this->reflectionProvider->getClass($class->extends->toString()); + + $this->addSources(SourceFiles::forClass($parent, Migration::class), null); + + $declaringClass = $member === 'connection' + ? ($parent->hasNativeProperty($member) ? $parent->getNativeProperty($member)->getDeclaringClass() : null) + : ($parent->hasNativeMethod($member) ? $parent->getNativeMethod($member)->getDeclaringClass() : null); + + return $declaringClass?->getName() === Migration::class; + } + + /** + * Evaluate a connection name expression: whether its value is known, and the value. + * + * Only string and null literals, the migration's connection property and getConnection(), config + * reads with a literal key and an evaluated default, and null coalescing of these are evaluated. + * Coalescing evaluates its right side only when the left one is null, as it runs. + * + * @param bool $method whether getConnection() can be evaluated, which is not the case inside it + * @return array{bool, ?string} + */ + private function evaluateConnectionName(PhpParser\Node\Expr $expr, PhpParser\Node\Stmt\Class_ $class, bool $method = true): array + { + if ($expr instanceof PhpParser\Node\Scalar\String_) { + return [true, $expr->value]; + } + + if ($this->isNullLiteral($expr)) { + return [true, null]; + } + + if ($expr instanceof PhpParser\Node\Expr\BinaryOp\Coalesce) { + $left = $this->evaluateConnectionName($expr->left, $class, $method); + + return $left[0] && $left[1] === null ? $this->evaluateConnectionName($expr->right, $class, $method) : $left; + } + + if ($expr instanceof PhpParser\Node\Expr\PropertyFetch && $this->isThis($expr->var) + && $expr->name instanceof PhpParser\Node\Identifier && $expr->name->toString() === 'connection') { + return $this->evaluateConnectionProperty($class); + } + + if ($method && $expr instanceof PhpParser\Node\Expr\MethodCall && $this->isThis($expr->var) + && $expr->name instanceof PhpParser\Node\Identifier && $expr->name->toLowerString() === 'getconnection' + && $expr->getArgs() === [] && ! $expr->isFirstClassCallable()) { + return $this->evaluateGetConnection($class); + } + + // config('key'), config()->get('key') or config()->string('key'), and the same Config facade methods. + $repository = ($expr instanceof PhpParser\Node\Expr\MethodCall && $expr->var instanceof PhpParser\Node\Expr\FuncCall + && $this->isConfigFunction($expr->var) && $expr->var->getArgs() === []) + || ($expr instanceof PhpParser\Node\Expr\StaticCall && $this->isFacade($expr->class, Config::class)); + $read = match (true) { + $expr instanceof PhpParser\Node\Expr\FuncCall && $this->isConfigFunction($expr) => 'get', + $repository && $expr->name instanceof PhpParser\Node\Identifier => $expr->name->toLowerString(), + default => null, + }; + + if (! in_array($read, ['get', 'string'], true)) { + return [false, null]; + } + + /** @var PhpParser\Node\Expr\FuncCall|PhpParser\Node\Expr\MethodCall|PhpParser\Node\Expr\StaticCall $expr */ + $key = $this->getArgument($expr, 0, 'key'); + + if (! $key instanceof PhpParser\Node\Scalar\String_) { + return [false, null]; + } + + [$present, $value] = $this->connectionConfigResolver->getConfiguredName($key->value); + + if (! $present) { + $default = $this->getArgument($expr, 1, 'default'); + [$known, $value] = $default === null ? [true, null] : $this->evaluateConnectionName($default, $class, $method); + + if (! $known) { + return [false, null]; + } + } + + // A value that is not a name is unknown, and Repository::string() throws for anything but a string. + return $value === false || ($read === 'string' && $value === null) ? [false, null] : [true, $value]; + } + + /** + * Determine whether a call is to the global config() helper. + */ + private function isConfigFunction(PhpParser\Node\Expr\FuncCall $call): bool + { + return $call->name instanceof PhpParser\Node\Name && $call->name->toLowerString() === 'config' && ! $call->isFirstClassCallable(); + } + + /** + * Determine whether an expression is $this. + */ + private function isThis(PhpParser\Node\Expr $expr): bool + { + return $expr instanceof PhpParser\Node\Expr\Variable && $expr->name === 'this'; + } + + /** + * Apply a body of statements, following branches and early returns. + * + * Each outcome of a branch applies to its own copy of the facts, and the copies merge + * where the outcomes join. Other control flow makes the facts it could affect unknown. + * + * @param PhpParser\Node\Stmt[] $stmts + * @param Scope $scope + * + * @return array{bool, ?State} whether an outcome reaches the end, which leaves its facts current, + * and the joined facts of the outcomes that returned early + */ + private function applyStatements(array $stmts, array $scope): array + { + $returned = null; + + foreach ($stmts as $stmt) { + $expr = $stmt instanceof PhpParser\Node\Stmt\Expression || $stmt instanceof PhpParser\Node\Stmt\Return_ ? $stmt->expr : null; + + if ($expr !== null) { + if ($scope['table'] === null) { + $this->applyExpression($expr, $scope); + } else { + $this->processColumnUpdate($expr, $scope); + } + } + + if ($stmt instanceof PhpParser\Node\Stmt\Return_) { + return [false, $this->mergeStates($returned, $this->snapshot())]; + } + + if ($stmt instanceof PhpParser\Node\Stmt\If_ || $stmt instanceof PhpParser\Node\Stmt\Switch_) { + [$continuing, $branchReturned] = $this->applyBranches($stmt, $scope); + $returned = $this->mergeStates($returned, $branchReturned); + + if ($continuing === null) { + return [false, $returned]; + } + + $this->restore($continuing); + } elseif (! $stmt instanceof PhpParser\Node\Stmt\Expression && ! $stmt instanceof PhpParser\Node\Stmt\Nop) { + $this->forgetAffectedBy($stmt, $scope); + } + } + + return [true, $returned]; + } + + /** + * Apply each outcome of an if or switch statement to its own copy of the facts. + * + * A condition decided from established facts skips the outcomes it rules out. + * + * @param Scope $scope + * + * @return array{?State, ?State} the joined facts of the outcomes that continue, and of those that returned + */ + private function applyBranches(PhpParser\Node\Stmt\If_|PhpParser\Node\Stmt\Switch_ $stmt, array $scope): array + { + $this->forgetAffectedBy($stmt->cond, $scope); + + $initial = $this->snapshot(); + $continuing = $returned = null; + $fallthrough = true; + + if ($stmt instanceof PhpParser\Node\Stmt\Switch_) { + $branches = []; + + // A case without a break falls into the next one, so each case runs the statements up to a break. + foreach ($stmt->cases as $index => $case) { + $body = []; + + foreach ($stmt->cases as $following => $next) { + if ($following < $index) { + continue; + } + + foreach ($next->stmts as $caseStmt) { + if ($caseStmt instanceof PhpParser\Node\Stmt\Break_) { + break 2; + } + + $body[] = $caseStmt; + } + } + + $branches[] = [null, $body]; + $fallthrough = $fallthrough && $case->cond !== null; + } + } else { + $branches = [[$stmt->cond, $stmt->stmts]]; + + foreach ($stmt->elseifs as $elseif) { + $branches[] = [$elseif->cond, $elseif->stmts]; + } + + if ($stmt->else !== null) { + $branches[] = [null, $stmt->else->stmts]; + $fallthrough = false; + } + } + + foreach ($branches as [$condition, $body]) { + $this->restore($initial); + $decision = $condition === null ? null : $this->decide($condition, $scope); + + if ($decision === false) { + continue; + } + + [$continues, $branchReturned] = $this->applyStatements($body, $scope); + $returned = $this->mergeStates($returned, $branchReturned); + $continuing = $continues ? $this->mergeStates($continuing, $this->snapshot()) : $continuing; + + // A condition that is certainly true rules out the later outcomes. + if ($decision === true && ! $stmt instanceof PhpParser\Node\Stmt\Switch_) { + $fallthrough = false; + + break; + } + } + + if ($fallthrough) { + $continuing = $this->mergeStates($continuing, $initial); + } + + return [$continuing, $returned]; + } + + /** + * Decide a Schema::hasTable() or hasColumn() guard from established facts, or return null when it is unknown. + * + * Only a known connection and a complete table decide it, also through a selected builder, + * negation and comparison with true or false. + * + * @param Scope $scope + */ + private function decide(PhpParser\Node\Expr $condition, array $scope): ?bool + { + if ($condition instanceof PhpParser\Node\Expr\BooleanNot) { + $decision = $this->decide($condition->expr, $scope); + + return $decision === null ? null : ! $decision; + } + + if ($condition instanceof PhpParser\Node\Expr\BinaryOp\Identical || $condition instanceof PhpParser\Node\Expr\BinaryOp\NotIdentical) { + foreach ([[$condition->left, $condition->right], [$condition->right, $condition->left]] as [$guard, $constant]) { + if ($constant instanceof PhpParser\Node\Expr\ConstFetch && in_array($constant->name->toLowerString(), ['true', 'false'], true)) { + $decision = $this->decide($guard, $scope); + + if ($decision === null) { + return null; + } + + $expected = $constant->name->toLowerString() === 'true'; + + return ($decision === $expected) === $condition instanceof PhpParser\Node\Expr\BinaryOp\Identical; + } + } + + return null; + } + + $call = $this->getSchemaCall($condition, $scope); + + if ($call === null || ! in_array($call['method'], ['hastable', 'hascolumn'], true) || $call['connection'] === null) { + return null; + } + + return $this->decideGuard( + $call['connection'], + $this->getNameArgument($call['call'], 0, 'table'), + $call['method'] === 'hascolumn' ? $this->getNameArgument($call['call'], 1, 'column') : null, + $call['method'] === 'hascolumn', + ); + } + + /** + * Decide whether a table, or a column of it, exists from established facts, or return null when it is unknown. + */ + private function decideGuard(ConnectionConfig $connection, ?string $tableName, ?string $columnName, bool $checksColumn): ?bool + { + if ($tableName === null) { + return null; + } + + [$schema, $name] = $connection->physicalTable($tableName); + $owner = $this->owner($connection); + $key = $this->findTableKey($connection, $schema, $name); + $table = is_string($key) ? $owner->tables[$key] : null; + + // The outcome the guard selects can change any table, so the files the table's facts were read from + // become sources of every fact. + if ($table !== null) { + $this->sources += $table->sources; + } + + // Both guards can match names in another letter case, depending on the database. + if (! $checksColumn) { + return match (true) { + $table !== null && $table->complete => true, + $key === null && $owner->complete + && ! array_any($owner->tables, static fn (SchemaTable $known): bool => mb_strtolower($known->name) === mb_strtolower($name)) => false, + default => null, + }; + } + + if ($columnName === null || $table === null || ! $table->complete) { + return null; + } + + $matches = array_filter($table->columns, static fn (SchemaColumn $column): bool => mb_strtolower($column->name) === mb_strtolower($columnName)); + + // A column of unknown type may be one that only some earlier outcomes added. + return match (true) { + $matches === [] => false, + array_any($matches, static fn (SchemaColumn $column): bool => $column->type !== null) => true, + default => null, + }; + } + + /** + * Apply an expression statement of a migration body. + * + * @param Scope $scope + */ + private function applyExpression(PhpParser\Node\Expr $expr, array $scope): void + { + // A first-class callable created as a statement is discarded without running. + if ($expr instanceof PhpParser\Node\Expr\CallLike && $expr->isFirstClassCallable()) { + return; + } + + $call = $this->getSchemaCall($expr, $scope); + + if ($call !== null && in_array($call['method'], self::MORPH_KEY_SCHEMA_METHODS, true)) { + $this->morphKeyType = match ($call['method']) { + 'morphusinguuids' => 'uuid', + 'morphusingulids' => 'ulid', + default => $this->getNameArgument($call['call'], 0, 'type'), + }; + + return; + } + + if ($call !== null && $call['connection'] !== null) { + switch ($call['method']) { + case 'create': + case 'table': + $this->alterTable($call['call'], $call['connection'], $call['method'] === 'create', $scope); + + return; + case 'drop': + case 'dropifexists': + $this->dropTable($call['call'], $call['connection']); + + return; + case 'rename': + $this->renameTableThroughStaticCall($call['call'], $call['connection']); + + return; + case 'withoutforeignkeyconstraints': + // The callback's Schema calls still run on the default connection. + $this->applyCallback($call['call'], 0, 'callback', $scope); + + return; + case 'whentablehascolumn': + case 'whentabledoesnthavecolumn': + case 'whentablehasindex': + case 'whentabledoesnthaveindex': + $this->applyConditionalTable($call['call'], $call['connection'], $call['method'], $scope); + + return; + } + } + + $statement = $this->literalDatabaseStatement($expr, $scope); + + if ($statement !== null && $statement[0] !== null && $statement[1] !== null) { + $this->applyLiteralSql($statement[0], $statement[1]); + + return; + } + + $method = $expr instanceof PhpParser\Node\Expr\MethodCall || $expr instanceof PhpParser\Node\Expr\StaticCall + ? $this->getHelperMethod($expr, $scope['class']) + : null; + + if ($method !== null && ! isset($this->appliedMethods[$method->name->toLowerString()])) { + // The arguments run before the helper, which receives them in parameters it does not follow. + $this->forgetAffectedBy($expr->getRawArgs(), $scope); + + $this->appliedMethods[$method->name->toLowerString()] = true; + $this->applyBody($method->stmts ?? [], $this->getFunctionScope($method, $scope)); + unset($this->appliedMethods[$method->name->toLowerString()]); + + return; + } + + $this->forgetAffectedBy($expr, $scope); + } + + /** + * Apply a Schema::whenTable...() helper, which runs table() with its callback when its guard holds. + * + * Index guards are never decided, since indexes are not tracked, so both outcomes are joined. + * + * @param Scope $scope + */ + private function applyConditionalTable(PhpParser\Node\Expr\CallLike $call, ConnectionConfig $connection, string $method, array $scope): void + { + $decision = str_contains($method, 'column') + ? $this->decideGuard($connection, $this->getNameArgument($call, 0, 'table'), $this->getNameArgument($call, 1, 'column'), true) + : null; + + if ($decision !== null && str_contains($method, 'doesnt')) { + $decision = ! $decision; + } + + if ($decision === false) { + return; + } + + $skipped = $decision === null ? $this->snapshot() : null; + + $this->alterTable($call, $connection, false, $scope, 2); + + if ($skipped !== null) { + $this->restore($this->mergeStates($this->snapshot(), $skipped)); + } + } + + /** + * Get the migration's own method a call on $this, self or static runs, which up() runs inline. + */ + private function getHelperMethod(PhpParser\Node\Expr\MethodCall|PhpParser\Node\Expr\StaticCall $call, PhpParser\Node\Stmt\Class_ $class): ?PhpParser\Node\Stmt\ClassMethod + { + $own = $call instanceof PhpParser\Node\Expr\MethodCall + ? $this->isThis($call->var) + : $call->class instanceof PhpParser\Node\Name && in_array($call->class->toLowerString(), ['self', 'static'], true); + + return $own && $call->name instanceof PhpParser\Node\Identifier ? $class->getMethod($call->name->toString()) : null; + } + + /** + * Apply the body of a callback argument, or make what it could change unknown. + * + * @param Scope $scope + */ + private function applyCallback(PhpParser\Node\Expr\CallLike $call, int $position, string $parameter, array $scope): void + { + $callback = $this->getArgument($call, $position, $parameter); + + // An arrow function reads the enclosing variables; a closure has its own. + if ($callback instanceof PhpParser\Node\Expr\ArrowFunction) { + $this->applyBody($this->getUpdateStatements($callback), $scope); + } elseif ($callback instanceof PhpParser\Node\Expr\Closure) { + $this->applyBody($this->getUpdateStatements($callback), $this->getFunctionScope($callback, $scope)); + } elseif ($callback !== null) { + $this->forgetAffectedBy($callback, $scope); + } + } + + /** + * Get a call to a Schema builder method, through the Schema facade, a selector or a builder + * variable, with its connection. + * + * The connection is null when it is unknown. An explicit connection is not routed to its migration + * connection, but its facts belong to the connection that owns its schema. A first-class callable + * is included, since a kept one can run later with arguments that are not known. + * + * @param Scope $scope + * @return null|array{call: PhpParser\Node\Expr\MethodCall|PhpParser\Node\Expr\StaticCall, method: lowercase-string, connection: ?ConnectionConfig} + */ + private function getSchemaCall(PhpParser\Node\Expr $expr, array $scope): ?array + { + if (! ($expr instanceof PhpParser\Node\Expr\StaticCall || $expr instanceof PhpParser\Node\Expr\MethodCall) + || ! $expr->name instanceof PhpParser\Node\Identifier) { + return null; + } + + if ($expr instanceof PhpParser\Node\Expr\StaticCall) { + return $this->isFacade($expr->class, Schema::class) && $expr->name->toLowerString() !== 'connection' + ? ['call' => $expr, 'method' => $expr->name->toLowerString(), 'connection' => $scope['connection']] + : null; + } + + if ($expr->var instanceof PhpParser\Node\Expr\Variable && is_string($expr->var->name) && array_key_exists($expr->var->name, $scope['builders'])) { + return ['call' => $expr, 'method' => $expr->name->toLowerString(), 'connection' => $scope['builders'][$expr->var->name]]; + } + + $selector = $this->getSchemaSelector($expr->var, $scope); + + return $selector === null ? null : ['call' => $expr, 'method' => $expr->name->toLowerString(), 'connection' => $selector['connection']]; + } + + /** + * Get the connection of an expression that selects a Schema builder, or null when it is not one. + * + * The selectors are Schema::connection(), DB::getSchemaBuilder() and DB::connection()->getSchemaBuilder(). + * The connection is null when it is unknown. + * + * @param Scope $scope + * @return null|array{connection: ?ConnectionConfig} + */ + private function getSchemaSelector(PhpParser\Node\Expr $expr, array $scope): ?array + { + return match (true) { + $this->isFacadeCall($expr, Schema::class, 'connection') => ['connection' => $this->getNamedConnection($expr, $scope)], + // A kept getSchemaBuilder() callable selects whichever connection is the default when it runs. + $this->isFacadeCall($expr, DB::class, 'getschemabuilder') => ['connection' => $expr->isFirstClassCallable() ? null : $scope['connection']], + $expr instanceof PhpParser\Node\Expr\MethodCall && $expr->name instanceof PhpParser\Node\Identifier + && $expr->name->toLowerString() === 'getschemabuilder' && $this->isFacadeCall($expr->var, DB::class, 'connection') => ['connection' => $expr->isFirstClassCallable() ? null : $this->getNamedConnection($expr->var, $scope)], + default => null, + }; + } + + /** + * Get the connection a Schema::connection() or DB::connection() call selects, or null when it is unknown. + * + * Without a name, it selects the default connection, which is the migration's own while it runs. + * A kept first-class callable can select any connection. + * + * @param Scope $scope + */ + private function getNamedConnection(PhpParser\Node\Expr\StaticCall $selector, array $scope): ?ConnectionConfig + { + if ($selector->isFirstClassCallable()) { + return null; + } + + $name = $this->getArgument($selector, 0, 'name'); + [$known, $value] = $name === null ? [true, null] : $this->evaluateConnectionName($name, $scope['class']); + + return match (true) { + ! $known => null, + // The database manager takes an empty name for the default connection. + $value === null || $value === '' => $scope['connection'], + default => $this->connectionConfigResolver->connection($value), + }; + } + + /** + * Get the scope of a function body the migration runs inline, with the builders its variables hold. + * + * @param Scope $scope + * @return Scope + */ + private function getFunctionScope(PhpParser\Node\Stmt\ClassMethod|PhpParser\Node\Expr\Closure $function, array $scope): array + { + $this->builderVariables[$function] ??= $this->getBuilderVariables($function, $scope); + + return $this->builderVariables[$function] + $scope; + } + + /** + * Get the local variables of a function that hold Schema builders. + * + * A variable holds a builder of one connection when every write to it is an assignment statement + * of a Schema selector, all selecting that connection, and every other use calls a method on it, + * also in nested functions. Other variables assigned a Schema or DB selector escape: what their + * uses run is unknown. A selector reaching a variable another way is not followed. + * + * @param Scope $scope + * @return BuilderVariables + */ + private function getBuilderVariables(PhpParser\Node\Stmt\ClassMethod|PhpParser\Node\Expr\Closure $function, array $scope): array + { + $stmts = $function->stmts ?? []; + $nodes = (new NodeFinder)->find($stmts, static fn (PhpParser\Node $node): bool => $node instanceof PhpParser\Node\Expr\Variable + || $node instanceof PhpParser\Node\Expr\MethodCall + || $node instanceof PhpParser\Node\Expr\Assign + || $node instanceof PhpParser\Node\Stmt\Expression); + + $allowed = []; + $statements = []; + $selections = []; + $qualifying = []; + + foreach ($nodes as $node) { + if ($node instanceof PhpParser\Node\Expr\MethodCall) { + $allowed[spl_object_id($node->var)] = true; + } elseif ($node instanceof PhpParser\Node\Stmt\Expression) { + $statements[spl_object_id($node->expr)] = true; + } + } + + foreach ($nodes as $node) { + if (! $node instanceof PhpParser\Node\Expr\Assign || ! $node->var instanceof PhpParser\Node\Expr\Variable || ! is_string($node->var->name)) { + continue; + } + + $selector = $this->getSchemaSelector($node->expr, $scope); + + // A kept selector callable is not a builder. + if ($selector !== null && isset($statements[spl_object_id($node)]) + && ! ($node->expr instanceof PhpParser\Node\Expr\CallLike && $node->expr->isFirstClassCallable())) { + $allowed[spl_object_id($node->var)] = true; + $qualifying[$node->var->name][] = $selector['connection']; + } elseif ($selector === null && $this->isFacadeCall($node->expr, DB::class, 'connection')) { + $selector = ['connection' => $this->getNamedConnection($node->expr, $scope)]; + } + + if ($selector !== null) { + $selections[$node->var->name][] = $selector['connection']; + } + } + + $disqualified = []; + + foreach ($nodes as $node) { + if ($node instanceof PhpParser\Node\Expr\Variable && is_string($node->name) && ! isset($allowed[spl_object_id($node)])) { + $disqualified[$node->name] = true; + } + } + + $variables = ['builders' => [], 'escaped' => []]; + + foreach ($selections as $name => $connections) { + $distinct = []; + + foreach ($connections as $connection) { + if (! in_array($connection, $distinct, true)) { + $distinct[] = $connection; + } + } + + if (! isset($disqualified[$name]) && count($qualifying[$name] ?? []) === count($connections) && count($distinct) === 1) { + $variables['builders'][$name] = $distinct[0]; + } else { + $variables['escaped'][$name] = $distinct; + } + } + + return $variables; + } + + /** + * Make the facts that uninterpretable code could change unknown. + * + * Schema changes to a named table make that table unknown; other changes make their connection + * unknown, and changes on an unknown connection make every connection unknown. The migration's + * own methods are searched too. Inside a Blueprint callback, any use of the Blueprint makes its + * table unknown. A selector whose builder or connection escapes, and any use of a variable holding + * one, make the connection it selects unknown. Only query chains on a DB connection are kept. + * + * @param PhpParser\Node|PhpParser\Node[] $node + * @param Scope $scope + */ + private function forgetAffectedBy(PhpParser\Node|array $node, array $scope): void + { + $nodes = (new NodeFinder)->find($node, static fn (PhpParser\Node $found): bool => $found instanceof PhpParser\Node\Expr\Variable + || $found instanceof PhpParser\Node\Expr\Assign + || $found instanceof PhpParser\Node\Expr\StaticCall + || $found instanceof PhpParser\Node\Expr\MethodCall); + + $calls = []; + // The selectors a builder variable or a recognized call consumes, and every receiver of a method call. + $consumed = []; + $receivers = []; + + foreach ($nodes as $found) { + if ($found instanceof PhpParser\Node\Expr\Variable && is_string($found->name)) { + if ($scope['table'] !== null && $found->name === $scope['table']['variable']) { + $this->forgetTableByKey($scope['table']['connection'], $scope['table']['key']); + } + + foreach ($scope['escaped'][$found->name] ?? [] as $connection) { + $this->forgetConnection($connection); + } + } elseif ($found instanceof PhpParser\Node\Expr\Assign) { + if ($found->var instanceof PhpParser\Node\Expr\Variable && is_string($found->var->name) && array_key_exists($found->var->name, $scope['builders'])) { + $consumed[spl_object_id($found->expr)] = true; + } + } elseif ($found instanceof PhpParser\Node\Expr\StaticCall || $found instanceof PhpParser\Node\Expr\MethodCall) { + $calls[] = $found; + + if ($found instanceof PhpParser\Node\Expr\MethodCall) { + $receivers[spl_object_id($found->var)] = true; + } + } + } + + foreach ($calls as $call) { + $helper = $this->getHelperMethod($call, $scope['class']); + + if ($helper !== null) { + if (! isset($this->appliedMethods[$helper->name->toLowerString()])) { + $this->appliedMethods[$helper->name->toLowerString()] = true; + $this->forgetAffectedBy($helper->stmts ?? [], ['table' => null] + $this->getFunctionScope($helper, $scope)); + unset($this->appliedMethods[$helper->name->toLowerString()]); + } + + continue; + } + + $schemaCall = $this->getSchemaCall($call, $scope); + + if ($schemaCall === null) { + $statement = $this->literalDatabaseStatement($call, $scope); + + if ($statement !== null) { + $this->forgetConnection($statement[0]); + } + + continue; + } + + if ($call instanceof PhpParser\Node\Expr\MethodCall) { + $consumed[spl_object_id($call->var)] = true; + } + + $method = $schemaCall['method']; + $connection = $schemaCall['connection']; + + if (in_array($method, self::MORPH_KEY_SCHEMA_METHODS, true)) { + $this->morphKeyType = null; + } elseif ($connection === null || ! isset(self::TABLE_SCHEMA_METHODS[$method])) { + if (! in_array($method, self::IGNORED_SCHEMA_METHODS, true)) { + $this->forgetConnection($connection); + } + } else { + $tableName = $this->getNameArgument($call, ...self::TABLE_SCHEMA_METHODS[$method]); + + if ($tableName === null) { + $this->owner($connection)->forget(); + } else { + $this->forgetTableByKey($connection, $this->resolveTable($connection, ...$connection->physicalTable($tableName))); + } + } + } + + foreach ($calls as $call) { + if (isset($consumed[spl_object_id($call)])) { + continue; + } + + $selector = $this->getSchemaSelector($call, $scope); + + if ($selector !== null) { + $this->forgetConnection($selector['connection']); + } elseif ($this->isFacadeCall($call, DB::class, 'connection') && ! isset($receivers[spl_object_id($call)])) { + $this->forgetConnection($this->getNamedConnection($call, $scope)); + } + } + } + + /** + * Make every fact of a connection unknown, or of every connection when it is unknown. + */ + private function forgetConnection(?ConnectionConfig $connection): void + { + if ($connection === null) { + $this->invalidate(); + } else { + $this->owner($connection)->forget(); + } + } + + /** + * Make a table unknown by its identity key, unless an earlier statement removed it. + */ + private function forgetTableByKey(ConnectionConfig $connection, string $key): void + { + $owner = $this->owner($connection); + $table = $owner->tables[$key] ?? null; + + if ($table !== null) { + $owner->forgetTable($table->schema, $table->name); + } + } + + /** + * Get the schema facts of a connection's owner, starting them when it has none yet. + * + * Migrations start from an empty database, unless an earlier migration could have changed any connection. + */ + private function owner(ConnectionConfig $connection): SchemaConnection + { + if (! isset($this->connections[$connection->owner])) { + $this->connections[$connection->owner] = new SchemaConnection($connection->owner, $connection->driver); + $this->connections[$connection->owner]->complete = ! $this->invalidated; + } + + return $this->connections[$connection->owner]; + } + + /** + * Find the identity key of the relation a physical name refers to on a connection. + * + * @return null|false|string null when it is absent, and false when it is ambiguous + */ + private function findTableKey(ConnectionConfig $connection, ?string $schema, string $name): false|string|null + { + return $this->owner($connection)->findTableKey($schema, $name, $connection->defaultSchema, $connection->searchPath); + } + + /** + * Get the identity key of the relation a changed physical name refers to, adding an unknown one when it is not known. + * + * An ambiguous name could refer to any of its candidates, so it makes the connection unknown. + */ + private function resolveTable(ConnectionConfig $connection, ?string $schema, string $name): string + { + $owner = $this->owner($connection); + $key = $this->findTableKey($connection, $schema, $name); + $this->forgetCaseVariants($connection, $schema, $name); + + if (is_string($key)) { + return $key; + } + + if ($key === false) { + $owner->forget(); + } + + $schema ??= $connection->definitionSchema(); + $owner->forgetTable($schema, $name); + $key = $owner->key($schema, $name); + $owner->tables[$key]->searchPath = $schema === null ? $connection->searchPath : null; + + return $key; + } + + /** + * Make the known relations that a change to a physical name could reach through letter case unknown. + */ + private function forgetCaseVariants(ConnectionConfig $connection, ?string $schema, string $name): void + { + foreach ($this->owner($connection)->caseVariants($schema ?? $connection->defaultSchema, $name) as $key) { + $this->forgetTableByKey($connection, $key); + } + } + + /** + * Copy the current facts. + * + * @return State + */ + private function snapshot(): array + { + return [array_map(static fn (SchemaConnection $connection): SchemaConnection => clone $connection, $this->connections), $this->invalidated, $this->morphKeyType]; + } + + /** + * Make a copy of earlier facts current. + * + * @param State $state + */ + private function restore(array $state): void + { + [$connections, $this->invalidated, $this->morphKeyType] = $state; + $this->connections = array_map(static fn (SchemaConnection $connection): SchemaConnection => clone $connection, $connections); + } + + /** + * Join the facts of two outcomes, keeping only what both establish. + * + * A connection missing from one outcome has its initial facts there, since no migration used it yet. + * + * @param ?State $first + * @param ?State $second + * + * @return ($first is null ? ($second is null ? null : State) : State) + */ + private function mergeStates(?array $first, ?array $second): ?array + { + if ($first === null || $second === null) { + return $first ?? $second; + } + + $connections = []; + + foreach ($first[0] + $second[0] as $name => $connection) { + $connections[$name] = $this->mergeConnections( + $first[0][$name] ?? $this->initialConnection($connection, $first[1]), + $second[0][$name] ?? $this->initialConnection($connection, $second[1]), + ); + } + + return [$connections, $first[1] || $second[1], $first[2] === $second[2] ? $first[2] : null]; + } + + /** + * Get the facts a connection starts with before any migration uses it. + */ + private function initialConnection(SchemaConnection $connection, bool $invalidated): SchemaConnection + { + $initial = new SchemaConnection($connection->name, $connection->driver); + $initial->complete = ! $invalidated; + + return $initial; + } + + /** + * Join the facts of a connection in two outcomes. + * + * A table in one outcome only may exist, so it is incomplete; its columns are unknown too unless + * the other outcome rules the table out. A column whose types differ, or that one outcome lacks, + * has an unknown type. A user type that one outcome lacks or defines differently is uncertain. + */ + private function mergeConnections(SchemaConnection $first, SchemaConnection $second): SchemaConnection + { + $merged = new SchemaConnection($first->name, $first->driver); + $merged->complete = $first->complete && $second->complete; + $merged->unusableDumps = $first->unusableDumps; + + foreach ($first->types + $second->types as $key => $type) { + $merged->types[$key] = array_key_exists($key, $first->types) && array_key_exists($key, $second->types) + && $first->types[$key] == $second->types[$key] ? $type : null; + } + + foreach ($first->tables + $second->tables as $key => $table) { + $firstTable = $first->tables[$key] ?? null; + $secondTable = $second->tables[$key] ?? null; + $table = clone $table; + $table->complete = $firstTable !== null && $secondTable !== null && $firstTable->complete && $secondTable->complete; + $table->sources = ($firstTable->sources ?? []) + ($secondTable->sources ?? []); + + if ($firstTable === null || $secondTable === null) { + if (! ($firstTable === null ? $first : $second)->complete) { + $table->columns = []; + } + } else { + foreach ($firstTable->columns + $secondTable->columns as $name => $column) { + $firstColumn = $firstTable->columns[$name] ?? null; + $secondColumn = $secondTable->columns[$name] ?? null; + + $table->columns[$name] = $firstColumn !== null && $secondColumn !== null + && $firstColumn->type == $secondColumn->type && $firstColumn->options === $secondColumn->options + ? new SchemaColumn($column->name, $column->type, $firstColumn->nullable || $secondColumn->nullable, $column->options) + : new SchemaColumn($column->name, null, true); + } + } + + $merged->tables[$key] = $table; + } + + return $merged; + } + + /** + * Apply Schema::create() or Schema::table() and the Blueprint callback it runs. + * + * A table changed without being known keeps the columns the callback establishes, but no claim + * about others. PostgreSQL changes its known descendants too. + * + * @param Scope $scope + * @param int $callbackPosition the position of the Blueprint callback parameter + */ + private function alterTable(PhpParser\Node\Expr\CallLike $call, ConnectionConfig $connection, bool $creating, array $scope, int $callbackPosition = 1): void + { + $owner = $this->owner($connection); + $tableName = $this->getNameArgument($call, 0, 'table'); + + if ($tableName === null) { + $owner->forget(); + + return; + } + + [$schema, $name] = $connection->physicalTable($tableName); + + if ($creating) { + $schema ??= $connection->definitionSchema(); + $key = $owner->key($schema, $name); + $owner->tables[$key] = new SchemaTable($name, $schema); + $owner->tables[$key]->searchPath = $schema === null ? $connection->searchPath : null; + } else { + $key = $this->resolveTable($connection, $schema, $name); + $owner->forgetDescendants($key); + } + + $callback = $this->getArgument($call, $callbackPosition, 'callback'); + $variable = $callback === null ? null : $this->getBlueprintVariable($callback); + + if ($variable === null) { + $this->forgetTableByKey($connection, $key); + + if ($callback !== null) { + $this->forgetAffectedBy($callback, $scope); + } + + return; + } + + /** @var PhpParser\Node\Expr\ArrowFunction|PhpParser\Node\Expr\Closure $callback */ + $this->applyBody( + $this->getUpdateStatements($callback), + ['table' => ['connection' => $connection, 'key' => $key, 'variable' => $variable]] + $scope, + ); + } + + /** + * Get the variable a Blueprint callback receives the Blueprint in, or null when the callback cannot be followed. + */ + private function getBlueprintVariable(PhpParser\Node\Expr $callback): ?string + { + $parameter = $callback instanceof PhpParser\Node\Expr\Closure || $callback instanceof PhpParser\Node\Expr\ArrowFunction + ? $callback->params[0] ?? null + : null; + + return $parameter !== null + && $parameter->var instanceof PhpParser\Node\Expr\Variable + && is_string($parameter->var->name) + && (! $parameter->type instanceof PhpParser\Node\Name || $this->isClass($parameter->type, Blueprint::class)) + ? $parameter->var->name + : null; + } + + /** + * Apply a statement of a Blueprint callback to its table. + * + * Only a call chain on the Blueprint is followed: its first call, with the last nullable() and + * unsigned() modifiers of the chain. A method it does not know, such as a macro, or a name that + * is not constant makes the table unknown. Other statements make the table unknown when they use + * the Blueprint. Creating a first-class callable runs nothing. + * + * @param Scope $scope + */ + private function processColumnUpdate(PhpParser\Node\Expr $expr, array $scope): void + { + /** @var TableScope $tableScope */ + $tableScope = $scope['table']; + $call = $expr; + $modifiers = []; + + while ($call instanceof PhpParser\Node\Expr\MethodCall && $call->var instanceof PhpParser\Node\Expr\MethodCall) { + // The outermost modifier runs last, so it decides. + if ($call->name instanceof PhpParser\Node\Identifier && ! $call->isFirstClassCallable()) { + $modifiers[$call->name->toLowerString()] ??= $call; + } + + $call = $call->var; + } + + if (! $call instanceof PhpParser\Node\Expr\MethodCall + || ! $call->var instanceof PhpParser\Node\Expr\Variable + || $call->var->name !== $tableScope['variable']) { + $this->forgetAffectedBy($expr, $scope); + + return; + } + + $table = $this->owner($tableScope['connection'])->tables[$tableScope['key']] ?? null; + $method = $call->name instanceof PhpParser\Node\Identifier ? $call->name->toLowerString() : null; + + // An earlier drop() or rename() removed the table, so later changes would fail. + if ($table === null || $call->isFirstClassCallable() || in_array($method, self::IGNORED_BLUEPRINT_METHODS, true)) { + return; + } + + if ($method === null || ! $this->processStatementAlterMethod($method, $call, $table, $modifiers, $scope)) { + $this->forgetTableByKey($tableScope['connection'], $tableScope['key']); + } + } + + /** + * Apply a Blueprint method to its table, or return false when its effect is unknown. + * + * @param array $modifiers the last call of each modifier in the chain + * @param Scope $scope + */ + private function processStatementAlterMethod( + string $method, + PhpParser\Node\Expr\MethodCall $call, + SchemaTable $table, + array $modifiers, + array $scope, + ): bool { + /** @var TableScope $tableScope */ + $tableScope = $scope['table']; + $connection = $tableScope['connection']; + + if (isset(self::COLUMN_METHODS[$method]) || $method === 'addcolumn') { + // addColumn() merges its parameters over the type and name, and the chain's modifiers then override them. + $parameters = $method === 'addcolumn' ? $this->getColumnParameters($call, $table) : []; + + // Parameters that are not a literal array can set any attribute, including the name and change(). + if ($parameters === null) { + return false; + } + + if ($method === 'addcolumn') { + $typeArgument = $parameters['type'] ?? $this->getArgument($call, 0, 'type'); + $nameArgument = $parameters['name'] ?? $this->getArgument($call, 1, 'name'); + $type = $typeArgument === null ? null : $this->resolveName($typeArgument, $table); + $name = $nameArgument === null ? null : $this->resolveName($nameArgument, $table); + } else { + $type = self::COLUMN_METHODS[$method]; + $name = $this->getNameArgument($call, 0, 'column', self::DEFAULT_COLUMN_NAMES[$method] ?? null, $table); + } + + $type = $type === null ? null : strtolower($type); + + if ($name === null) { + return $this->addUnnamedColumn($table, isset($modifiers['change']) || isset($parameters['change'])); + } + + $this->setColumn( + $connection, + $table, + $name, + $type, + $this->getModifier($modifiers, 'unsigned', match (true) { + in_array($method, self::UNSIGNED_COLUMN_METHODS, true) => true, + in_array($method, self::SIGNABLE_COLUMN_METHODS, true) => $this->getBooleanArgument($call, 2, 'unsigned', false), + isset($parameters['unsigned']) => $this->resolveBoolean($parameters['unsigned']), + default => false, + }), + $this->getModifier($modifiers, 'nullable', isset($parameters['nullable']) + ? $this->resolveBoolean($parameters['nullable']) + : in_array($method, self::NULLABLE_COLUMN_METHODS, true)) ?? true, + in_array($type, ['enum', 'set'], true) + ? $this->getAllowedValues($method === 'addcolumn' ? $parameters['allowed'] ?? null : $this->getArgument($call, 1, 'allowed'), $table) + : null, + ); + + return true; + } + + switch ($method) { + case 'foreignidfor': + case 'foreignuuidfor': + case 'foreignulidfor': + $reference = $this->getForeignModel($call, $table); + + if ($reference === null) { + return $this->addUnnamedColumn($table, isset($modifiers['change'])); + } + + [$model, $name] = $reference; + $type = match (true) { + $method === 'foreignuuidfor' => 'uuid', + $method === 'foreignulidfor' => 'char', + $model->getKeyType() === 'int' => 'biginteger', + isset(class_uses_recursive($model)[HasUlids::class]) => 'char', + default => 'uuid', + }; + + $this->setColumn( + $connection, + $table, + $name, + $type, + $this->getModifier($modifiers, 'unsigned', $type === 'biginteger'), + $this->getModifier($modifiers, 'nullable', false) ?? true, + ); + + return true; + case 'morphs': + case 'nullablemorphs': + case 'numericmorphs': + case 'nullablenumericmorphs': + case 'uuidmorphs': + case 'nullableuuidmorphs': + case 'ulidmorphs': + case 'nullableulidmorphs': + $name = $this->getNameArgument($call, 0, 'name', table: $table); + + if ($name === null) { + return $this->addUnnamedColumn($table, isset($modifiers['change'])); + } + + $keyType = match (true) { + str_contains($method, 'numeric') => 'int', + str_contains($method, 'uuid') => 'uuid', + str_contains($method, 'ulid') => 'ulid', + default => $this->morphKeyType, + }; + $nullable = str_starts_with($method, 'nullable'); + + $this->setColumn($connection, $table, "{$name}_type", 'string', false, $nullable); + $this->setColumn($connection, $table, "{$name}_id", match ($keyType) { + null => null, + 'uuid' => 'uuid', + 'ulid' => 'char', + default => 'biginteger', + }, true, $nullable); + + return true; + case 'timestamps': + case 'nullabletimestamps': + case 'timestampstz': + case 'nullabletimestampstz': + case 'datetimes': + $type = match ($method) { + 'timestampstz', 'nullabletimestampstz' => 'timestamptz', + 'datetimes' => 'datetime', + default => 'timestamp', + }; + + $this->setColumn($connection, $table, 'created_at', $type, false, true); + $this->setColumn($connection, $table, 'updated_at', $type, false, true); + + return true; + case 'renamecolumn': + $from = $this->getNameArgument($call, 0, 'from', table: $table); + $from = $from === null ? null : $this->resolveColumnName($connection, $table, $from); + $to = $this->getNameArgument($call, 1, 'to', table: $table); + + if ($from === null || $to === null) { + return false; + } + + $table->renameColumn($from, $to); + + return true; + case 'after': + $callback = $this->getArgument($call, 1, 'callback'); + $variable = $callback === null ? null : $this->getBlueprintVariable($callback); + + if ($variable === null) { + if ($callback !== null) { + $this->forgetAffectedBy($callback, $scope); + } + + return false; + } + + /** @var PhpParser\Node\Expr\ArrowFunction|PhpParser\Node\Expr\Closure $callback */ + $this->applyBody($this->getUpdateStatements($callback), ['table' => ['variable' => $variable] + $tableScope] + $scope); + + return true; + case 'removecolumn': + $name = $this->getNameArgument($call, 0, 'name', table: $table); + + if ($name === null) { + return false; + } + + // Unlike upstream's drop, it only cancels the column definitions this Blueprint queued, so the + // column may keep its earlier type, or not exist. + $table->setColumn(new SchemaColumn($name, null, true)); + + return true; + case 'drop': + case 'dropifexists': + $this->owner($connection)->dropTable($tableScope['key']); + + return true; + case 'rename': + $this->renameTable($connection, $tableScope['key'], $this->getNameArgument($call, 0, 'to')); + + return true; + case 'dropmorphs': + $name = $this->getNameArgument($call, 0, 'name', table: $table); + $columns = $name === null ? [null] : ["{$name}_type", "{$name}_id"]; + + break; + default: + $columns = match ($method) { + 'dropcolumn' => $this->getColumnNames($call, $table), + 'dropconstrainedforeignid' => [$this->getNameArgument($call, 0, 'column', table: $table)], + 'dropforeignidfor', 'dropconstrainedforeignidfor' => [$this->getForeignModel($call, $table)[1] ?? null], + 'dropremembertoken' => ['remember_token'], + 'dropsoftdeletes', 'dropsoftdeletestz' => [$this->getNameArgument($call, 0, 'column', 'deleted_at', $table)], + 'droptimestamps', 'droptimestampstz' => ['created_at', 'updated_at'], + // Macros and other methods it does not know. + default => [null], + }; + } + + if (in_array(null, $columns, true)) { + return false; + } + + foreach ($columns as $column) { + $column = $this->resolveColumnName($connection, $table, $column); + + if ($column === null) { + return false; + } + + $table->dropColumn($column); + } + + return true; + } + + /** + * Get the parameters addColumn() merges over the column's attributes, or null when they are not a literal + * array with constant keys. + * + * @return null|array + */ + private function getColumnParameters(PhpParser\Node\Expr\MethodCall $call, SchemaTable $table): ?array + { + $argument = $this->getArgument($call, 2, 'parameters'); + + if ($argument === null) { + return []; + } + + if (! $argument instanceof PhpParser\Node\Expr\Array_) { + return null; + } + + $parameters = []; + + foreach ($argument->items as $item) { + $key = $item->unpack || $item->key === null ? null : $this->resolveName($item->key, $table); + + if ($key === null) { + return null; + } + + $parameters[$key] = $item->value; + } + + return $parameters; + } + + /** + * Get the stored name of the column a statement names, or null when several columns could match. + * + * MySQL, MariaDB and SQLite match column names in any letter case, and the column keeps its stored spelling. + */ + private function resolveColumnName(ConnectionConfig $connection, SchemaTable $table, string $name): ?string + { + if (isset($table->columns[$name]) || ! in_array($connection->driver, ['mysql', 'mariadb', 'sqlite'], true)) { + return $name; + } + + $matches = array_values(array_filter( + array_keys($table->columns), + static fn (string $column): bool => mb_strtolower($column) === mb_strtolower($name), + )); + + return match (count($matches)) { + 0 => $name, + 1 => $matches[0], + default => null, + }; + } + + /** + * Record that a table gained columns whose names are unknown, or return false when change() may modify a known one. + * + * Adding a column cannot change the existing ones, so they keep their facts. + */ + private function addUnnamedColumn(SchemaTable $table, bool $change): bool + { + if ($change) { + return false; + } + + $table->complete = false; + + return true; + } + + /** + * Get the value the last nullable() or unsigned() modifier of a chain sets, the default without one, or null when it is not a literal. + * + * @param array $modifiers + */ + private function getModifier(array $modifiers, string $name, ?bool $default): ?bool + { + return isset($modifiers[$name]) ? $this->getBooleanArgument($modifiers[$name], 0, 'value', true) : $default; + } + + /** + * Add or replace a column with the type the connection's schema grammar creates for a Blueprint type. + * + * @param ?string $type the lowercase Blueprint type, or null when it is unknown + * @param ?bool $unsigned whether the column is unsigned, or null when it is unknown + * @param null|list $values the values an enum or set allows, or null when they are unknown + */ + private function setColumn( + ConnectionConfig $connection, + SchemaTable $table, + string $name, + ?string $type, + ?bool $unsigned, + bool $nullable, + ?array $values = null, + ): void { + $driver = $connection->driver === 'mariadb' ? 'mysql' : $connection->driver; + $typeName = match (true) { + $connection->driver === 'mariadb' && $type === 'uuid' => 'uuid', + // SQLite's grammar creates json and jsonb columns only when the connection opts in, and text otherwise. + $driver === 'sqlite' && $type === 'json' && $connection->nativeJson => 'json', + $driver === 'sqlite' && $type === 'jsonb' && $connection->nativeJsonb => 'jsonb', + default => self::GRAMMAR_TYPES[$driver][$type ?? ''] ?? null, + }; + // MySQL enums and sets hold their values in the type; the other grammars check an enum's string. + $typeValues = $driver === 'mysql' && in_array($type, ['enum', 'set'], true); + + // Only MySQL reads the sign, so an unknown sign only makes its type unknown. + if ($driver === 'mysql' && $unsigned === null) { + $typeName = null; + } + + $storedName = $this->resolveColumnName($connection, $table, $name); + + // Only change() can name an existing column in another letter case, and the spelling the column + // then keeps depends on the database's statement, so neither spelling is known. + if ($storedName !== $name) { + if ($storedName !== null) { + $table->dropColumn($storedName); + } + + $table->complete = false; + + return; + } + + $table->setColumn(new SchemaColumn( + $name, + $typeName === null ? null : new ColumnType( + $typeName, + $driver === 'pgsql' ? 'pg_catalog' : null, + $type === 'boolean' && $driver !== 'pgsql' ? ['1'] : [], + unsigned: $driver === 'mysql' && $unsigned === true, + values: $typeValues ? $values ?? [] : [], + ), + $nullable, + $type === 'enum' && ! $typeValues ? $values ?? [] : [], + )); + } + + /** + * Get the model a foreignIdFor()-style call references and the column it names, or null when either is unknown. + * + * The model's key and foreign key name come from its class, parents and traits, which become sources of the table's columns. + * + * @return null|array{Model, string} + */ + private function getForeignModel(PhpParser\Node\Expr\MethodCall $call, SchemaTable $table): ?array + { + $modelArgument = $this->getArgument($call, 0, 'model'); + $class = match (true) { + $modelArgument instanceof PhpParser\Node\Expr\ClassConstFetch + && $modelArgument->class instanceof PhpParser\Node\Name + && $modelArgument->name instanceof PhpParser\Node\Identifier + && $modelArgument->name->toLowerString() === 'class' => $modelArgument->class->toString(), + $modelArgument instanceof PhpParser\Node\Scalar\String_ => $modelArgument->value, + default => null, + }; + + if ($class === null || ! $this->reflectionProvider->hasClass($class) || ! $this->reflectionProvider->getClass($class)->is(Model::class)) { + return null; + } + + $classReflection = $this->reflectionProvider->getClass($class); + + try { + $model = ModelHelper::newInstanceWithoutConstructor($classReflection); + } catch (ReflectionException) { + return null; + } + + $this->addSources(SourceFiles::forInstance($this->reflectionProvider, $classReflection, Model::class), $table); + + $columnArgument = $this->getArgument($call, 1, 'column'); + $column = $columnArgument === null || $this->isNullLiteral($columnArgument) ? '' : $this->resolveName($columnArgument, $table); + + // Blueprint uses the model's foreign key for an empty name too. + return $column === null ? null : [$model, $column === '' ? $model->getForeignKey() : $column]; + } + + /** + * Get the column names dropColumn() receives as an array or as separate arguments, with null for each one that is not constant. + * + * @return non-empty-list + */ + private function getColumnNames(PhpParser\Node\Expr\MethodCall $call, SchemaTable $table): array + { + $columns = $this->getArgument($call, 0, 'columns'); + $values = $columns instanceof PhpParser\Node\Expr\Array_ + ? array_map(static fn (PhpParser\Node\ArrayItem $item): ?PhpParser\Node\Expr => $item->unpack ? null : $item->value, $columns->items) + : array_map(static fn (PhpParser\Node\Arg $argument): ?PhpParser\Node\Expr => $argument->unpack ? null : $argument->value, $call->getArgs()); + + return array_map(fn (?PhpParser\Node\Expr $value): ?string => $value === null ? null : $this->resolveName($value, $table), $values) ?: [null]; + } + + /** + * Get the values an enum() or set() column allows, or null when they are not constant. + * + * @return null|list + */ + private function getAllowedValues(?PhpParser\Node\Expr $allowed, SchemaTable $table): ?array + { + if (! $allowed instanceof PhpParser\Node\Expr\Array_) { + return null; + } + + $values = []; + + foreach ($allowed->items as $item) { + $value = $item->unpack ? null : $this->resolveName($item->value, $table); + + if ($value === null) { + return null; + } + + $values[] = $value; + } + + return $values; + } + + /** + * Get the constant string an expression names: a literal, or a class constant with a string value. + * + * A constant's files are sources of the table's column facts when a table is given, and of every + * fact otherwise. + */ + private function resolveName(PhpParser\Node\Expr $value, ?SchemaTable $table = null): ?string + { + if ($value instanceof PhpParser\Node\Scalar\String_) { + return $value->value; + } + + if ( + ! $value instanceof PhpParser\Node\Expr\ClassConstFetch + || ! $value->class instanceof PhpParser\Node\Name\FullyQualified + || ! $value->name instanceof PhpParser\Node\Identifier + || ! $this->reflectionProvider->hasClass($value->class->name) + ) { + return null; + } + + $class = $this->reflectionProvider->getClass($value->class->name); + + if ( + ! $class->hasConstant($value->name->toString()) + || ($class->isEnum() && $class->hasEnumCase($value->name->toString())) + ) { + return null; + } + + $constant = $class->getConstant($value->name->toString()); + $strings = $this->initializerExprTypeResolver->getType( + $constant->getValueExpr(), + InitializerExprContext::fromClassReflection($constant->getDeclaringClass()), + )->getConstantStrings(); + + if (count($strings) !== 1) { + return null; + } + + $this->addSources(SourceFiles::forConstant($this->reflectionProvider, $class, $value->name->toString()), $table); + + return $strings[0]->getValue(); + } + + /** + * Record PHP files besides the migrations as sources of a table's column facts, or of every fact without a table. + * + * @param list $files + */ + private function addSources(array $files, ?SchemaTable $table): void + { + foreach ($files as $file) { + if ($table === null) { + $this->sources[$file] = true; + } else { + $table->sources[$file] = true; + } + } + } + + /** + * Apply Schema::drop() or dropIfExists(). + */ + private function dropTable(PhpParser\Node\Expr\CallLike $call, ConnectionConfig $connection): void + { + $owner = $this->owner($connection); + $tableName = $this->getNameArgument($call, 0, 'table'); + + if ($tableName === null) { + $owner->forget(); + + return; + } + + [$schema, $name] = $connection->physicalTable($tableName); + $key = $this->findTableKey($connection, $schema, $name); + $this->forgetCaseVariants($connection, $schema, $name); + + if ($key === false) { + $owner->forget(); + } elseif ($key !== null) { + $owner->dropTable($key); + } + } + + /** + * Apply Schema::rename(). + */ + private function renameTableThroughStaticCall(PhpParser\Node\Expr\CallLike $call, ConnectionConfig $connection): void + { + $from = $this->getNameArgument($call, 0, 'from'); + + if ($from === null) { + $this->owner($connection)->forget(); + + return; + } + + $this->renameTable( + $connection, + $this->resolveTable($connection, ...$connection->physicalTable($from)), + $this->getNameArgument($call, 1, 'to'), + ); + } + + /** + * Move a table's facts to its new name. + * + * PostgreSQL and SQLite rename a table within its schema, while MySQL resolves the new name like + * any other. A new name that is not constant leaves an unknown relation. + */ + private function renameTable(ConnectionConfig $connection, string $key, ?string $to): void + { + $owner = $this->owner($connection); + $table = $owner->tables[$key]; + $owner->dropTable($key); + + if ($to === null) { + $owner->complete = false; + + return; + } + + [$schema, $table->name] = $connection->physicalTable($to); + + if (in_array($connection->driver, ['mysql', 'mariadb'], true)) { + $table->schema = $schema ?? $connection->defaultSchema; + } + + $owner->tables[$owner->key($table->schema, $table->name)] = $table; + } + + /** + * Get the expression a call passes for a parameter, by name or position. + * + * An unpacked argument stands for its position and every later one, so its value is not constant. + */ + private function getArgument(PhpParser\Node\Expr\CallLike $call, int $position, string $parameter): ?PhpParser\Node\Expr + { + if ($call->isFirstClassCallable()) { + return null; + } + + foreach ($call->getArgs() as $index => $argument) { + $matches = $argument->name !== null + ? $argument->name->toString() === $parameter + : $index === $position || ($argument->unpack && $index < $position); + + if ($matches) { + return $argument->value; + } + } + + return null; + } + + /** + * Get the literal boolean a call passes for a parameter, the default when it passes none, or null when it is not a literal. + */ + private function getBooleanArgument(PhpParser\Node\Expr\CallLike $call, int $position, string $parameter, bool $default): ?bool + { + $argument = $this->getArgument($call, $position, $parameter); + + return $argument === null ? $default : $this->resolveBoolean($argument); + } + + /** + * Get the boolean an expression is a literal of, or null when it is not a boolean literal. + */ + private function resolveBoolean(PhpParser\Node\Expr $expr): ?bool + { + return $expr instanceof PhpParser\Node\Expr\ConstFetch && in_array($expr->name->toLowerString(), ['true', 'false'], true) + ? $expr->name->toLowerString() === 'true' + : null; + } + + /** + * Get the constant name a call passes for a parameter, the default when it passes none, or null when it is not constant. + */ + private function getNameArgument(PhpParser\Node\Expr\CallLike $call, int $position, string $parameter, ?string $default = null, ?SchemaTable $table = null): ?string + { + $argument = $this->getArgument($call, $position, $parameter); + + return $argument === null ? $default : $this->resolveName($argument, $table); + } + + /** + * Determine whether an expression is the null literal. + */ + private function isNullLiteral(PhpParser\Node\Expr $expr): bool + { + return $expr instanceof PhpParser\Node\Expr\ConstFetch && $expr->name->toLowerString() === 'null'; + } + + /** + * Get the statements a callback runs. + * + * Unlike upstream, which applied the expressions inside if statements unconditionally, branches + * are applied as branches. + * + * @return PhpParser\Node\Stmt[] + */ + private function getUpdateStatements(PhpParser\Node\Expr\Closure|PhpParser\Node\Expr\ArrowFunction $callback): array + { + return $callback instanceof PhpParser\Node\Expr\ArrowFunction + ? [new PhpParser\Node\Stmt\Expression($callback->expr)] + : $callback->stmts; + } + + /** + * Get the connection and SQL of a statement() or unprepared() call, or null when the expression is not one. + * + * The call runs on the default connection through the DB facade, or on a selected one through + * DB::connection() or Schema's getConnection(). The connection is null when it is unknown, and + * the SQL when it is not a literal, as for a first-class callable. + * + * @param Scope $scope + * @return null|array{?ConnectionConfig, ?string} + */ + private function literalDatabaseStatement(PhpParser\Node\Expr $expr, array $scope): ?array + { + if (! ($expr instanceof PhpParser\Node\Expr\StaticCall || $expr instanceof PhpParser\Node\Expr\MethodCall) + || ! $expr->name instanceof PhpParser\Node\Identifier + || ! in_array($expr->name->toLowerString(), ['statement', 'unprepared'], true)) { + return null; + } + + if ($expr instanceof PhpParser\Node\Expr\StaticCall) { + if (! $this->isFacade($expr->class, DB::class)) { + return null; + } + + $connection = $scope['connection']; + } elseif (($schemaCall = $this->getSchemaCall($expr->var, $scope)) !== null && $schemaCall['method'] === 'getconnection') { + $connection = $schemaCall['connection']; + } elseif ($this->isFacadeCall($expr->var, DB::class, 'connection')) { + $connection = $this->getNamedConnection($expr->var, $scope); + } else { + return null; + } + + $sql = $this->getArgument($expr, 0, 'query'); + + return [$connection, $sql instanceof PhpParser\Node\Scalar\String_ ? $sql->value : null]; + } + + /** + * Determine whether a class name refers to a facade, also through its global alias. + */ + private function isFacade(PhpParser\Node $class, string $facade): bool + { + return $class instanceof PhpParser\Node\Name + && ($class->toString() === class_basename($facade) || $this->isClass($class, $facade)); + } + + /** + * Determine whether an expression calls a facade method. + * + * @param lowercase-string $method + * @phpstan-assert-if-true =PhpParser\Node\Expr\StaticCall $expr + */ + private function isFacadeCall(PhpParser\Node $expr, string $facade, string $method): bool + { + return $expr instanceof PhpParser\Node\Expr\StaticCall + && $expr->name instanceof PhpParser\Node\Identifier + && $expr->name->toLowerString() === $method + && $this->isFacade($expr->class, $facade); + } + + /** + * Determine whether a class name refers to a class or one of its subclasses. + */ + private function isClass(PhpParser\Node\Name $name, string $class): bool + { + return $this->reflectionProvider->hasClass($name->toString()) + && $this->reflectionProvider->getClass($name->toString())->is($class); + } + + /** + * Apply raw SQL to the facts of its connection. + * + * Each parse starts from an empty schema, so its definitions only describe the relations and types + * the SQL creates, and its impact names the earlier relations and types it changes. A definition of + * a relation that may already exist could be a no-op, such as CREATE TABLE IF NOT EXISTS, so it + * makes the relation unknown. Session state that separate calls carry, such as USE or SET + * search_path, is not tracked: each call resolves names with the connection's configured + * database and search path. + */ + private function applyLiteralSql(ConnectionConfig $connection, string $sql): void + { + $owner = $this->owner($connection); + + try { + $schema = $connection->createParser()?->parse($sql); + } catch (ParseException) { + $schema = null; + } + + if ($schema === null || $schema->impact->unknown) { + $owner->forget(); + + return; + } + + foreach ($schema->impact->relations as $relation) { + $this->forgetTableByKey($connection, $this->resolveTable($connection, $relation->schema, $relation->name)); + } + + foreach ($schema->impact->types as $type) { + $owner->forgetType($type->schema, $type->name, $connection->searchPath); + } + + foreach ([...$schema->tables, ...$schema->views] as $definition) { + $exists = ! $owner->complete + || $this->findTableKey($connection, $definition->schema, $definition->name) !== null + || $owner->caseVariants($definition->schema ?? $connection->defaultSchema, $definition->name) !== []; + + if ($definition instanceof View || $exists) { + $this->forgetTableByKey($connection, $this->resolveTable($connection, $definition->schema, $definition->name)); + + continue; + } + + $owner->addTable($definition, $connection, $schema->issues === []); + } + + // A type definition with an issue could have other labels or a different base. + foreach ([...$schema->enums, ...$schema->domains] as $type) { + $owner->types[$owner->key($type->schema, $type->name)] = $schema->issues === [] ? $type : null; + } + } +} diff --git a/src/phpstan/src/Properties/SchemaColumn.php b/src/phpstan/src/Properties/SchemaColumn.php new file mode 100644 index 000000000..ea0205616 --- /dev/null +++ b/src/phpstan/src/Properties/SchemaColumn.php @@ -0,0 +1,28 @@ + $options the values a CHECK constraint from Blueprint's enum() allows + */ + public function __construct( + public string $name, + public ?ColumnType $type, + public bool $nullable = false, + public array $options = [], + ) { + } +} diff --git a/src/phpstan/src/Properties/SchemaConnection.php b/src/phpstan/src/Properties/SchemaConnection.php new file mode 100644 index 000000000..87800e282 --- /dev/null +++ b/src/phpstan/src/Properties/SchemaConnection.php @@ -0,0 +1,244 @@ + tables and views, by identity key */ + public array $tables = []; + + /** @var array PostgreSQL user types by identity key, null when the definition or its presence is uncertain */ + public array $types = []; + + /** Whether every relation is known, so that a missing table is a claim. */ + public bool $complete = true; + + /** @var list the schema dumps that could not be read */ + public array $unusableDumps = []; + + /** + * Create a new schema connection instance. + */ + public function __construct(public string $name, public string $driver) + { + } + + /** + * Copy the tables, so that each branch of a migration changes its own copy. + */ + public function __clone(): void + { + foreach ($this->tables as $key => $table) { + $this->tables[$key] = clone $table; + } + } + + /** + * Get the identity key of a schema-qualified name, which ignores ASCII case where SQLite does. + */ + public function key(?string $schema, string $name): string + { + if ($this->driver === 'sqlite') { + $schema = $schema === null ? null : strtolower($schema); + $name = strtolower($name); + } + + return ($schema === null ? 'n:' : 's:' . strlen($schema) . ':' . $schema) . $name; + } + + /** + * Find the identity key of a relation, or return null when it is absent and false when it is ambiguous. + * + * Unqualified MySQL and SQLite names use the connection's default schema. An unqualified PostgreSQL + * name resolves through the search path. An unqualified definition was created in the first existing + * schema of its connection's path, so it only matches the same path when no qualified candidate exists. + * + * @param list $searchPath + */ + public function findTableKey(?string $schema, string $name, ?string $defaultSchema, array $searchPath): false|string|null + { + if ($this->driver !== 'pgsql') { + $key = $this->key($schema ?? $defaultSchema, $name); + + return isset($this->tables[$key]) ? $key : null; + } + + $unqualifiedKey = $this->key(null, $name); + $unqualified = $this->tables[$unqualifiedKey] ?? null; + + if ($schema !== null) { + $key = $this->key($schema, $name); + + return $unqualified !== null ? false : (isset($this->tables[$key]) ? $key : null); + } + + foreach ($searchPath as $searchSchema) { + $key = $this->key($searchSchema, $name); + + if (isset($this->tables[$key])) { + return $unqualified === null ? $key : false; + } + } + + if ($unqualified === null) { + return null; + } + + return $unqualified->searchPath === $searchPath ? $unqualifiedKey : false; + } + + /** + * Add a parsed table, linking the known relations it inherits from or partitions. + * + * @param bool $complete whether the input it comes from is free of schema-level issues + */ + public function addTable(Table $definition, ConnectionConfig $connection, bool $complete): void + { + $table = new SchemaTable($definition->name, $definition->schema ?? $connection->definitionSchema()); + $table->searchPath = $table->schema === null ? $connection->searchPath : null; + $table->complete = $complete && $definition->isComplete(); + + foreach ($definition->columns as $column) { + $table->setColumn(new SchemaColumn($column->name, $column->type, $column->nullable)); + } + + foreach ([...$definition->parents, ...($definition->partitionOf === null ? [] : [$definition->partitionOf])] as $parent) { + $key = $this->findTableKey($parent->schema, $parent->name, $connection->defaultSchema, $connection->searchPath); + + if (is_string($key)) { + $table->parents[] = $key; + } + } + + $this->tables[$this->key($table->schema, $table->name)] = $table; + } + + /** + * Get the identity keys of other relations whose schema and name differ only in letter case. + * + * MySQL compares database and table names without case on some servers, so a change to one + * spelling can change another. Identities stay exact, since other servers keep them distinct. + * + * @return list + */ + public function caseVariants(?string $schema, string $name): array + { + if (! in_array($this->driver, ['mysql', 'mariadb'], true)) { + return []; + } + + $exact = $this->key($schema, $name); + $folded = $this->key($schema === null ? null : mb_strtolower($schema), mb_strtolower($name)); + $variants = []; + + foreach ($this->tables as $key => $table) { + if ($key !== $exact && $this->key($table->schema === null ? null : mb_strtolower($table->schema), mb_strtolower($table->name)) === $folded) { + $variants[] = $key; + } + } + + return $variants; + } + + /** + * Make a relation and its known inheritance and partition descendants unknown. + * + * The relation may no longer exist or have its earlier columns. PostgreSQL applies + * ALTER TABLE without ONLY, and the drop of a partitioned table, to descendants too. + */ + public function forgetTable(?string $schema, string $name): void + { + $key = $this->key($schema, $name); + $table = $this->tables[$key] ??= new SchemaTable($name, $schema); + $table->columns = []; + $table->complete = false; + + $this->forgetDescendants($key); + } + + /** + * Make the known inheritance and partition descendants of a relation unknown. + */ + public function forgetDescendants(string $key): void + { + $forgotten = [$key]; + + for ($index = 0; isset($forgotten[$index]); ++$index) { + foreach ($this->tables as $childKey => $child) { + if (in_array($forgotten[$index], $child->parents, true) && ! in_array($childKey, $forgotten, true)) { + $child->columns = []; + $child->complete = false; + $forgotten[] = $childKey; + } + } + } + } + + /** + * Remove a relation that no longer exists, making its descendants unknown. + */ + public function dropTable(string $key): void + { + $this->forgetDescendants($key); + + unset($this->tables[$key]); + } + + /** + * Make a user type uncertain after a script altered or dropped it. + * + * A qualified name is marked even when nothing recorded it, which stops any search along + * a path through its schema, so a definition recorded without a schema stays known: it was + * created in a path schema. An unqualified name can denote any recorded definition along + * the search path. + * + * @param list $searchPath + */ + public function forgetType(?string $schema, string $name, array $searchPath): void + { + if ($schema !== null) { + $this->types[$this->key($schema, $name)] = null; + + return; + } + + foreach ([null, ...$searchPath] as $candidate) { + $key = $this->key($candidate, $name); + + if (array_key_exists($key, $this->types)) { + $this->types[$key] = null; + } + } + } + + /** + * Make every fact unknown. + */ + public function forget(): void + { + $this->tables = []; + $this->types = []; + $this->complete = false; + } +} diff --git a/src/phpstan/src/Properties/SchemaTable.php b/src/phpstan/src/Properties/SchemaTable.php new file mode 100644 index 000000000..f0625a61f --- /dev/null +++ b/src/phpstan/src/Properties/SchemaTable.php @@ -0,0 +1,69 @@ + */ + public array $columns = []; + + /** Whether every column is known, so that a missing column is a claim. */ + public bool $complete = true; + + /** @var list the identity keys of the tables it inherits from or partitions */ + public array $parents = []; + + /** @var null|list the search path of the connection that defined it without a schema */ + public ?array $searchPath = null; + + /** @var array the PHP files besides the migrations that its columns were read from, such as a constant naming one */ + public array $sources = []; + + /** + * Create a new schema table instance. + */ + public function __construct(public string $name, public ?string $schema = null) + { + } + + /** + * Add or replace a column. + */ + public function setColumn(SchemaColumn $column): void + { + $this->columns[$column->name] = $column; + } + + /** + * Rename a column. + */ + public function renameColumn(string $oldName, string $newName): void + { + // An unknown column of an incomplete table keeps an unknown type under its new name. + if (! isset($this->columns[$oldName])) { + if (! $this->complete) { + $this->setColumn(new SchemaColumn($newName, null)); + } + + return; + } + + $oldColumn = $this->columns[$oldName]; + + unset($this->columns[$oldName]); + + // Branch copies of a table share their columns, so the renamed column is a new instance. + $this->columns[$newName] = new SchemaColumn($newName, $oldColumn->type, $oldColumn->nullable, $oldColumn->options); + } + + /** + * Drop a column. + */ + public function dropColumn(string $columnName): void + { + unset($this->columns[$columnName]); + } +} diff --git a/src/phpstan/src/Properties/SquashedMigrationHelper.php b/src/phpstan/src/Properties/SquashedMigrationHelper.php new file mode 100644 index 000000000..cd3fb2905 --- /dev/null +++ b/src/phpstan/src/Properties/SquashedMigrationHelper.php @@ -0,0 +1,181 @@ + + */ + public function initializeConnections(): array + { + $connections = []; + + foreach ($this->findSchemaFiles() as [$name, $file]) { + $connection = $this->connectionConfigResolver->connection($name); + + if ($connection !== null) { + $this->addDump($connections[$connection->owner] ??= new SchemaConnection($connection->owner, $connection->driver), $connection, $file); + } + } + + return $connections; + } + + /** + * Add a schema dump's definitions to its owner's facts. + * + * Schema-level issues mean represented definitions may be wrong, so its tables are incomplete + * and its types uncertain. Another dump defining the same relation, or a type differently, + * makes it unknown. + */ + private function addDump(SchemaConnection $owner, ConnectionConfig $connection, SplFileInfo $file): void + { + $sql = file_get_contents($file->getPathname()); + $schema = null; + $failure = $sql === false ? new ParseException('The dump could not be read') : null; + + if ($sql !== false) { + try { + $schema = $connection->createParser()?->parse($sql); + } catch (ParseException $exception) { + $failure = $exception; + } + } + + if ($failure !== null) { + $owner->unusableDumps[] = ['path' => $file->getPathname(), 'line' => $failure->sqlLine ?? 1, 'message' => $failure->getMessage()]; + } + + if ($schema === null) { + $owner->complete = false; + + return; + } + + $complete = $schema->issues === []; + $owner->complete = $owner->complete && $complete; + $altered = []; + + foreach ($schema->impact->relations as $relation) { + $altered[$owner->key($relation->schema ?? $connection->definitionSchema(), $relation->name)] = true; + } + + foreach ($schema->tables as $definition) { + $tableSchema = $definition->schema ?? $connection->definitionSchema(); + $key = $owner->key($tableSchema, $definition->name); + + // Dumps alter most tables they create, but an alteration the parser could not apply can leave a + // column that no longer exists or has another type. + if (isset($owner->tables[$key]) || (! $definition->isComplete() && isset($altered[$key]))) { + $owner->forgetTable($tableSchema, $definition->name); + } else { + $owner->addTable($definition, $connection, $complete); + } + } + + foreach ($schema->views as $view) { + $owner->forgetTable($view->schema ?? $connection->definitionSchema(), $view->name); + } + + foreach ([...$schema->enums, ...$schema->domains] as $type) { + $key = $owner->key($type->schema, $type->name); + $owner->types[$key] = ! $complete || (array_key_exists($key, $owner->types) && $owner->types[$key] != $type) ? null : $type; + } + } + + /** + * Get the schema dumps of the booted connections, which the migration cache fingerprints. + * + * @return list + */ + public function getSchemaFiles(): array + { + return array_column($this->findSchemaFiles(), 1); + } + + /** + * Find each booted connection's schema dump in each schema directory, with the connection's name. + * + * As in MigrateCommand::schemaPath(), a connection's .dump file takes precedence over its .sql file. + * + * @return list + */ + private function findSchemaFiles(): array + { + $files = []; + + foreach ($this->getSchemaDirectories() as $directory) { + foreach ($this->connectionConfigResolver->getConnectionNames() as $name) { + foreach (['dump', 'sql'] as $extension) { + $path = "{$directory}/{$name}-schema.{$extension}"; + + if (is_file($path)) { + $files[] = [$name, new SplFileInfo($path)]; + + break; + } + } + } + } + + return $files; + } + + /** + * The directories scanned for schema dumps, see DirectoryResolver::resolve(). + * + * @return list + */ + public function getSchemaDirectories(): array + { + if ($this->disableSchemaScan) { + return []; + } + + if (empty($this->schemaPaths)) { + $this->schemaPaths = [database_path('schema')]; + } + + return DirectoryResolver::resolve($this->schemaPaths, $this->fileHelper); + } +} diff --git a/src/phpstan/src/Reflection/SourceFiles.php b/src/phpstan/src/Reflection/SourceFiles.php new file mode 100644 index 000000000..e1298f032 --- /dev/null +++ b/src/phpstan/src/Reflection/SourceFiles.php @@ -0,0 +1,163 @@ + + */ + public static function forClass(ClassReflection $classReflection, ?string $baseClass): array + { + $sources = []; + + foreach ([$classReflection, ...$classReflection->getParents()] as $ancestor) { + if ($ancestor->getName() === $baseClass) { + break; + } + + $sources[] = $ancestor; + + // A class's getTraits(true) also lists every parent's traits, including the base class's, but a + // trait's lists only the traits it uses. + foreach ($ancestor->getTraits() as $trait) { + $sources = [...$sources, $trait, ...array_values($trait->getTraits(true))]; + } + } + + $files = []; + + foreach ($sources as $source) { + if ($source->getFileName() !== null) { + $files[$source->getFileName()] = true; + } + } + + return array_keys($files); + } + + /** + * Get the files an instance's property defaults and method bodies come from: those declaring the class and + * its parents below a base class, with their traits, and those of the constants the defaults read. + * + * @return list + */ + public static function forInstance(ReflectionProvider $reflectionProvider, ClassReflection $classReflection, string $baseClass): array + { + $files = array_fill_keys(self::forClass($classReflection, $baseClass), true); + $constants = []; + + foreach ([$classReflection, ...$classReflection->getParents()] as $ancestor) { + if ($ancestor->getName() === $baseClass) { + break; + } + + foreach ($ancestor->getNativeReflection()->getProperties() as $property) { + if ($property->getDeclaringClass()->getName() === $ancestor->getName() && $property->hasDefaultValue()) { + self::addExpressionFiles($reflectionProvider, $ancestor, $property->getDefaultValueExpression(), $files, $constants); + } + } + } + + return array_keys($files); + } + + /** + * Get the files a constant read through a class takes its value from: those declaring the class and its + * parents up to the declaring class, as any of them can override it, and those of the constants it reads. + * + * @return list + */ + public static function forConstant(ReflectionProvider $reflectionProvider, ClassReflection $classReflection, string $name): array + { + $files = []; + $constants = []; + + self::addConstantFiles($reflectionProvider, $classReflection, $name, $files, $constants); + + return array_keys($files); + } + + /** + * Add the files a constant read through a class takes its value from. + * + * @param array $files + * @param array $constants the constants already visited + */ + private static function addConstantFiles(ReflectionProvider $reflectionProvider, ClassReflection $classReflection, string $name, array &$files, array &$constants): void + { + // A constant can refer to itself in code that fails only once it runs. + if (isset($constants[$classReflection->getName() . '::' . $name])) { + return; + } + + $constants[$classReflection->getName() . '::' . $name] = true; + + $constant = $classReflection->getConstant($name); + $declaringClass = $constant->getDeclaringClass(); + + $files += array_fill_keys(self::forClass($classReflection, $declaringClass->getParentClass()?->getName()), true); + + // An interface declaring the constant is not among the class's parents. + if ($declaringClass->getFileName() !== null) { + $files[$declaringClass->getFileName()] = true; + } + + self::addExpressionFiles($reflectionProvider, $declaringClass, $constant->getValueExpr(), $files, $constants); + } + + /** + * Add the files of the constants an initializer expression in a class reads. + * + * @param array $files + * @param array $constants the constants already visited + */ + private static function addExpressionFiles(ReflectionProvider $reflectionProvider, ClassReflection $scopeClass, Expr $expression, array &$files, array &$constants): void + { + foreach ((new NodeFinder)->findInstanceOf($expression, ClassConstFetch::class) as $fetch) { + if (! $fetch->class instanceof Name || ! $fetch->name instanceof Identifier || $fetch->name->toLowerString() === 'class') { + continue; + } + + $className = match ($fetch->class->toLowerString()) { + 'self' => $scopeClass->getName(), + 'parent' => $scopeClass->getParentClass()?->getName(), + default => $fetch->class->toString(), + }; + + if ($className === null || ! $reflectionProvider->hasClass($className)) { + continue; + } + + $class = $reflectionProvider->getClass($className); + + if ($class->hasConstant($fetch->name->toString())) { + self::addConstantFiles($reflectionProvider, $class, $fetch->name->toString(), $files, $constants); + } + } + } +} diff --git a/src/phpstan/src/ReturnTypes/CastDefinitionDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/CastDefinitionDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 000000000..dfc2f6e07 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/CastDefinitionDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,169 @@ +, list> + */ + private const array HELPERS = [ + AsArrayObject::class => ['nullable'], + AsBinary::class => ['of', 'ulid', 'uuid'], + AsCollection::class => ['nullable', 'of', 'using'], + AsEncryptedCollection::class => ['of', 'using'], + AsEnumArrayObject::class => ['of'], + AsEnumCollection::class => ['of'], + ]; + + /** + * Get the class whose static calls are typed. + */ + public function getClass(): string + { + return Castable::class; + } + + /** + * Determine whether the method is a cast definition helper. + */ + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return in_array($methodReflection->getName(), self::HELPERS[$methodReflection->getDeclaringClass()->getName()] ?? [], true); + } + + /** + * Get the cast string the helper builds. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $class = $methodReflection->getDeclaringClass(); + + // A subclass could change what a helper calls through static::. + if ( + ! $methodCall->class instanceof Name + || $methodCall->class->isSpecialClassName() + || strcasecmp($scope->resolveName($methodCall->class), $class->getName()) !== 0 + ) { + return null; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants()); + $arguments = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $methodCall)?->getArgs(); + $parameters = $parametersAcceptor->getParameters(); + + if ($arguments === null || count($arguments) > count($parameters)) { + return null; + } + + $values = []; + + foreach ($parameters as $index => $parameter) { + $argument = $arguments[$index] ?? null; + + if ($argument === null) { + if (! $parameter->isOptional()) { + return null; + } + + break; + } + + $type = $scope->getType($argument->value); + + if ($argument->unpack || ! $parameter->getType()->isSuperTypeOf($type)->yes()) { + return null; + } + + $scalars = $type->getConstantScalarValues(); + + if (count($scalars) === 1) { + $values[] = $scalars[0]; + + continue; + } + + $mapping = $this->getCallableMapping($type); + + if ($mapping === null) { + return null; + } + + $values[] = $mapping; + } + + // The cast string follows the helper's source, which analysis does not otherwise track. + $scope->trackFileDependency((string) $class->getFileName()); + + $helper = [$class->getName(), $methodReflection->getName()]; + + return new ConstantStringType($helper(...$values)); + } + + /** + * Get the [class, method] mapping a collection helper accepts, or null when the type is not one. + * + * The helpers turn a callable mapping into "class@method", but would cast any other array to a string. + * + * @return null|array{string, string} + */ + private function getCallableMapping(Type $type): ?array + { + $arrays = $type->getConstantArrays(); + + if (count($arrays) !== 1 || ! $type->isConstantArray()->yes() || count($arrays[0]->getValueTypes()) !== 2) { + return null; + } + + $class = $arrays[0]->getValueTypes()[0]->getConstantStrings(); + $method = $arrays[0]->getValueTypes()[1]->getConstantStrings(); + + if (! $arrays[0]->isList()->yes() || count($class) !== 1 || count($method) !== 1) { + return null; + } + + $mapping = [$class[0]->getValue(), $method[0]->getValue()]; + + return is_callable($mapping) ? $mapping : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/ModelSerializationDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ModelSerializationDynamicMethodReturnTypeExtension.php new file mode 100644 index 000000000..4d0929d7b --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ModelSerializationDynamicMethodReturnTypeExtension.php @@ -0,0 +1,267 @@ +getName(), ['toArray', 'attributesToArray'], true); + } + + /** + * Get the array type a model serializes to, or null to keep the declared type. + * + * Unlike upstream, a model whose database attributes are not all known keeps the declared type. + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $types = []; + + foreach ($scope->getType($methodCall->var)->getObjectClassReflections() as $class) { + if (! $class->is(Model::class)) { + continue; + } + + if ($class->isAbstract() || $class->getName() === Model::class) { + return null; + } + + // Defer to serializer and configuration overrides without executing application methods. + foreach (array_unique([$methodReflection->getName(), 'attributesToArray', 'getAppends', 'getHidden', 'getVisible']) as $method) { + if ($class->getNativeMethod($method)->getDeclaringClass()->getName() !== Model::class) { + return null; + } + } + + $databaseNames = $this->properties->getDatabasePropertyNames($class); + + if ($databaseNames === null) { + return null; + } + + $appends = $this->configuredNames($class, 'Appends'); + $hidden = $this->configuredNames($class, 'Hidden'); + $visible = $this->configuredNames($class, 'Visible'); + $names = array_unique(array_merge($databaseNames, $appends)); + + if ($names === []) { + return null; + } + + $array = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($names as $name) { + if (in_array($name, $hidden, true) || ($visible !== [] && ! in_array($name, $visible, true))) { + continue; + } + + $array->setOffsetValueType(new ConstantStringType($name), $this->attributeType($class, $name, $scope), true); + } + + // Partial selects omit columns; aggregates, aliases and loaded relations add keys. + $array->makeUnsealed(new StringType, new MixedType); + $types[] = $array->getArray(); + } + + return $types === [] ? null : TypeCombinator::union(...$types); + } + + /** + * Get the names a model configures through its property and the class attribute that Model::resolveClassAttribute() finds. + * + * @return array + */ + private function configuredNames(ClassReflection $class, string $attribute): array + { + $defaults = $class->getNativeReflection()->getProperty(lcfirst($attribute))->getDefaultValue(); + $defaults = is_array($defaults) ? array_filter($defaults, is_string(...)) : []; + + do { + foreach ([$class, ...$class->getTraits()] as $source) { + foreach ($source->getNativeReflection()->getAttributes() as $config) { + if ($config->getName() !== 'Hypervel\Database\Eloquent\Attributes\\' . $attribute) { + continue; + } + + $arguments = array_values($config->getArguments()); + $columns = is_array($arguments[0] ?? null) ? $arguments[0] : $arguments; + + return array_unique(array_merge($defaults, array_filter($columns, is_string(...)))); + } + } + + $class = $class->getParentClass(); + } while ($class !== null); + + return $defaults; + } + + /** + * Get the type an attribute serializes to, following mutateAttributeForArray() and the cast and date handling. + */ + private function attributeType(ClassReflection $class, string $name, Scope $scope): Type + { + // Unlike property reads, serialization prefers an Attribute getter to a get{Name}Attribute() method. + $attributeGetter = $this->properties->getAttributeGetMutatorType($class, $name); + $getter = $attributeGetter ?? $this->properties->getGetMutatorType($class, $name); + $accessor = $getter !== null; + + if ($accessor) { + $type = $getter; + } elseif ($this->properties->hasDatabaseProperty($class, $name)->yes()) { + $type = $this->properties->getDatabaseProperty($class, $name)->getReadableType(); + } else { + return new MixedType; + } + + $cast = $this->casts->getCastForProperty($class, $name); + + // A cast that cannot be determined may be a class cast, which mutateAttributeForArray() applies even before an accessor. + if ($cast === null && $this->casts->hasCastForProperty($class, $name)) { + return new MixedType; + } + + $castClass = explode(':', $cast ?? '')[0]; + $castReflection = $this->reflectionProvider->hasClass($castClass) ? $this->reflectionProvider->getClass($castClass) : null; + $classCast = $castReflection !== null && ! $castReflection->isEnum() && ! $castReflection->is(CastsInboundAttributes::class); + $enumCast = ! $accessor && $castReflection !== null && $castReflection->isEnum(); + // Only the Attribute getter branch serializes the dates it returns. + $date = ! $accessor || $attributeGetter !== null; + + if ($accessor) { + if ($classCast && $cast !== null) { + // mutateAttributeForArray reads class casts before accessors, without their serialize hook. + $type = $this->casts->getReadableType($cast, new MixedType); + $date = false; + } + + $cast = null; + } elseif ($classCast) { + if (in_array($castClass, [AsArrayObject::class, AsEncryptedArrayObject::class], true)) { + return TypeCombinator::addNull(new ArrayType(TypeCombinator::union(new IntegerType, new StringType), new MixedType)); + } + + if ($castReflection->is(Castable::class) && ! in_array($castClass, [AsCollection::class, AsEncryptedCollection::class, AsStringable::class], true)) { + // A castUsing factory can choose a serializer dynamically. + return new MixedType; + } + + if ($castReflection->hasNativeMethod('serialize')) { + $type = $castReflection->getNativeMethod('serialize')->getVariants()[0]->getReturnType(); + $date = false; + } + } + + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($class, $scope, $cast, $date, $enumCast): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + if ($date && (new ObjectType(DateTimeInterface::class))->isSuperTypeOf($type)->yes()) { + if ($cast !== null && str_contains($cast, ':') && in_array(explode(':', $cast)[0], ['date', 'datetime', 'immutable_date', 'immutable_datetime'], true)) { + return new StringType; + } + + return $class->getNativeMethod('serializeDate')->getVariants()[0]->getReturnType(); + } + + if ((new ObjectType(Enumerable::class))->isSuperTypeOf($type)->yes()) { + $value = TypeTraverser::map($type->getIterableValueType(), static function (Type $item, callable $traverse) use ($scope): Type { + if ($item instanceof UnionType) { + return $traverse($item); + } + + return (new ObjectType(Arrayable::class))->isSuperTypeOf($item)->yes() + ? $item->getMethod('toArray', $scope)->getVariants()[0]->getReturnType() + : $item; + }); + + return new ArrayType($type->getIterableKeyType(), $value); + } + + if ((new ObjectType(Arrayable::class))->isSuperTypeOf($type)->yes()) { + return $type->getMethod('toArray', $scope)->getVariants()[0]->getReturnType(); + } + + if ($enumCast && $type->isEnum()->yes()) { + $values = []; + + foreach ($type->getEnumCases() as $case) { + $values[] = $case->getBackingValueType() ?? new ConstantStringType($case->getEnumCaseName()); + } + + return $values === [] ? new MixedType : TypeCombinator::union(...$values); + } + + return $type; + }); + } +} diff --git a/src/phpstan/src/Rules/UnusableSchemaDumpRule.php b/src/phpstan/src/Rules/UnusableSchemaDumpRule.php new file mode 100644 index 000000000..b510385d6 --- /dev/null +++ b/src/phpstan/src/Rules/UnusableSchemaDumpRule.php @@ -0,0 +1,66 @@ + + */ +#[AutowiredService] +class UnusableSchemaDumpRule implements Rule +{ + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * Report each unusable schema dump that model property lookups consulted. + * + * @param CollectedDataNode $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + + foreach ($node->get(UnusableSchemaDumpCollector::class) as $collected) { + foreach ($collected as $dumps) { + foreach ($dumps as $dump) { + $errors[$dump['path']] ??= RuleErrorBuilder::message(sprintf('Model property types cannot use this schema dump: %s.', rtrim($dump['message'], '.'))) + ->tip('Fix the dump or regenerate it with schema:dump. Until then, the columns it defines are unknown.') + ->file($dump['path']) + ->line($dump['line']) + ->identifier('hypervel.schemaDump') + ->build(); + } + } + } + + return array_values($errors); + } +} diff --git a/src/phpstan/src/Support/ConfigParser.php b/src/phpstan/src/Support/ConfigParser.php index 10e1bd6b1..84697106b 100644 --- a/src/phpstan/src/Support/ConfigParser.php +++ b/src/phpstan/src/Support/ConfigParser.php @@ -139,25 +139,25 @@ final class ConfigParser private array $returnScopes = []; /** - * Files the statements before a config file's return depend on, such as - * the config files they read. + * Files and directories the statements before a config file's return depend on, + * such as the config files they read, each path mapped to whether it is a directory. * - * @var array> + * @var array> */ private array $configFileDependencies = []; /** - * Files a config key's value depends on besides the files that can hold - * the key, such as config files its expression reads. + * Files and directories a config key's value depends on besides the key's own + * sources, such as config files its expression reads. * - * @var array> + * @var array> */ private array $keyDependencies = []; /** - * Files each top-level key's composed value depends on. + * Files and directories each top-level key's composed value depends on. * - * @var array> + * @var array> */ private array $compositionDependencies = []; @@ -176,9 +176,9 @@ final class ConfigParser private array $expressionTypes = []; /** - * Files each value expression's type depends on, keyed by its object ID. + * Files and directories each value expression's type depends on, keyed by its object ID. * - * @var array> + * @var array> */ private array $expressionDependencies = []; @@ -186,12 +186,16 @@ final class ConfigParser * Dependencies collected for the config files and keys being typed, so * a cached result can declare them again to every file reading it. * - * @var list> + * @var list> */ private array $dependencyCollectors = []; - /** @var array> */ - private array $configFileCandidates = []; + /** + * The paths whose contents can change each config key's value, each mapped to whether it is a directory. + * + * @var array> + */ + private array $keySources = []; /** * How many lookups found their config file, expression or composed value already @@ -224,8 +228,11 @@ public function __construct( */ public function getValue(string $key, Scope $scope): ?array { - // Every analysed file reading the key depends on the config files, parsed or served from the cache. - $this->trackFileDependencies($scope, $this->getConfigFileCandidates($key)); + // Every analysed file reading the key depends on its sources, parsed or served from the cache. + $this->trackDependencies($scope, $this->keySources[$key] ??= $this->configSourceResolver->getKeySources( + $key, + $this->configSourceResolver->getConfigDirectories(), + )); $this->loadConfigFiles(); // A config file reads the configuration as it stands while the file loads. Framework @@ -247,7 +254,7 @@ public function getValue(string $key, Scope $scope): ?array $cacheKey = $loadingKey === null ? $key : $loadingKey . "\0" . $key; if (array_key_exists($cacheKey, $this->parsedConfigs)) { - $this->trackFileDependencies($scope, $this->keyDependencies[$cacheKey] ?? []); + $this->trackDependencies($scope, $this->keyDependencies[$cacheKey] ?? []); return $this->parsedConfigs[$cacheKey]; } @@ -260,13 +267,13 @@ public function getValue(string $key, Scope $scope): ?array $inProgressLookups = $this->inProgressLookups; $segments = explode('.', $key); - [$value, $dependencies] = $this->collectFileDependencies(function () use ($segments, $loadingKey, $scope): ?array { + [$value, $dependencies] = $this->collectDependencies(function () use ($segments, $loadingKey, $scope): ?array { $root = $this->getComposedConfig($segments[0], $loadingKey, $scope); return $root === null ? null : $this->lookup($root, $segments, $scope); }); - $this->trackFileDependencies($scope, $dependencies); + $this->trackDependencies($scope, $dependencies); if ($this->inProgressLookups === $inProgressLookups) { $this->keyDependencies[$cacheKey] = $dependencies; @@ -351,14 +358,14 @@ private function getComposedConfig(string $name, ?string $loadingKey, Scope $sco $cacheKey = $loadingKey === null ? $name : $loadingKey . "\0" . $name; if (array_key_exists($cacheKey, $this->composedConfigs)) { - $this->trackFileDependencies($scope, $this->compositionDependencies[$cacheKey]); + $this->trackDependencies($scope, $this->compositionDependencies[$cacheKey]); return $this->composedConfigs[$cacheKey]; } $inProgressLookups = $this->inProgressLookups; - [$result, $dependencies] = $this->collectFileDependencies(fn (): ?array => $this->guard( + [$result, $dependencies] = $this->collectDependencies(fn (): ?array => $this->guard( 'hypervel.configComposition.' . $cacheKey, fn (): ConfigArray => $this->composeConfig($name, $loadingKey, $scope), )); @@ -399,7 +406,7 @@ private function composeConfig(string $name, ?string $loadingKey, Scope $scope): } if ($frameworkFile !== null) { - $this->trackFileDependencies($scope, [$frameworkFile]); + $this->trackDependencies($scope, [$frameworkFile => false]); $root = new ConfigArray([$name => $this->getFileValue($frameworkFile)]); } @@ -432,7 +439,7 @@ private function composeConfig(string $name, ?string $loadingKey, Scope $scope): foreach ($merges as $merge) { if ($merge['definite'] && $merge['path'] !== null) { $path = $merge['path']; - $this->trackFileDependencies($scope, [$path]); + $this->trackDependencies($scope, [$path => false]); $root = $this->setPath($root, explode('.', $merge['key']), function (ConfigArray|Expr|Type|null $current, bool $exists) use ($merge, $path, $scope): ?ConfigArray { $package = $this->getFileValue($path); @@ -987,7 +994,7 @@ private function getExpressionType(Expr $expr, Scope $scope): ?Type $id = spl_object_id($expr); if (array_key_exists($id, $this->expressionTypes)) { - $this->trackFileDependencies($scope, $this->expressionDependencies[$id]); + $this->trackDependencies($scope, $this->expressionDependencies[$id]); return $this->expressionTypes[$id]; } @@ -995,14 +1002,14 @@ private function getExpressionType(Expr $expr, Scope $scope): ?Type $inProgressLookups = $this->inProgressLookups; $path = $this->expressionFiles[$id]; - [$result, $dependencies] = $this->collectFileDependencies(fn (): ?array => $this->guard('hypervel.configExpression.' . $id, function () use ($expr, $path, $scope): ?Type { + [$result, $dependencies] = $this->collectDependencies(fn (): ?array => $this->guard('hypervel.configExpression.' . $id, function () use ($expr, $path, $scope): ?Type { $returnScope = $this->getReturnScope($path); if ($returnScope === null) { return null; } - $this->trackFileDependencies($scope, $this->configFileDependencies[$path] ?? []); + $this->trackDependencies($scope, $this->configFileDependencies[$path] ?? []); // An undefined variable, such as one an included file assigns, is unknown rather than an error return TypeTraverser::map($returnScope->getType($expr), static function (Type $type, callable $traverse): Type { @@ -1014,7 +1021,7 @@ private function getExpressionType(Expr $expr, Scope $scope): ?Type }); })); - $this->trackFileDependencies($scope, $dependencies); + $this->trackDependencies($scope, $dependencies); $type = $result[0] ?? null; @@ -1096,7 +1103,7 @@ private function getReturnScope(string $path): ?Scope return $scope; } - [$statementResult, $dependencies] = $this->collectFileDependencies(fn () => $this->nodeScopeResolver->processStmtNodes( + [$statementResult, $dependencies] = $this->collectDependencies(fn () => $this->nodeScopeResolver->processStmtNodes( $return, $statements, $scope, @@ -1118,40 +1125,44 @@ static function (): void { } /** - * Declare the files on the scope and on the config files and keys being - * typed, whose cached results declare them again later. + * Declare the files and config directories on the scope and on the config files + * and keys being typed, whose cached results declare them again later. * * @param DependencyTracker&Scope $scope - * @param list $files + * @param array $dependencies each path mapped to whether it is a directory */ - private function trackFileDependencies(Scope $scope, array $files): void + private function trackDependencies(Scope $scope, array $dependencies): void { - foreach ($files as $file) { - $scope->trackFileDependency($file); + foreach ($dependencies as $path => $directory) { + if ($directory) { + $scope->trackDirectoryDependency($path, ConfigSourceResolver::CONFIG_FILE_PATTERN); + } else { + $scope->trackFileDependency($path); + } foreach (array_keys($this->dependencyCollectors) as $index) { - $this->dependencyCollectors[$index][$file] = true; + $this->dependencyCollectors[$index][$path] = $directory; } } } /** - * Run the callback, collecting the files declared while it runs. + * Run the callback, collecting the files and directories declared while it runs. * * @param callable(): T $callback * - * @return array{T, list} the callback's result and the files declared while it ran + * @return array{T, array} the callback's result and the paths declared while it ran * * @template T */ - private function collectFileDependencies(callable $callback): array + private function collectDependencies(callable $callback): array { $this->dependencyCollectors[] = []; try { $result = $callback(); } finally { - $dependencies = array_keys(array_pop($this->dependencyCollectors)); + $dependencies = array_pop($this->dependencyCollectors); } return [$result, $dependencies]; @@ -1208,37 +1219,6 @@ private function loadConfigFiles(): void } } - /** - * Get where a file holding the config key's value can be: one path for every - * prefix of the key in every config directory, existing or not, so a - * created file that changes which file holds the value is noticed. - * - * Files created below the key's own directory, which make its value a - * merged one, and new directories matching a glob config path are not - * covered: there is no fixed set of paths to watch for them. - * - * @return list - */ - private function getConfigFileCandidates(string $key): array - { - if (array_key_exists($key, $this->configFileCandidates)) { - return $this->configFileCandidates[$key]; - } - - $candidates = []; - $keyParts = explode('.', $key); - - for ($length = count($keyParts); $length > 0; --$length) { - $relativePath = implode('/', array_slice($keyParts, 0, $length)) . '.php'; - - foreach ($this->configSourceResolver->getConfigDirectories() as $configPath) { - $candidates[] = $configPath . '/' . $relativePath; - } - } - - return $this->configFileCandidates[$key] = $candidates; - } - /** * Find the value the config file returns: its only return statement * outside functions and classes, at the top level of the file or of its diff --git a/src/phpstan/src/Support/ConfigSourceResolver.php b/src/phpstan/src/Support/ConfigSourceResolver.php index 792563d98..348d1c76f 100644 --- a/src/phpstan/src/Support/ConfigSourceResolver.php +++ b/src/phpstan/src/Support/ConfigSourceResolver.php @@ -66,12 +66,16 @@ use function array_fill_keys; use function array_keys; use function array_map; +use function array_slice; use function array_unique; use function array_values; +use function count; use function dirname; +use function explode; use function function_exists; use function glob; use function Hypervel\Testbench\workbench_path; +use function implode; use function in_array; use function is_array; use function is_dir; @@ -100,6 +104,11 @@ final class ConfigSourceResolver { use HasContainer; + /** + * The names of the files a config directory loads, as a directory dependency pattern. + */ + public const string CONFIG_FILE_PATTERN = '*.php'; + /** * Whether each merge method replaces recursively, keyed by lowercase name. */ @@ -122,6 +131,9 @@ final class ConfigSourceResolver /** @var null|list */ private ?array $configDirectories = null; + /** @var null|list */ + private ?array $bootedConfigDirectories = null; + /** * The sources discovered from the loaded providers, or null before discovery. * @@ -171,6 +183,10 @@ public function getConfigDirectories(): array return $this->configDirectories; } + if ($this->configuredDirectories === []) { + return $this->configDirectories = $this->getBootedConfigDirectories(); + } + $directories = []; foreach ($this->configuredDirectories as $configuredDirectory) { @@ -182,18 +198,97 @@ public function getConfigDirectories(): array } } - if ($this->configuredDirectories === []) { - $bootstrapper = $this->getApplication()->make(LoadConfiguration::class); + return $this->configDirectories = array_map($this->fileHelper->normalizePath(...), $directories); + } + + /** + * Get the config directories the booted application loaded, highest priority first. + * + * Testbench's workbench config directory overlays the config path when its config + * discovery is on. + * + * @return list + */ + public function getBootedConfigDirectories(): array + { + if ($this->bootedConfigDirectories !== null) { + return $this->bootedConfigDirectories; + } - if ($bootstrapper instanceof LoadConfigurationWithWorkbench - && (fn (): bool => $this->usesWorkbenchConfigFile)->call($bootstrapper)) { - $directories[] = workbench_path('config'); + $directories = []; + $bootstrapper = $this->getApplication()->make(LoadConfiguration::class); + + if ($bootstrapper instanceof LoadConfigurationWithWorkbench + && (fn (): bool => $this->usesWorkbenchConfigFile)->call($bootstrapper)) { + $directories[] = workbench_path('config'); + } + + $directories[] = $this->getApplication()->configPath(); + + return $this->bootedConfigDirectories = array_map($this->fileHelper->normalizePath(...), $directories); + } + + /** + * Get the paths whose contents can change a config key's loaded value, each mapped to + * whether it is a directory of config files. + * + * In each config directory, these are the file of every prefix of the key, existing or + * not, and the directory below the key, whose files make its value a merged one. Then + * the framework's file for the key and the package files merged into an overlapping + * key. New directories matching a glob config path are not covered. + * + * @param list $directories the config directories the value loads from + * @return array + */ + public function getKeySources(string $key, array $directories): array + { + $sources = []; + $segments = explode('.', $key); + + foreach ($directories as $directory) { + for ($length = count($segments); $length > 0; --$length) { + $sources[$directory . '/' . implode('/', array_slice($segments, 0, $length)) . '.php'] = false; } - $directories[] = $this->getApplication()->configPath(); + $sources[$directory . '/' . implode('/', $segments)] = true; } - return $this->configDirectories = array_map($this->fileHelper->normalizePath(...), $directories); + if ($this->shouldMergeFrameworkConfiguration()) { + $sources[$this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath() . '/' . $segments[0] . '.php')] = false; + } + + foreach ($this->getMerges() as $merge) { + if ($merge['path'] !== null && $this->keysOverlap($key, $merge['key'])) { + $sources[$merge['path']] = false; + } + } + + return $sources; + } + + /** + * Get the paths whose contents can change any loaded config value, each mapped to whether + * it is a directory of config files: the config directories, the framework's config + * directory and the package files merged into the configuration. + * + * @param list $directories the config directories the values load from + * @return array + */ + public function getAllSources(array $directories): array + { + $sources = array_fill_keys($directories, true); + + if ($this->shouldMergeFrameworkConfiguration()) { + $sources[$this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath())] = true; + } + + foreach ($this->getMerges() as $merge) { + if ($merge['path'] !== null) { + $sources[$merge['path']] = false; + } + } + + return $sources; } /** diff --git a/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php b/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php new file mode 100644 index 000000000..22b8b9e41 --- /dev/null +++ b/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php @@ -0,0 +1,248 @@ +. + */ +class GenericModelPropertyType extends StringType +{ + /** + * Create a new generic model property type instance. + */ + public function __construct(private Type $type, private ModelPropertyHelper $modelPropertyHelper) + { + parent::__construct(); + } + + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + return 'model property of ' . $this->type->describe($level); + } + + /** + * Get the classes the type refers to. + * + * @return string[] + */ + public function getReferencedClasses(): array + { + return $this->getGenericType()->getReferencedClasses(); + } + + /** + * Get the model type whose properties the string names. + */ + public function getGenericType(): Type + { + return $this->type; + } + + /** + * Determine whether the type equals another. + */ + public function equals(Type $type): bool + { + return $type instanceof self && $this->type->equals($type->type); + } + + /** + * Determine whether the type accepts another, checking a constant string against the model's properties. + * + * A "table.column" string is checked against that table on the model's connection. + */ + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + if (count($type->getConstantStrings()) === 1) { + $givenString = $type->getConstantStrings()[0]->getValue(); + $genericType = $this->getGenericType(); + + if ($genericType instanceof MixedType) { + return AcceptsResult::createYes(); + } + + if (count($genericType->getObjectClassNames()) < 1) { + return AcceptsResult::createYes(); + } + + if (str_contains($givenString, ' as ')) { + $givenString = explode(' as ', $givenString)[0]; + } + + if (str_contains($givenString, '.')) { + $parts = explode('.', $givenString); + + if (count($parts) !== 2) { + return AcceptsResult::createYes(); + } + + [$tableName, $propertyName] = $parts; + + foreach ($genericType->getObjectClassReflections() as $classReflection) { + if ($this->modelPropertyHelper->hasTableColumn($classReflection, $tableName, $propertyName)) { + return AcceptsResult::createYes(); + } + } + + return AcceptsResult::createNo([sprintf('Database table "%s" does not have column "%s"', $tableName, $propertyName)]); + } + + $reasons = []; + + if (! $genericType->hasInstanceProperty($givenString)->yes()) { + $reasons[] = sprintf('The given string should be a property of %s, %s given.', $this->type->describe(VerbosityLevel::value()), $givenString); + } + + return new AcceptsResult($genericType->hasInstanceProperty($givenString), $reasons); + } + + if ($type instanceof self) { + return new AcceptsResult($this->getGenericType()->accepts($type->getGenericType(), $strictTypes)->result, [sprintf('The given string should be a property of %s', $this->type->describe(VerbosityLevel::value()))]); + } + + if ($type->isString()->yes()) { + // It is an arbitrary string, so we cannot check if it is a property of the model. + // We return yes to not cause issues on levels 7+ + return AcceptsResult::createYes(); + } + + return AcceptsResult::createNo(); + } + + /** + * Determine whether the type is a supertype of another. + */ + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + $constantStrings = $type->getConstantStrings(); + + if (count($constantStrings) === 1) { + if (! $this->getGenericType()->hasInstanceProperty($constantStrings[0]->getValue())->yes()) { + return IsSuperTypeOfResult::createNo(); + } + + return IsSuperTypeOfResult::createYes(); + } + + if ($type instanceof self) { + return $this->getGenericType()->isSuperTypeOf($type->getGenericType()); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + /** + * Map the model type through a callback. + */ + public function traverse(callable $cb): Type + { + $newType = $cb($this->getGenericType()); + + if ($newType === $this->getGenericType()) { + return $this; + } + + return new self($newType, $this->modelPropertyHelper); + } + + /** + * Infer the model template type from a received type. + */ + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + // @phpstan-ignore phpstanApi.instanceofType (Each member infers on its own, as in PHPStan's generic types.) + if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) { + return $receivedType->inferTemplateTypesOn($this); + } + + $constantStrings = $receivedType->getConstantStrings(); + + if (count($constantStrings) === 1) { + $typeToInfer = new ObjectType($constantStrings[0]->getValue()); + } elseif ($receivedType instanceof self) { + $typeToInfer = $receivedType->type; + } elseif ($receivedType->isClassString()->yes()) { + $typeToInfer = $this->getGenericType(); + + if ($typeToInfer instanceof TemplateType) { + $typeToInfer = $typeToInfer->getBound(); + } + + $typeToInfer = TypeCombinator::intersect($typeToInfer, new ObjectWithoutClassType); + } else { + return TemplateTypeMap::createEmpty(); + } + + if (! $this->getGenericType()->isSuperTypeOf($typeToInfer)->no()) { + return $this->getGenericType()->inferTemplateTypes($typeToInfer); + } + + return TemplateTypeMap::createEmpty(); + } + + /** + * Get the template types the model type refers to. + * + * @return TemplateTypeReference[] + */ + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $variance = $positionVariance->compose(TemplateTypeVariance::createCovariant()); + + return $this->getGenericType()->getReferencedTemplateTypes($variance); + } + + /** + * Determine whether the type has template or late-resolvable parts. + */ + public function hasTemplateOrLateResolvableType(): bool + { + return true; + } + + /** + * Restore the type from var_export() output. + * + * @param mixed[] $properties + */ + public static function __set_state(array $properties): Type + { + return new self($properties['type'], $properties['modelPropertyHelper']); + } +} diff --git a/src/phpstan/src/Types/ModelProperty/ModelPropertyTypeNodeResolverExtension.php b/src/phpstan/src/Types/ModelProperty/ModelPropertyTypeNodeResolverExtension.php new file mode 100644 index 000000000..fd6caa6f9 --- /dev/null +++ b/src/phpstan/src/Types/ModelProperty/ModelPropertyTypeNodeResolverExtension.php @@ -0,0 +1,83 @@ + type node. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof GenericTypeNode) { + return null; + } + + if ((string) $typeNode->type !== 'model-property') { + return null; + } + + if (! $this->active) { + return new StringType; + } + + if (count($typeNode->genericTypes) !== 1) { + return new ErrorType; + } + + $genericType = $this->baseResolver->resolve($typeNode->genericTypes[0], $nameScope); + + if ((new ObjectType(Model::class))->isSuperTypeOf($genericType)->no()) { + return new ErrorType; + } + + if ($genericType instanceof NeverType) { + return new ErrorType; + } + + // A file calling a method with such a parameter does not have to use a model itself + $className = $nameScope->getClassName(); + + if ($className !== null && $this->reflectionProvider->hasClass($className)) { + $this->modelPropertyHelper->trackSchemaDependency($this->reflectionProvider->getClass($className)); + } + + return new GenericModelPropertyType($genericType, $this->modelPropertyHelper); + } +} From 53e526511048766009b357294a4a438904837234 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:21:33 +0000 Subject: [PATCH 107/303] Cover model schema inference and warm-cache dependency changes Extend the analyzer fixture application with model metadata, migration inputs and real schema dumps for the supported drivers. Port the applicable model-property, cast, serialization and generic-property fixtures and add targeted regressions for Hypervel's connection, accessor and fetch behavior. Exercise warm-cache changes to casts, model metadata, external migration constants, guards and custom builder/query declarations. Isolate model, relation and direct builder callers so one route cannot mask another's missing dependency, and verify recovery after restoring declarations. Validation: 1464 analyzer tests passed, including the isolated cache regressions; source and Eloquent type analysis passed. Composer's development classmap includes the unit data fixtures used by reflection. --- composer.json | 3 +- .../application/app/Models/BaseModel.php | 21 + .../application/app/Models/BuilderUsage.php | 27 + .../application/app/Models/Columns.php | 10 + .../application/app/Models/FlagColumns.php | 10 + .../application/app/Models/Group.php | 26 + .../application/app/Models/ModelUsage.php | 19 + .../application/app/Models/RelationUsage.php | 27 + .../application/app/Models/Usage.php | 24 + .../application/app/Models/User.php | 25 + .../application/app/Models/UserBuilder.php | 38 + .../application/app/Models/UserQuery.php | 18 + .../application/bootstrap/app.php | 7 + .../application/bootstrap/autoload.php | 10 + .../application/config/models.php | 7 + .../application/config/schema.php | 7 + .../2026_01_01_000000_create_users_table.php | 29 + .../2026_01_02_000000_create_flags_table.php | 35 + tests/PHPStan/Fixtures/OverridesConfig.php | 30 + tests/PHPStan/Fixtures/ReadsSchemaFacts.php | 87 ++ .../Fixtures/SchemaAggregator/Author.php | 9 + .../SchemaAggregator/BackedStatus.php | 10 + .../ConnectionMethodMigration.php | 18 + .../ConnectionPropertyMigration.php | 12 + .../Fixtures/SchemaAggregator/Constants.php | 17 + .../SchemaAggregator/InheritedConstants.php | 10 + .../Fixtures/SchemaAggregator/KeyedByUuid.php | 12 + .../Fixtures/SchemaAggregator/Person.php | 14 + .../Fixtures/SchemaAggregator/PureStatus.php | 10 + .../SchemaAggregator/TypedConstants.php | 12 + .../SourceFiles/SelfReferencingConstants.php | 12 + tests/PHPStan/Integration/IntegrationTest.php | 11 + .../Integration/data/model-accessors.php | 106 ++ .../ForwardedBuilderMethodExtensionTest.php | 3 + tests/PHPStan/ModelPropertyDependencyTest.php | 268 ++++ .../Rules/UnusableSchemaDumpRuleTest.php | 44 + .../Rules/data/unusable-schema-dump.php | 49 + .../Rules/data/unusable-schema-dumps.neon | 5 + .../unusable-schema-dumps/mariadb-schema.sql | 5 + .../unusable-schema-dumps/mysql-schema.sql | 5 + .../unusable-schema-dumps/pgsql-schema.dump | Bin 0 -> 14 bytes tests/PHPStan/Type/BleedingEdgeTypeTest.php | 1 + .../ConfigDynamicReturnTypeExtensionTest.php | 52 +- .../PHPStan/Type/ConfigFileResolutionTest.php | 25 +- tests/PHPStan/Type/GeneralTypeTest.php | 9 + .../Type/GenericModelPropertyTypeTest.php | 199 +++ tests/PHPStan/Type/ModelCastsParsingTest.php | 33 + tests/PHPStan/Type/data/arr-map.php | 3 +- .../collection-usage-inference.php | 56 + .../Type/data/collection-stubs-l12-20.php | 3 +- .../data/config-check-model-properties.neon | 4 + .../higher-order-collection-proxy-methods.php | 102 ++ .../Type/data/model-casts-parsing-trait.php | 18 + .../PHPStan/Type/data/model-casts-parsing.php | 326 +++++ .../Type/data/model-date-column-casts.php | 14 + .../Type/data/model-properties-l13-2.php | 12 + .../Type/data/model-properties-l13.php | 12 + tests/PHPStan/Type/data/model-properties.php | 162 +++ .../Type/data/model-serialization-l13.php | 49 + .../PHPStan/Type/data/model-serialization.php | 334 +++++ .../Unit/ConnectionConfigResolverTest.php | 90 ++ .../Unit/DataTypeToPhpTypeConverterTest.php | 81 ++ tests/PHPStan/Unit/DirectoryResolverTest.php | 86 ++ tests/PHPStan/Unit/MigrationCacheTest.php | 287 ++++ tests/PHPStan/Unit/MigrationHelperTest.php | 384 ++++++ tests/PHPStan/Unit/ModelCastHelperTest.php | 81 ++ .../PHPStan/Unit/ModelPropertyHelperTest.php | 307 +++++ ...lPropertyTypeNodeResolverExtensionTest.php | 55 + tests/PHPStan/Unit/SchemaAggregatorTest.php | 1162 +++++++++++++++++ tests/PHPStan/Unit/SourceFilesTest.php | 25 + .../Unit/SquashedMigrationHelperTest.php | 304 +++++ .../Unit/Support/ConfigSourceResolverTest.php | 30 + .../2021_02_01_000000_create_teams_table.php | 25 + .../2020_01_30_000000_create_users_table.php | 25 + .../2020_01_30_000000_create_users_table.php | 25 + .../2020_01_31_000000_alter_users_table.php | 53 + .../2020_01_30_000000_create_users_table.php | 23 + ..._31_000000_add_address1_to_users_table.php | 26 + ..._31_000000_add_address2_to_users_table.php | 28 + ..._01_31_000000_add_email_to_users_table.php | 28 + .../2020_01_30_000000_create_users_table.php | 25 + .../Unit/data/migration_with_const/Table.php | 8 + .../2020_01_30_000000_create_users_table.php | 29 + ...020_01_31_000000_create_articles_table.php | 27 + .../2020_01_30_000000_create_users_table.php | 22 + .../2020_01_30_000000_create_users_table.php | 24 + .../2020_01_31_000000_add_to_users_table.php | 24 + ..._000000_create_add_drop_to_users_table.php | 31 + .../2020_01_30_000000_create_users_table.php | 29 + .../2020_01_30_000000_create_users_table.php | 29 + .../2020_01_30_000000_create_users_table.php | 26 + .../2020_01_30_000000_create_users_table.php | 25 + ...0_create_users_table_with_soft_deletes.php | 26 + ...ter_users_table_to_use_soft_deletes_tz.php | 26 + .../2020_01_30_000000_create_users_table.php | 35 + .../data/model-property-schema-dependency.php | 10 + .../Unit/data/model-schema-dependency.php | 10 + .../Unit/data/no-model-schema-dependency.php | 8 + .../2021_12_08_000000_rename_users_table.php | 30 + .../schema/basic_schema/mysql-schema.dump | 24 + .../mysql-schema.sql | 19 + .../mysql-schema.dump | 19 + .../mysql-schema.sql | 14 + .../mariadb-schema.sql | 19 + .../mysql-schema.dump | 19 + .../pgsql_conflicting_types/pgsql-schema.sql | 18 + .../pgsql_types_and_views/pgsql-schema.sql | 21 + .../schema/pgsql_with_issues/pgsql-schema.sql | 11 + .../unapplied_alteration/sqlite-schema.sql | 3 + .../schema/unusable_dumps/mysql-schema.sql | 5 + .../schema/unusable_dumps/pgsql-schema.dump | Bin 0 -> 14 bytes .../application/app/Casts/FormattedDate.php | 27 + .../application/app/ChildTeamBuilder.php | 9 + .../PHPStan/application/app/GuardedModel.php | 22 + tests/PHPStan/application/app/Member.php | 11 + .../application/app/MemberWithCustomKey.php | 11 + .../MemberWithNonIncrementingStringKey.php | 11 + .../app/MemberWithoutTimestampsAttribute.php | 13 + .../app/MemberWithoutTimestampsTable.php | 11 + .../application/app/ModelPropertyConsumer.php | 13 + .../app/ModelWithDateColumnCasts.php | 22 + tests/PHPStan/application/app/RoleUser.php | 27 + tests/PHPStan/application/app/Team.php | 37 + tests/PHPStan/application/app/TeamBuilder.php | 12 + tests/PHPStan/application/app/Thread.php | 61 + tests/PHPStan/application/app/UlidModel.php | 13 + tests/PHPStan/application/app/User.php | 2 +- tests/PHPStan/application/app/UuidModel.php | 13 + ...0000_create_model_with_casts_table.php.php | 45 + .../2020_01_30_000000_create_users_table.php | 70 + ...020_03_20_000000_create_accounts_table.php | 24 + .../2020_03_25_000000_create_roles_table.php | 24 + ..._25_190200_create_guarded_models_table.php | 25 + .../2020_10_04_000000_create_posts_table.php | 30 + ...21_09_10_000000_create_addresses_table.php | 36 + ...23_06_12_000000_create_role_user_table.php | 25 + ..._02_12_000000_create_ulid_models_table.php | 23 + ..._02_12_000000_create_uuid_models_table.php | 23 + .../database/migrations/Schema.php | 9 + .../database/schema/mariadb-schema.sql | 224 ++++ .../database/schema/mysql-schema.sql | 222 ++++ .../database/schema/pgsql-schema.sql | 558 ++++++++ .../database/schema/sqlite-schema.sql | 24 + tests/PHPStan/phpunit-bootstrap.php | 3 +- 144 files changed, 7895 insertions(+), 32 deletions(-) create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/BaseModel.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/BuilderUsage.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Columns.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/FlagColumns.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Group.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/ModelUsage.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/RelationUsage.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Usage.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/User.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserBuilder.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserQuery.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/app.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/autoload.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/models.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/schema.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_01_000000_create_users_table.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_02_000000_create_flags_table.php create mode 100644 tests/PHPStan/Fixtures/OverridesConfig.php create mode 100644 tests/PHPStan/Fixtures/ReadsSchemaFacts.php create mode 100644 tests/PHPStan/Fixtures/SchemaAggregator/Author.php create mode 100644 tests/PHPStan/Fixtures/SchemaAggregator/BackedStatus.php create mode 100644 tests/PHPStan/Fixtures/SchemaAggregator/ConnectionMethodMigration.php create mode 100644 tests/PHPStan/Fixtures/SchemaAggregator/ConnectionPropertyMigration.php create mode 100644 tests/PHPStan/Fixtures/SchemaAggregator/Constants.php create mode 100644 tests/PHPStan/Fixtures/SchemaAggregator/InheritedConstants.php create mode 100644 tests/PHPStan/Fixtures/SchemaAggregator/KeyedByUuid.php create mode 100644 tests/PHPStan/Fixtures/SchemaAggregator/Person.php create mode 100644 tests/PHPStan/Fixtures/SchemaAggregator/PureStatus.php create mode 100644 tests/PHPStan/Fixtures/SchemaAggregator/TypedConstants.php create mode 100644 tests/PHPStan/Fixtures/SourceFiles/SelfReferencingConstants.php create mode 100644 tests/PHPStan/Integration/data/model-accessors.php create mode 100644 tests/PHPStan/ModelPropertyDependencyTest.php create mode 100644 tests/PHPStan/Rules/UnusableSchemaDumpRuleTest.php create mode 100644 tests/PHPStan/Rules/data/unusable-schema-dump.php create mode 100644 tests/PHPStan/Rules/data/unusable-schema-dumps.neon create mode 100644 tests/PHPStan/Rules/data/unusable-schema-dumps/mariadb-schema.sql create mode 100644 tests/PHPStan/Rules/data/unusable-schema-dumps/mysql-schema.sql create mode 100644 tests/PHPStan/Rules/data/unusable-schema-dumps/pgsql-schema.dump create mode 100644 tests/PHPStan/Type/GenericModelPropertyTypeTest.php create mode 100644 tests/PHPStan/Type/ModelCastsParsingTest.php create mode 100644 tests/PHPStan/Type/data/bleeding-edge/collection-usage-inference.php create mode 100644 tests/PHPStan/Type/data/config-check-model-properties.neon create mode 100644 tests/PHPStan/Type/data/higher-order-collection-proxy-methods.php create mode 100644 tests/PHPStan/Type/data/model-casts-parsing-trait.php create mode 100644 tests/PHPStan/Type/data/model-casts-parsing.php create mode 100644 tests/PHPStan/Type/data/model-date-column-casts.php create mode 100644 tests/PHPStan/Type/data/model-properties-l13-2.php create mode 100644 tests/PHPStan/Type/data/model-properties-l13.php create mode 100644 tests/PHPStan/Type/data/model-properties.php create mode 100644 tests/PHPStan/Type/data/model-serialization-l13.php create mode 100644 tests/PHPStan/Type/data/model-serialization.php create mode 100644 tests/PHPStan/Unit/ConnectionConfigResolverTest.php create mode 100644 tests/PHPStan/Unit/DataTypeToPhpTypeConverterTest.php create mode 100644 tests/PHPStan/Unit/DirectoryResolverTest.php create mode 100644 tests/PHPStan/Unit/MigrationCacheTest.php create mode 100644 tests/PHPStan/Unit/MigrationHelperTest.php create mode 100644 tests/PHPStan/Unit/ModelCastHelperTest.php create mode 100644 tests/PHPStan/Unit/ModelPropertyHelperTest.php create mode 100644 tests/PHPStan/Unit/ModelPropertyTypeNodeResolverExtensionTest.php create mode 100644 tests/PHPStan/Unit/SchemaAggregatorTest.php create mode 100644 tests/PHPStan/Unit/SourceFilesTest.php create mode 100644 tests/PHPStan/Unit/SquashedMigrationHelperTest.php create mode 100644 tests/PHPStan/Unit/data/additional_migrations/2021_02_01_000000_create_teams_table.php create mode 100644 tests/PHPStan/Unit/data/basic_migration/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/complex_migrations/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/complex_migrations/2020_01_31_000000_alter_users_table.php create mode 100644 tests/PHPStan/Unit/data/conditional_migrations/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_address1_to_users_table.php create mode 100644 tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_address2_to_users_table.php create mode 100644 tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_email_to_users_table.php create mode 100644 tests/PHPStan/Unit/data/migration_with_const/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/migration_with_const/Table.php create mode 100644 tests/PHPStan/Unit/data/migration_with_default_arguments/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/migration_with_foreign_id_for/2020_01_31_000000_create_articles_table.php create mode 100644 tests/PHPStan/Unit/data/migration_with_schema_connection/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/migrations_using_after_method/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/migrations_using_after_method/2020_01_31_000000_add_to_users_table.php create mode 100644 tests/PHPStan/Unit/data/migrations_using_drop_column/2023_08_07_000000_create_add_drop_to_users_table.php create mode 100644 tests/PHPStan/Unit/data/migrations_using_drop_remember_token/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/migrations_using_drop_timestamps/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/migrations_using_nullable/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/migrations_using_nullable_timestamps_tz/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/migrations_using_soft_deletes/2020_01_30_000000_create_users_table_with_soft_deletes.php create mode 100644 tests/PHPStan/Unit/data/migrations_using_soft_deletes_tz/2020_01_31_000000_alter_users_table_to_use_soft_deletes_tz.php create mode 100644 tests/PHPStan/Unit/data/migrations_with_different_methods/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/Unit/data/model-property-schema-dependency.php create mode 100644 tests/PHPStan/Unit/data/model-schema-dependency.php create mode 100644 tests/PHPStan/Unit/data/no-model-schema-dependency.php create mode 100644 tests/PHPStan/Unit/data/rename_migrations/2021_12_08_000000_rename_users_table.php create mode 100644 tests/PHPStan/Unit/data/schema/basic_schema/mysql-schema.dump create mode 100644 tests/PHPStan/Unit/data/schema/basic_schema_with_sql_extension/mysql-schema.sql create mode 100644 tests/PHPStan/Unit/data/schema/multiple_schemas_for_same_table/mysql-schema.dump create mode 100644 tests/PHPStan/Unit/data/schema/multiple_schemas_for_same_table/mysql-schema.sql create mode 100644 tests/PHPStan/Unit/data/schema/multiple_schemas_with_different_extensions/mariadb-schema.sql create mode 100644 tests/PHPStan/Unit/data/schema/multiple_schemas_with_different_extensions/mysql-schema.dump create mode 100644 tests/PHPStan/Unit/data/schema/pgsql_conflicting_types/pgsql-schema.sql create mode 100644 tests/PHPStan/Unit/data/schema/pgsql_types_and_views/pgsql-schema.sql create mode 100644 tests/PHPStan/Unit/data/schema/pgsql_with_issues/pgsql-schema.sql create mode 100644 tests/PHPStan/Unit/data/schema/unapplied_alteration/sqlite-schema.sql create mode 100644 tests/PHPStan/Unit/data/schema/unusable_dumps/mysql-schema.sql create mode 100644 tests/PHPStan/Unit/data/schema/unusable_dumps/pgsql-schema.dump create mode 100644 tests/PHPStan/application/app/Casts/FormattedDate.php create mode 100644 tests/PHPStan/application/app/ChildTeamBuilder.php create mode 100644 tests/PHPStan/application/app/GuardedModel.php create mode 100644 tests/PHPStan/application/app/Member.php create mode 100644 tests/PHPStan/application/app/MemberWithCustomKey.php create mode 100644 tests/PHPStan/application/app/MemberWithNonIncrementingStringKey.php create mode 100644 tests/PHPStan/application/app/MemberWithoutTimestampsAttribute.php create mode 100644 tests/PHPStan/application/app/MemberWithoutTimestampsTable.php create mode 100644 tests/PHPStan/application/app/ModelPropertyConsumer.php create mode 100644 tests/PHPStan/application/app/ModelWithDateColumnCasts.php create mode 100644 tests/PHPStan/application/app/RoleUser.php create mode 100644 tests/PHPStan/application/app/Team.php create mode 100644 tests/PHPStan/application/app/TeamBuilder.php create mode 100644 tests/PHPStan/application/app/Thread.php create mode 100644 tests/PHPStan/application/app/UlidModel.php create mode 100644 tests/PHPStan/application/app/UuidModel.php create mode 100644 tests/PHPStan/application/database/migrations/2020_01_30_000000_create_model_with_casts_table.php.php create mode 100644 tests/PHPStan/application/database/migrations/2020_01_30_000000_create_users_table.php create mode 100644 tests/PHPStan/application/database/migrations/2020_03_20_000000_create_accounts_table.php create mode 100644 tests/PHPStan/application/database/migrations/2020_03_25_000000_create_roles_table.php create mode 100644 tests/PHPStan/application/database/migrations/2020_09_25_190200_create_guarded_models_table.php create mode 100644 tests/PHPStan/application/database/migrations/2020_10_04_000000_create_posts_table.php create mode 100644 tests/PHPStan/application/database/migrations/2021_09_10_000000_create_addresses_table.php create mode 100644 tests/PHPStan/application/database/migrations/2023_06_12_000000_create_role_user_table.php create mode 100644 tests/PHPStan/application/database/migrations/2025_02_12_000000_create_ulid_models_table.php create mode 100644 tests/PHPStan/application/database/migrations/2025_02_12_000000_create_uuid_models_table.php create mode 100644 tests/PHPStan/application/database/migrations/Schema.php create mode 100644 tests/PHPStan/application/database/schema/mariadb-schema.sql create mode 100644 tests/PHPStan/application/database/schema/mysql-schema.sql create mode 100644 tests/PHPStan/application/database/schema/pgsql-schema.sql create mode 100644 tests/PHPStan/application/database/schema/sqlite-schema.sql diff --git a/composer.json b/composer.json index 048358588..a65a9c792 100644 --- a/composer.json +++ b/composer.json @@ -376,7 +376,8 @@ "classmap": [ "tests/PHPStan/Integration/data", "tests/PHPStan/Rules/data", - "tests/PHPStan/Type/data" + "tests/PHPStan/Type/data", + "tests/PHPStan/Unit/data" ], "files": [ "tests/Database/Fixtures/MigrationCreatorFakeMigration.php" diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/BaseModel.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/BaseModel.php new file mode 100644 index 000000000..7532ba92b --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/BaseModel.php @@ -0,0 +1,21 @@ +exists(); + } + + /** + * Call a method of the builder's query class. + */ + public function active(UserBuilder $builder): void + { + $builder->whereActive(); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Columns.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Columns.php new file mode 100644 index 000000000..38accb5e9 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Columns.php @@ -0,0 +1,10 @@ +> */ + use HasBuilder; + + protected static string $builder = UserBuilder::class; + + /** + * Get the group's subgroups. + * + * @return HasMany + */ + public function subgroups(): HasMany + { + return $this->hasMany(Group::class, 'parent_id'); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/ModelUsage.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/ModelUsage.php new file mode 100644 index 000000000..62dd16dcf --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/ModelUsage.php @@ -0,0 +1,19 @@ +subgroups()->exists(); + } + + /** + * Call a builder method through a relation. + */ + public function publishedSubgroups(Group $group): void + { + $group->subgroups()->published(); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Usage.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Usage.php new file mode 100644 index 000000000..a6088b480 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Usage.php @@ -0,0 +1,24 @@ +nickname; + } + + /** + * Read a column the model casts. + */ + public function name(User $user): string + { + return $user->name; + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/User.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/User.php new file mode 100644 index 000000000..0084b3c0d --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/User.php @@ -0,0 +1,25 @@ +> */ + use HasBuilder; + + protected static string $builder = UserBuilder::class; + + /** + * Get the attributes that should be cast. + */ + protected function casts(): array + { + return [ + 'name' => 'string', + ]; + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserBuilder.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserBuilder.php new file mode 100644 index 000000000..f216af2df --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserBuilder.php @@ -0,0 +1,38 @@ + + */ +class UserBuilder extends Builder +{ + /** @var UserQuery */ + protected QueryBuilder $query; + + protected array $passthru = ['exists']; + + /** + * Get the underlying query builder instance. + */ + public function getQuery(): UserQuery + { + return $this->query; + } + + /** + * Constrain the query to published users. + */ + public function published(): static + { + return $this->whereNotNull('published_at'); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserQuery.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserQuery.php new file mode 100644 index 000000000..fa3c3a57e --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserQuery.php @@ -0,0 +1,18 @@ +where('active', true); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/app.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/app.php new file mode 100644 index 000000000..72856d7b0 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/app.php @@ -0,0 +1,7 @@ +create(); diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/autoload.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/autoload.php new file mode 100644 index 000000000..382da40a0 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/autoload.php @@ -0,0 +1,10 @@ +addPsr4('ModelPropertyDependency\Models\\', dirname(__DIR__) . '/app/Models'); +$loader->register(); diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/models.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/models.php new file mode 100644 index 000000000..5115ea3c0 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/models.php @@ -0,0 +1,7 @@ + 'sqlite', +]; diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/schema.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/schema.php new file mode 100644 index 000000000..5115ea3c0 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/schema.php @@ -0,0 +1,7 @@ + 'sqlite', +]; diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_01_000000_create_users_table.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_01_000000_create_users_table.php new file mode 100644 index 000000000..fe6096a5a --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_01_000000_create_users_table.php @@ -0,0 +1,29 @@ +id(); + $table->string(Columns::NAME); + }); + } +}; diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_02_000000_create_flags_table.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_02_000000_create_flags_table.php new file mode 100644 index 000000000..4111211b5 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_02_000000_create_flags_table.php @@ -0,0 +1,35 @@ +id(); + $table->string(FlagColumns::NAME); + }); + + if (Schema::hasColumn('flags', 'nickname')) { + Schema::table('users', function (Blueprint $table) { + $table->string('nickname'); + }); + } + } +}; diff --git a/tests/PHPStan/Fixtures/OverridesConfig.php b/tests/PHPStan/Fixtures/OverridesConfig.php new file mode 100644 index 000000000..3ab8e9e4d --- /dev/null +++ b/tests/PHPStan/Fixtures/OverridesConfig.php @@ -0,0 +1,30 @@ + $values + * @param callable(): TResult $callback + * @return TResult + */ + private function withConfig(array $values, callable $callback): mixed + { + $items = config()->all(); + + try { + config($values); + + return $callback(); + } finally { + config()->replaceItems($items); + } + } +} diff --git a/tests/PHPStan/Fixtures/ReadsSchemaFacts.php b/tests/PHPStan/Fixtures/ReadsSchemaFacts.php new file mode 100644 index 000000000..8e98608ab --- /dev/null +++ b/tests/PHPStan/Fixtures/ReadsSchemaFacts.php @@ -0,0 +1,87 @@ + $connections + */ + protected function findSchema(array $connections, ?string $connection = null): ?SchemaConnection + { + return $connections[$this->connectionConfig($connection)->owner] ?? null; + } + + /** + * Get the facts of a table as a model on the connection looks it up, or null when it is not known. + * + * @param array $connections + */ + protected function findTable(array $connections, string $table, ?string $connection = null): ?SchemaTable + { + $config = $this->connectionConfig($connection); + $schema = $connections[$config->owner] ?? null; + [$tableSchema, $name] = $config->physicalTable($table); + $key = $schema?->findTableKey($tableSchema, $name, $config->defaultSchema, $config->searchPath); + + return $schema !== null && is_string($key) ? $schema->tables[$key] : null; + } + + /** + * Get a table's columns as their read types (null when unknown) and nullability, or null when the table is not known. + * + * @param array $connections + * + * @return null|array + */ + protected function describeColumns(array $connections, string $table, ?string $connection = null): ?array + { + $config = $this->connectionConfig($connection); + $schema = $this->findSchema($connections, $connection); + $tableFacts = $this->findTable($connections, $table, $connection); + + if ($schema === null || $tableFacts === null) { + return null; + } + + $converter = self::getContainer()->getByType(DataTypeToPhpTypeConverter::class); + $columns = []; + + foreach ($tableFacts->columns as $column) { + $columns[$column->name] = [ + $column->type === null + ? null + : $converter->convert($schema->driver, $column->type, $column->options, $schema, $tableFacts->searchPath ?? $config->searchPath)['readable']->describe(VerbosityLevel::precise()), + $column->nullable, + ]; + } + + return $columns; + } + + /** + * Get the facts of a connection, or of the default connection when no name is given, failing when they cannot be resolved. + */ + private function connectionConfig(?string $connection): ConnectionConfig + { + $resolver = self::getContainer()->getByType(ConnectionConfigResolver::class); + $config = $resolver->connection($connection ?? (string) $resolver->getDefaultConnectionName()); + + $this->assertNotNull($config); + + return $config; + } +} diff --git a/tests/PHPStan/Fixtures/SchemaAggregator/Author.php b/tests/PHPStan/Fixtures/SchemaAggregator/Author.php new file mode 100644 index 000000000..78b7a8ce4 --- /dev/null +++ b/tests/PHPStan/Fixtures/SchemaAggregator/Author.php @@ -0,0 +1,9 @@ + ['Property ModelAccessors\AccessorModel::$name (string) does not accept int.'], + 87 => ['Property ModelAccessors\AccessorModel::$email (int) does not accept string.'], + 92 => ['Property ModelAccessors\AccessorModel::$virtual (string) does not accept int.'], + 96 => ['Property ModelAccessors\AccessorModel::$password (int) does not accept string.'], + ], + ]; + // Hypervel: optional() passes its callback only values that are not null. yield [__DIR__ . '/data/optional-callbacks.php']; diff --git a/tests/PHPStan/Integration/data/model-accessors.php b/tests/PHPStan/Integration/data/model-accessors.php new file mode 100644 index 000000000..84e0284de --- /dev/null +++ b/tests/PHPStan/Integration/data/model-accessors.php @@ -0,0 +1,106 @@ + 'integer', 'blocked' => 'boolean']; + + /** @return Attribute */ + protected function name(): Attribute + { + return Attribute::get(static fn (): int => 1); + } + + /** @return Attribute */ + protected function email(): Attribute + { + return Attribute::set(static fn (int $value): string => (string) $value); + } + + /** @return Attribute */ + protected function virtual(): Attribute + { + return Attribute::set(static fn (string $value): int => (int) $value); + } + + /** @return Attribute */ + protected function blocked(): Attribute + { + return Attribute::set(static fn (bool $value): bool => $value); + } + + public function setPasswordAttribute(int $value): void + { + $this->attributes['password'] = (string) $value; + } + + public function getSecretAttribute(): int + { + return 1; + } + + public function secret(): string + { + return 'unrelated'; + } +} + +class ParentAccessorModel extends Model +{ + protected ?string $table = 'users'; + + public function getStringAttribute(): int + { + return 1; + } +} + +class ChildAccessorModel extends ParentAccessorModel +{ + /** @return Attribute */ + protected function string(): Attribute + { + return Attribute::get(static fn (): float => 1.5); + } +} + +function accessors(AccessorModel $model, ChildAccessorModel $child): void +{ + // A get-only Attribute reads its getter's type; Eloquent writes the column directly. + assertType('int', $model->name); + $model->name = 'name'; + $model->name = 1; + + // A set-only Attribute accepts its setter's input; Eloquent reads the column directly. + assertType('string', $model->email); + $model->email = 1; + $model->email = 'email'; + + // A virtual attribute without a getter reads its raw value through its cast. + assertType('int', $model->virtual); + $model->virtual = 'value'; + $model->virtual = 1; + + // A legacy setter types writes by its parameter. + $model->password = 1; + $model->password = 'password'; + + // A legacy getter applies beside a method that is not an Attribute accessor. + assertType('int', $model->secret); + + // Eloquent finds an inherited legacy getter before the child's Attribute getter. + assertType('int', $child->string); + + // A set-only Attribute serializes through the column's cast. + assertType('bool|null', $model->attributesToArray()['blocked'] ?? null); +} diff --git a/tests/PHPStan/Methods/ForwardedBuilderMethodExtensionTest.php b/tests/PHPStan/Methods/ForwardedBuilderMethodExtensionTest.php index 14b1d2fe7..6ef03845a 100644 --- a/tests/PHPStan/Methods/ForwardedBuilderMethodExtensionTest.php +++ b/tests/PHPStan/Methods/ForwardedBuilderMethodExtensionTest.php @@ -5,11 +5,13 @@ namespace Hypervel\Tests\PHPStan\Methods; use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\PHPStan\Methods\BuilderDependencies; use Hypervel\PHPStan\Methods\BuilderMacroResolver; use Hypervel\PHPStan\Methods\ForwardedBuilderMethodExtension; use Hypervel\PHPStan\Methods\Macros\SoftDeletingMacros; use Hypervel\PHPStan\Methods\ModelScopeMethodResolver; use Hypervel\Tests\PHPStan\TestCase; +use PHPStan\Analyser\DeclarationDependencyTracker; use PHPStan\Reflection\ReflectionProvider; class ForwardedBuilderMethodExtensionTest extends TestCase @@ -23,6 +25,7 @@ public function testDoesNotReflectClassesDuringConstruction(): void $reflectionProvider, new ModelScopeMethodResolver, new BuilderMacroResolver($reflectionProvider, [SoftDeletes::class => SoftDeletingMacros::class]), + new BuilderDependencies($this->createStub(DeclarationDependencyTracker::class)), ); } } diff --git a/tests/PHPStan/ModelPropertyDependencyTest.php b/tests/PHPStan/ModelPropertyDependencyTest.php new file mode 100644 index 000000000..7cc0c9d03 --- /dev/null +++ b/tests/PHPStan/ModelPropertyDependencyTest.php @@ -0,0 +1,268 @@ +tempDir = ParallelTesting::tempDir('PHPStanModelPropertyDependencyTest'); + (new Filesystem)->deleteDirectory($this->tempDir); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + /** + * A config change can move a migration to another connection with no analysed file changing, + * so the files using models are analysed again, and the cached schema is aggregated again. + */ + public function testChangedConfigTheMigrationsReadReanalysesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($applicationPath . '/config/schema.php', " 'pgsql'];\n"); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + } + + /** + * PHPStan analyses the dependents of a changed file again only when its declarations change, but + * a model's casts also follow the body of its casts() method. + */ + public function testChangedCastsMethodBodyReanalysesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $modelPath = $applicationPath . '/app/Models/User.php'; + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($modelPath, str_replace("'name' => 'string'", "'name' => 'array'", file_get_contents($modelPath))); + + $this->assertSame( + ['Usage.php:14 property.notFound', 'Usage.php:22 return.type'], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + } + + /** + * A model's connection can come from a property default in a parent, which is not part of the + * declarations PHPStan compares. + */ + public function testChangedModelParentReanalysesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $parentPath = $applicationPath . '/app/Models/BaseModel.php'; + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($parentPath, str_replace('$connection = null', "\$connection = 'pgsql'", file_get_contents($parentPath))); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + } + + /** + * An overriding getConnectionName() can read any config value. + */ + public function testChangedConfigTheModelConnectionReadsReanalysesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($applicationPath . '/config/models.php', " 'pgsql'];\n"); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + } + + /** + * A constant a migration reads is declared outside the migrations, so its file is checked like them. + */ + public function testChangedConstantTheMigrationsReadReanalysesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $constantsPath = $applicationPath . '/app/Models/Columns.php'; + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($constantsPath, str_replace("NAME = 'name'", "NAME = 'nickname'", file_get_contents($constantsPath))); + + // The cast still makes name an attribute. + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + } + + /** + * A guard reads one table's facts to decide what happens to another, so the files those facts were + * read from are checked for every table. + */ + public function testChangedConstantAGuardReadsReanalysesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $constantsPath = $applicationPath . '/app/Models/FlagColumns.php'; + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($constantsPath, str_replace("NAME = 'name'", "NAME = 'nickname'", file_get_contents($constantsPath))); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + } + + /** + * Calls through a model or a relation depend on the model, not on its builder, and a builder's + * passthru methods come from a property default, which is not part of its declarations. + * + * PHPStan gives a class's tracked inputs to every file depending on it, so each route's calls name + * their own classes, and one route's inputs cannot cover a gap in another's. + */ + public function testChangedBuilderReanalysesForwardedCalls(): void + { + $applicationPath = $this->copyApplication(); + $builderPath = $applicationPath . '/app/Models/UserBuilder.php'; + $builder = file_get_contents($builderPath); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + // A passthru edit alone, since a declaration change reanalyses the builder's direct callers anyway. + file_put_contents($builderPath, str_replace("['exists']", '[]', $builder)); + + $this->assertSame( + [ + 'BuilderUsage.php:17 return.type', + 'ModelUsage.php:17 return.type', + 'RelationUsage.php:17 return.type', + 'Usage.php:14 property.notFound', + ], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + + file_put_contents($builderPath, str_replace('function published()', 'function unpublished()', $builder)); + + $this->assertSame( + ['RelationUsage.php:25 method.notFound', 'Usage.php:14 property.notFound'], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + + file_put_contents($builderPath, $builder); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + } + + /** + * Only the builder's getQuery() signature names its query class. + */ + public function testChangedQueryClassReanalysesBuilderUsage(): void + { + $applicationPath = $this->copyApplication(); + $queryPath = $applicationPath . '/app/Models/UserQuery.php'; + $query = file_get_contents($queryPath); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($queryPath, str_replace('function whereActive()', 'function whereInactive()', $query)); + + $this->assertSame( + ['BuilderUsage.php:25 method.notFound', 'Usage.php:14 property.notFound'], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + + file_put_contents($queryPath, $query); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + } + + /** + * Copy the fixture application, with its models, to the temporary directory. + */ + protected function copyApplication(): string + { + $applicationPath = $this->tempDir . '/application'; + (new Filesystem)->copyDirectory(__DIR__ . '/Fixtures/ModelPropertyDependency/application', $applicationPath); + mkdir($applicationPath . '/bootstrap/cache'); + + return $applicationPath; + } + + /** + * Run PHPStan from the application over its models, with the result and migration caches on. + * + * PHPStan uses its result cache only when it analyses a directory. + */ + protected function analyse(string $applicationPath): Process + { + $repositoryPath = dirname(__DIR__, 2); + + file_put_contents($applicationPath . '/phpstan.neon', <<run(); + + return $process; + } + + /** + * Get the file diagnostics as "file:line identifier" entries. + * + * @return list + */ + protected function diagnostics(Process $process, int $exitCode): array + { + $this->assertSame($exitCode, $process->getExitCode(), $process->getErrorOutput() . $process->getOutput()); + + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame([], $result['errors']); + + $diagnostics = []; + + foreach ($result['files'] as $file => $errors) { + foreach ($errors['messages'] as $message) { + $diagnostics[] = basename($file) . ":{$message['line']} {$message['identifier']}"; + } + } + + sort($diagnostics); + + return $diagnostics; + } +} diff --git a/tests/PHPStan/Rules/UnusableSchemaDumpRuleTest.php b/tests/PHPStan/Rules/UnusableSchemaDumpRuleTest.php new file mode 100644 index 000000000..5eb3582c7 --- /dev/null +++ b/tests/PHPStan/Rules/UnusableSchemaDumpRuleTest.php @@ -0,0 +1,44 @@ + */ +class UnusableSchemaDumpRuleTest extends RuleTestCase +{ + private const string TIP = 'Fix the dump or regenerate it with schema:dump. Until then, the columns it defines are unknown.'; + + protected function getRule(): Rule + { + return new UnusableSchemaDumpRule; + } + + /** @return array> */ + protected function getCollectors(): array + { + return [new UnusableSchemaDumpCollector(self::getContainer()->getByType(ModelPropertyHelper::class))]; + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/unusable-schema-dump.php'], [ + ['Model property types cannot use this schema dump: Unterminated string literal at SQL line 3.', 3, self::TIP], + ['Model property types cannot use this schema dump: PostgreSQL archives must be exported as plain SQL before parsing.', 1, self::TIP], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/unusable-schema-dumps.neon']; + } +} diff --git a/tests/PHPStan/Rules/data/unusable-schema-dump.php b/tests/PHPStan/Rules/data/unusable-schema-dump.php new file mode 100644 index 000000000..6d935a5a4 --- /dev/null +++ b/tests/PHPStan/Rules/data/unusable-schema-dump.php @@ -0,0 +1,49 @@ +label; + echo $mySql->id; + + // A declared property does not consult the schema. + echo $mariaDb->exists ? 'yes' : 'no'; + + echo $postgres?->label; + + // The default connection's facts come from usable migrations. + echo $sqlite->name; +} diff --git a/tests/PHPStan/Rules/data/unusable-schema-dumps.neon b/tests/PHPStan/Rules/data/unusable-schema-dumps.neon new file mode 100644 index 000000000..fddd8cd80 --- /dev/null +++ b/tests/PHPStan/Rules/data/unusable-schema-dumps.neon @@ -0,0 +1,5 @@ +includes: + - ../phpstan-rules.neon +parameters: + squashedMigrationsPath: + - %rootDir%/../../../tests/PHPStan/Rules/data/unusable-schema-dumps diff --git a/tests/PHPStan/Rules/data/unusable-schema-dumps/mariadb-schema.sql b/tests/PHPStan/Rules/data/unusable-schema-dumps/mariadb-schema.sql new file mode 100644 index 000000000..f8a9a2795 --- /dev/null +++ b/tests/PHPStan/Rules/data/unusable-schema-dumps/mariadb-schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE `accounts` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `label` varchar(255) DEFAULT 'unterminated, + PRIMARY KEY (`id`) +); diff --git a/tests/PHPStan/Rules/data/unusable-schema-dumps/mysql-schema.sql b/tests/PHPStan/Rules/data/unusable-schema-dumps/mysql-schema.sql new file mode 100644 index 000000000..f8a9a2795 --- /dev/null +++ b/tests/PHPStan/Rules/data/unusable-schema-dumps/mysql-schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE `accounts` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `label` varchar(255) DEFAULT 'unterminated, + PRIMARY KEY (`id`) +); diff --git a/tests/PHPStan/Rules/data/unusable-schema-dumps/pgsql-schema.dump b/tests/PHPStan/Rules/data/unusable-schema-dumps/pgsql-schema.dump new file mode 100644 index 0000000000000000000000000000000000000000..5c5c302e1879d6fc052f05632df8188a61cfb0ad GIT binary patch literal 14 VcmWG=ckvBigetByType(ValueDependencyCollector::class); $file = __DIR__ . '/data/config-file-dependency.php'; + $fileHelper = self::getContainer()->getByType(FileHelper::class); - // The second run hits the parsed config cache and must still declare the files. - foreach ([1, 2] as $run) { - $collector->startFile($file); - self::processFile($file, static function (): void { - }); - $dependencies = $collector->finishFile(); + // Every config directory's candidate files and the directory below each key, and the framework files merged beneath them. + $expected = []; - $dependencyFiles = []; + foreach (['auth', 'test', 'missing'] as $name) { + $expected[] = [FileResultCacheValueExtension::class, $fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath() . '/' . $name . '.php')]; + } - foreach ($dependencies['values'] as [$extensionClass, $key]) { - $this->assertSame(FileResultCacheValueExtension::class, $extensionClass); - $dependencyFiles[] = $key; + foreach (self::getContainer()->getByType(ConfigSourceResolver::class)->getConfigDirectories() as $configPath) { + foreach (['auth', 'auth/defaults', 'test', 'test/foo', 'missing', 'missing/foo'] as $name) { + $expected[] = [FileResultCacheValueExtension::class, $fileHelper->normalizePath($configPath . '/' . $name . '.php')]; } - // Every config directory's candidate files, and the framework file merged beneath auth.php. - $fileHelper = self::getContainer()->getByType(FileHelper::class); - $expected = [$fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath() . '/auth.php')]; + foreach (['auth/defaults', 'test/foo', 'missing/foo'] as $name) { + $expected[] = [ + DirectoryResultCacheValueExtension::class, + DirectoryResultCacheValueExtension::createKey($fileHelper->normalizePath($configPath . '/' . $name), '*.php'), + ]; + } + } + + sort($expected); + + // The second run hits the parsed config cache and must still declare the sources. + foreach ([1, 2] as $run) { + $collector->startFile($file); + self::processFile($file, static function (): void { + }); + $dependencies = []; - foreach (self::getContainer()->getByType(ConfigSourceResolver::class)->getConfigDirectories() as $configPath) { - foreach (['auth', 'auth/defaults', 'test', 'test/foo', 'missing', 'missing/foo'] as $name) { - $expected[] = $fileHelper->normalizePath($configPath . '/' . $name . '.php'); - } + foreach ($collector->finishFile()['values'] as [$extensionClass, $key]) { + $dependencies[] = [$extensionClass, $key]; } - sort($expected); - sort($dependencyFiles); + sort($dependencies); - $this->assertSame($expected, $dependencyFiles, sprintf('Run %d', $run)); + $this->assertSame($expected, $dependencies, sprintf('Run %d', $run)); } } diff --git a/tests/PHPStan/Type/ConfigFileResolutionTest.php b/tests/PHPStan/Type/ConfigFileResolutionTest.php index c0f801e85..9c5b5fbc6 100644 --- a/tests/PHPStan/Type/ConfigFileResolutionTest.php +++ b/tests/PHPStan/Type/ConfigFileResolutionTest.php @@ -4,12 +4,15 @@ namespace Hypervel\Tests\PHPStan\Type; +use PHPStan\Analyser\ResultCache\DirectoryResultCacheValueExtension; +use PHPStan\Analyser\ResultCache\FileResultCacheValueExtension; use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\File\FileHelper; use PHPStan\Testing\TypeInferenceTestCase; use PHPUnit\Framework\Attributes\DataProvider; use function array_column; +use function array_filter; use function sprintf; class ConfigFileResolutionTest extends TypeInferenceTestCase @@ -38,7 +41,10 @@ public function testEveryFileThatCanHoldAKeyIsAFileDependency(): void $collector->startFile($file); self::processFile($file, static function (): void { }); - $dependencyFiles = array_column($collector->finishFile()['values'], 1); + $dependencyFiles = array_column(array_filter( + $collector->finishFile()['values'], + static fn (array $value): bool => $value[0] === FileResultCacheValueExtension::class, + ), 1); $fileHelper = self::getContainer()->getByType(FileHelper::class); @@ -57,20 +63,27 @@ public function testEveryFileThatCanHoldAKeyIsAFileDependency(): void } } - public function testConfigFilesReadByAConfigFileAreFileDependencies(): void + public function testConfigSourcesReadByAConfigFileAreDependencies(): void { $collector = self::getContainer()->getByType(ValueDependencyCollector::class); $file = __DIR__ . '/data/config-file-scope-dependency.php'; - $leaf = self::getContainer()->getByType(FileHelper::class) - ->normalizePath(__DIR__ . '/data/config-file-resolution/config/scopeleaf.php'); + $fileHelper = self::getContainer()->getByType(FileHelper::class); + $leaf = $fileHelper->normalizePath(__DIR__ . '/data/config-file-resolution/config/scopeleaf.php'); + // A file created below the key the config file reads changes that key's value. + $leafDirectory = DirectoryResultCacheValueExtension::createKey( + $fileHelper->normalizePath(__DIR__ . '/data/config-file-resolution/config/scopeleaf/v'), + '*.php', + ); - // The second run reads the parsed config cache and must still declare the file the config file reads. + // The second run reads the parsed config cache and must still declare the sources the config file reads. foreach ([1, 2] as $run) { $collector->startFile($file); self::processFile($file, static function (): void { }); + $dependencies = array_column($collector->finishFile()['values'], 1); - $this->assertContains($leaf, array_column($collector->finishFile()['values'], 1), sprintf('Run %d', $run)); + $this->assertContains($leaf, $dependencies, sprintf('Run %d', $run)); + $this->assertContains($leafDirectory, $dependencies, sprintf('Run %d', $run)); } } diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php index 5c3f29845..e69395470 100644 --- a/tests/PHPStan/Type/GeneralTypeTest.php +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -47,6 +47,15 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/data/gate-facade.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/helpers.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/higher-order-collection-proxies.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/higher-order-collection-proxy-methods.php'); + // Hypervel: covers the overrides that repeat method templates for PHPStan 2.3.5. + yield from self::gatherAssertTypes(__DIR__ . '/data/inherited-method-templates.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-date-column-casts.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties-l13.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties-l13-2.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-serialization.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-serialization-l13.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/managers.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/optional-helper.php'); // Upstream runs this per redisDriver setting; Hypervel only has phpredis, so it runs once here. diff --git a/tests/PHPStan/Type/GenericModelPropertyTypeTest.php b/tests/PHPStan/Type/GenericModelPropertyTypeTest.php new file mode 100644 index 000000000..de5d37fc2 --- /dev/null +++ b/tests/PHPStan/Type/GenericModelPropertyTypeTest.php @@ -0,0 +1,199 @@ +assertSame($expected, $left->equals($right)); + $this->assertSame($expected, $right->equals($left)); + } + + /** @return iterable */ + public static function dataEquals(): iterable + { + $modelPropertyHelper = static::getContainer()->getByType(ModelPropertyHelper::class); + + yield [ + new GenericModelPropertyType(new ObjectType(User::class), $modelPropertyHelper), + new GenericModelPropertyType(new ObjectType(User::class), $modelPropertyHelper), + true, + ]; + + yield [ + new GenericModelPropertyType(new ObjectType(User::class), $modelPropertyHelper), + new GenericModelPropertyType(new ObjectType(Account::class), $modelPropertyHelper), + false, + ]; + } + + /** @param class-string $expectedTypeClass */ + #[DataProvider('dataUnion')] + public function testUnion( + callable $types, + string $expectedTypeClass, + string $expectedTypeDescription, + ): void { + $types = $types(static::getContainer()); + + $actualType = TypeCombinator::union(...$types); + $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); + + $this->assertSame( + $expectedTypeDescription, + $actualTypeDescription, + sprintf('union(%s)', implode(', ', array_map( + static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), + $types, + ))), + ); + + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + /** @param class-string $expectedTypeClass */ + #[DataProvider('dataUnion')] + public function testUnionInversed( + callable $types, + string $expectedTypeClass, + string $expectedTypeDescription, + ): void { + $types = array_reverse($types(static::getContainer())); + $actualType = TypeCombinator::union(...$types); + $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); + + $this->assertSame( + $expectedTypeDescription, + $actualTypeDescription, + sprintf('union(%s)', implode(', ', array_map( + static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), + $types, + ))), + ); + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + /** @return iterable, string}> */ + public static function dataUnion(): iterable + { + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new GenericModelPropertyType(new ObjectType(Account::class), $container->getByType(ModelPropertyHelper::class)), + ], + UnionType::class, + 'model property of App\Account|model property of App\User', + ]; + + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new StringType, + ], + StringType::class, + 'string', + ]; + + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new ConstantStringType('email'), + ], + GenericModelPropertyType::class, + 'model property of App\User', + ]; + } + + /** @param class-string $expectedTypeClass */ + #[DataProvider('dataIntersect')] + public function testIntersect( + callable $types, + string $expectedTypeClass, + string $expectedTypeDescription, + ): void { + $types = $types(static::getContainer()); + + $actualType = TypeCombinator::intersect(...$types); + $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); + + $this->assertSame($expectedTypeDescription, $actualTypeDescription); + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + /** @param class-string $expectedTypeClass */ + #[DataProvider('dataIntersect')] + public function testIntersectInversed( + callable $types, + string $expectedTypeClass, + string $expectedTypeDescription, + ): void { + $actualType = TypeCombinator::intersect(...array_reverse($types(static::getContainer()))); + $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); + + $this->assertSame($expectedTypeDescription, $actualTypeDescription); + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + /** @return iterable, string}> */ + public static function dataIntersect(): iterable + { + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new GenericModelPropertyType(new ObjectType(Account::class), $container->getByType(ModelPropertyHelper::class)), + ], + NeverType::class, + '*NEVER*', + ]; + + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new StringType, + ], + GenericModelPropertyType::class, + 'model property of App\User', + ]; + + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new ConstantStringType('email'), + ], + ConstantStringType::class, + "'email'", + ]; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../src/phpstan/extension.neon', + ]; + } +} diff --git a/tests/PHPStan/Type/ModelCastsParsingTest.php b/tests/PHPStan/Type/ModelCastsParsingTest.php new file mode 100644 index 000000000..b30962fb6 --- /dev/null +++ b/tests/PHPStan/Type/ModelCastsParsingTest.php @@ -0,0 +1,33 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function dataFileAsserts(): Generator + { + yield from self::gatherAssertTypes(__DIR__ . '/data/model-casts-parsing.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + // Upstream's config-with-migrations-and-parsing-casts.neon matches the test defaults. + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Type/data/arr-map.php b/tests/PHPStan/Type/data/arr-map.php index 32f0840f1..c0b4750cc 100644 --- a/tests/PHPStan/Type/data/arr-map.php +++ b/tests/PHPStan/Type/data/arr-map.php @@ -43,5 +43,6 @@ function testMapWithKeys(array $items, array $users): void assertType('array', Arr::mapWithKeys($items, static fn (int $value, string $key): array => [$value => $key])); assertType("array<'total', int>", Arr::mapWithKeys($items, static fn (int $value): array => ['total' => $value])); - assertType('array', Arr::mapWithKeys($users, static fn (User $user): array => [$user->email => $user])); + // Unlike upstream, the key is the name column: User::email() is an accessor Eloquent applies, so email reads as its untyped callback. + assertType('array', Arr::mapWithKeys($users, static fn (User $user): array => [$user->name => $user])); } diff --git a/tests/PHPStan/Type/data/bleeding-edge/collection-usage-inference.php b/tests/PHPStan/Type/data/bleeding-edge/collection-usage-inference.php new file mode 100644 index 000000000..ba9c1e439 --- /dev/null +++ b/tests/PHPStan/Type/data/bleeding-edge/collection-usage-inference.php @@ -0,0 +1,56 @@ + $ints */ +function takeInts(Collection $ints): void +{ +} + +/** + * @param iterable $users + * @param Collection $names + */ +function emptyCollections(iterable $users, Collection $names, bool $flag): void +{ + $sent = collect(); + assertType('Hypervel\Support\Collection', $sent); + takeInts($sent); + + $sentEmptyArray = collect([]); + assertType('Hypervel\Support\Collection', $sentEmptyArray); + takeInts($sentEmptyArray); + + $pushed = collect(); + foreach ($users as $user) { + $pushed->push($user->name); + } + + assertType('Hypervel\Support\Collection', $pushed); + + $put = collect([]); + $put->put('a', 1); + $put->put('b', 2); + assertType("Hypervel\Support\Collection<'a'|'b', 1|2>", $put); + + assertType('Hypervel\Support\Collection', collect()->merge($names)); + assertType('Hypervel\Support\Collection', $flag ? $names : collect()); +} + +function queries(int $id, string $role): void +{ + // REMOVED: contains(), partition(), some() and reject() value widening. Upstream's stubs redeclare + // TValue invariant; Hypervel keeps Laravel's covariant TValue, which PHPStan 2.3.5 does not widen + // to a queried value. + $ids = collect([1, 2, 3]); + assertType('bool', $ids->contains($id)); + + $roles = collect(['admin', 'editor']); + assertType('bool', $roles->contains($role)); + assertType("Hypervel\Support\Collection<0|1, 'admin'|'editor'>", $roles); +} diff --git a/tests/PHPStan/Type/data/collection-stubs-l12-20.php b/tests/PHPStan/Type/data/collection-stubs-l12-20.php index 5ac697556..301b2475e 100644 --- a/tests/PHPStan/Type/data/collection-stubs-l12-20.php +++ b/tests/PHPStan/Type/data/collection-stubs-l12-20.php @@ -9,5 +9,6 @@ function test(): void { assertType('Hypervel\Support\Collection<(int|string), non-falsy-string>', User::get()->pluck(fn (User $user) => "$user->id - $user->email", 'id')); assertType('Hypervel\Support\Collection', User::get()->pluck('id', fn (User $user) => "$user->id - $user->email")); - assertType('Hypervel\Support\Collection', User::get()->pluck(fn (User $user) => $user->email, fn (User $user) => "$user->id - $user->email")); + // Unlike upstream, the value is the name column: User::email() is an accessor Eloquent applies, so email reads as its untyped callback. + assertType('Hypervel\Support\Collection', User::get()->pluck(fn (User $user) => $user->name, fn (User $user) => "$user->id - $user->email")); } diff --git a/tests/PHPStan/Type/data/config-check-model-properties.neon b/tests/PHPStan/Type/data/config-check-model-properties.neon new file mode 100644 index 000000000..12e4137aa --- /dev/null +++ b/tests/PHPStan/Type/data/config-check-model-properties.neon @@ -0,0 +1,4 @@ +includes: + - ../../phpstan-tests.neon +parameters: + checkModelProperties: true diff --git a/tests/PHPStan/Type/data/higher-order-collection-proxy-methods.php b/tests/PHPStan/Type/data/higher-order-collection-proxy-methods.php new file mode 100644 index 000000000..6a2b947fe --- /dev/null +++ b/tests/PHPStan/Type/data/higher-order-collection-proxy-methods.php @@ -0,0 +1,102 @@ + $users + * @param SupportCollection $collection + * @param SupportCollection $supportCollectionWithModels + * @param OnlyValueGenericCollection $onlyValueGenericCollection + * @param TransactionCollection $transactionCollection + */ +function test( + EloquentCollection $users, + User $user, + SupportCollection $collection, + SupportCollection $supportCollectionWithModels, + NonGenericCollection $nonGenericCollection, + OnlyValueGenericCollection $onlyValueGenericCollection, + TransactionCollection $transactionCollection, +): void { + // Unlike upstream, these follow Hypervel's collection types: averages may be integers, an empty + // collection has no maximum or minimum, an integer sum can overflow to a float, map() keeps the + // collection's keys, and partition() returns the base collection keyed 0 and 1. + assertType('float|int', $users->avg->id() + $users->average->id()); + assertType('bool', $users->contains->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->each->delete()); + assertType('bool', $users->every->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->filter->isActive()); + assertType('App\User|null', $users->first->isActive()); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $users->flatMap->isActive()); + assertType('Hypervel\Database\Eloquent\Collection>', $users->groupBy->isActive()); + assertType('Hypervel\Database\Eloquent\Collection>', $users->groupBy->getAllCapsName()); + assertType('Hypervel\Database\Eloquent\Collection<(int|string), App\User>', $users->keyBy->isActive()); + assertType('Hypervel\Support\Collection', $users->map->isActive()); + assertType('Hypervel\Support\Collection>', $users->map->accounts()); + assertType('Hypervel\Support\Collection', $users->map->id()); +// assertType('array', $user->accounts->map->getAttributes()->all()); + assertType('int|null', $users->max->id()); + assertType('int|null', $users->min->id()); + assertType('Hypervel\Support\Collection, Hypervel\Database\Eloquent\Collection>', $users->partition->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->reject->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->skipUntil->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->skipWhile->isActive()); + assertType('float|int', $users->sum->id()); + assertType('Hypervel\Database\Eloquent\Collection', $users->takeUntil->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->takeWhile->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->unique->isActive()); + assertType('Hypervel\Support\Collection', $collection->map->import()); + assertType('Hypervel\Support\Collection', $collection->each->import()); + assertType('Hypervel\Support\Collection', $collection->filter->isImported()); + + // Unlike upstream, ids read int on the SQLite test connection, dates read CarbonInterface, and + // User::email() is an accessor Eloquent applies, so email reads as its untyped callback. + assertType('float|int', $users->avg->id + $users->average->id); + assertType('bool', $users->contains->email); + assertType('bool', $users->every->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->each->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->filter->email); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $users->flatMap->email); + assertType('App\User|null', $users->first->email); + assertType('Hypervel\Database\Eloquent\Collection<(int|string), Hypervel\Database\Eloquent\Collection>', $users->groupBy->email); + assertType('Hypervel\Database\Eloquent\Collection>', $users->groupBy->id); + assertType('Hypervel\Database\Eloquent\Collection<(int|string), App\User>', $users->keyBy->email); + assertType('Hypervel\Support\Collection', $users->map->email); + assertType('Hypervel\Support\Collection', $users->map->id); + assertType('Hypervel\Support\Collection<(int|string), int<0, max>>', $users->groupBy('status')->map->count()); + assertType('Hypervel\Support\Collection', $users->map->created_at); + assertType('mixed', $users->max->email); + assertType('int|null', $users->max->id); + assertType('mixed', $users->min->email); + assertType('int|null', $users->min->id); + assertType('Hypervel\Support\Collection, Hypervel\Database\Eloquent\Collection>', $users->partition->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->reject->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->skipUntil->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->skipWhile->email); + assertType('float|int', $users->sum->id); + assertType('float|int', $users->sum->unsigned_integer); + assertType('Hypervel\Database\Eloquent\Collection', $users->takeUntil->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->takeWhile->email); + + assertType('Hypervel\Support\Collection', $supportCollectionWithModels->reject->isActive()); + + // Custom collections + assertType('App\NonGenericCollection', $nonGenericCollection->each->count()); + assertType('App\NonGenericCollection', $nonGenericCollection->reject->id); + assertType('App\OnlyValueGenericCollection', $onlyValueGenericCollection->each->count()); + assertType('App\OnlyValueGenericCollection', $onlyValueGenericCollection->reject->id); + assertType('App\TransactionCollection', $transactionCollection->each->count()); + assertType('App\TransactionCollection', $transactionCollection->reject->id); +} diff --git a/tests/PHPStan/Type/data/model-casts-parsing-trait.php b/tests/PHPStan/Type/data/model-casts-parsing-trait.php new file mode 100644 index 000000000..e736da212 --- /dev/null +++ b/tests/PHPStan/Type/data/model-casts-parsing-trait.php @@ -0,0 +1,18 @@ + 'float', + ]; + } +} diff --git a/tests/PHPStan/Type/data/model-casts-parsing.php b/tests/PHPStan/Type/data/model-casts-parsing.php new file mode 100644 index 000000000..fc5f1de66 --- /dev/null +++ b/tests/PHPStan/Type/data/model-casts-parsing.php @@ -0,0 +1,326 @@ +integer); + assertType(Stringable::class, $model->string); +} + +class ModelWithCasts extends Model +{ + public function foo(): int + { + return 5; + } + + public function casts(): array + { + return [ + 'integer' => 'bool', + 'string' => AsStringable::class.':argument', + ]; + } +} + +function testRegressions( + ChildModel $child, + SecondModel $second, + TraitModel $trait, + NestedClosureModel $nestedClosure, + HelperModel $helpers, + ConstructedCastModel $constructed, + MultipleReturnsModel $multipleReturns, + OpenShapeModel $openShape, + UnknownCastsModel $unknown, + MissingTableModel $missingTable, + PropertyArrayCastModel $propertyArrays, + VirtualCastModel $virtual, +): void { + // self:: in an inherited casts() refers to the class declaring it. + assertType('bool', $child->integer); + + // Each model reads its own casts(), even when another model in the file declares one first. + assertType('float', $second->integer); + + assertType('float', $trait->integer); + + // A nested closure's return is not the method's. + assertType('float', $nestedClosure->integer); + + assertType('Hypervel\Support\Collection<(int|string), App\Casts\BackedEnumeration>|null', $helpers->collection); + assertType('(Hypervel\Support\Collection<(int|string), mixed>|null)', $helpers->nullable_collection); + // The selected collection class is kept, unless a mapper may return another. + assertType('(ModelCastsParsing\OptionCollection<(int|string), mixed>|null)', $helpers->object); + assertType('(Hypervel\Support\Collection<(int|string), mixed>|null)', $helpers->bool); + assertType('Hypervel\Database\Eloquent\Casts\ArrayObject', $helpers->json); + assertType('Hypervel\Support\Collection<(int|string), App\Casts\BackedEnumeration>|null', $helpers->array); + assertType('mixed', $helpers->string); + assertType('float', $helpers->float); + $helpers->string = 'value'; + assertType('mixed', $helpers->string); + + // The cast is read from the declared return type, without constructing the model or calling casts(). + assertType(Stringable::class, $constructed->string); + + // A method with several returns is read from its declared return type. + assertType('bool', $multipleReturns->integer); + assertType('mixed', $multipleReturns->string); + assertType('float', $multipleReturns->float); + + // An open shape leaves the other attributes' casts unknown. + assertType('bool', $openShape->integer); + assertType('mixed', $openShape->float); + + assertType('mixed', $unknown->integer); + assertType('mixed', $unknown->float); + // The cast may be a class cast, which serializes before the accessor. + assertType('mixed', $unknown->toArray()['real']); + + // A key without a known column is still cast. + assertType('string', $missingTable->id); + + // Array definitions in the $casts property are normalized as initializeHasAttributes() does. + assertType('Hypervel\Database\Eloquent\Casts\ArrayObject', $propertyArrays->json); + assertType('numeric-string', $propertyArrays->decimal); + + // A cast key is an attribute without a column of its own. + assertType('ModelCastsParsing\Dimensions', $virtual->dimensions); +} + +function testHelpers(): void +{ + assertType("'Hypervel\\\\Database\\\\Eloquent\\\\Casts\\\\AsEnumCollection:App\\\\Casts\\\\BackedEnumeration'", AsEnumCollection::of(BackedEnumeration::class)); + assertType("'Hypervel\\\\Database\\\\Eloquent\\\\Casts\\\\AsCollection:,,nullable'", AsCollection::nullable(null)); + assertType("'Hypervel\\\\Database\\\\Eloquent\\\\Casts\\\\AsCollection:,App\\\\Casts\\\\BackedEnumeration@from'", AsCollection::of([BackedEnumeration::class, 'from'])); + assertType('string', AsCollection::of(['not', 'callable'])); + assertType('string', AsEnumCollection::of(123)); + assertType('string', CollectionCast::of(BackedEnumeration::class)); +} + +class CollectionCast extends AsCollection +{ +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class OptionCollection extends Collection +{ +} + +class ParentModel extends Model +{ + public const string CAST = 'bool'; + + protected ?string $table = 'model_with_casts'; + + protected function casts(): array + { + return [ + 'integer' => self::CAST, + ]; + } +} + +class ChildModel extends ParentModel +{ + public const string CAST = 'array'; +} + +class SecondModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected function casts(): array + { + return [ + 'integer' => 'float', + ]; + } +} + +class TraitModel extends Model +{ + use CastsIntegerAsFloat; + + protected ?string $table = 'model_with_casts'; +} + +class NestedClosureModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected function casts(): array + { + $unused = static function (): array { + return ['integer' => 'bool']; + }; + + return [ + 'integer' => 'float', + ]; + } +} + +class HelperModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected function casts(): array + { + $cast = 'array'; + + return [ + 'collection' => AsEnumCollection::of(BackedEnumeration::class), + 'nullable_collection' => AsCollection::using(Collection::class), + 'object' => AsCollection::using(OptionCollection::class), + 'bool' => AsCollection::using(OptionCollection::class, Dimensions::class), + 'json' => AsArrayObject::nullable(), + 'array' => [AsEnumCollection::class, BackedEnumeration::class], + 'string' => $cast, + ]; + } +} + +class ConstructedCastModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + private string $castClass; + + public function __construct(array $attributes = []) + { + $this->castClass = AsStringable::class; + + parent::__construct($attributes); + } + + /** + * @return array{string: 'Hypervel\\Database\\Eloquent\\Casts\\AsStringable'} + */ + protected function casts(): array + { + return [ + 'string' => $this->castClass, + ]; + } +} + +class MultipleReturnsModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + /** + * @return array{integer: 'bool', string?: 'array'} + */ + protected function casts(): array + { + if ($this->exists) { + return ['integer' => 'bool', 'string' => 'array']; + } + + return ['integer' => 'bool']; + } +} + +class OpenShapeModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + /** + * @return array{integer: 'bool', ...} + */ + protected function casts(): array + { + return [...parent::casts(), 'integer' => 'bool']; + } +} + +class UnknownCastsModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected function casts(): array + { + return array_merge(parent::casts(), ['integer' => 'bool']); + } + + /** + * @return Attribute + */ + protected function real(): Attribute + { + return Attribute::get(fn (float $value): string => (string) $value); + } +} + +class MissingTableModel extends Model +{ + protected ?string $table = 'missing_table'; + + protected function casts(): array + { + return [ + 'id' => 'string', + ]; + } +} + +class PropertyArrayCastModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected array $casts = [ + 'json' => [AsArrayObject::class], + 'decimal' => ['decimal', 1], + ]; +} + +class VirtualCastModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected array $casts = [ + 'dimensions' => DimensionsCast::class, + ]; +} + +/** + * @implements CastsAttributes + */ +class DimensionsCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): Dimensions + { + return new Dimensions($attributes['real'], $attributes['float']); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): array + { + return ['real' => $value->width, 'float' => $value->height]; + } +} + +class Dimensions +{ + public function __construct(public float $width, public float $height) + { + } +} diff --git a/tests/PHPStan/Type/data/model-date-column-casts.php b/tests/PHPStan/Type/data/model-date-column-casts.php new file mode 100644 index 000000000..046411902 --- /dev/null +++ b/tests/PHPStan/Type/data/model-date-column-casts.php @@ -0,0 +1,14 @@ +created_at); + assertType('int|null', $model->updated_at); + assertType('Carbon\CarbonImmutable|null', $model->deleted_at); +} diff --git a/tests/PHPStan/Type/data/model-properties-l13-2.php b/tests/PHPStan/Type/data/model-properties-l13-2.php new file mode 100644 index 000000000..0afd8605d --- /dev/null +++ b/tests/PHPStan/Type/data/model-properties-l13-2.php @@ -0,0 +1,12 @@ +created_at); +} diff --git a/tests/PHPStan/Type/data/model-properties-l13.php b/tests/PHPStan/Type/data/model-properties-l13.php new file mode 100644 index 000000000..c1d2a9d98 --- /dev/null +++ b/tests/PHPStan/Type/data/model-properties-l13.php @@ -0,0 +1,12 @@ +created_at); +} diff --git a/tests/PHPStan/Type/data/model-properties.php b/tests/PHPStan/Type/data/model-properties.php new file mode 100644 index 000000000..ae3d81d3e --- /dev/null +++ b/tests/PHPStan/Type/data/model-properties.php @@ -0,0 +1,162 @@ +newStyleAttribute); + assertType('int', $user->stringButInt); + // Unlike upstream, User::email() is an accessor Eloquent applies, so email reads as its untyped callback. + assertType('mixed', $user->email); + assertType('string|null', $user->remember_token); + assertType('array', $user->allowed_ips); + assertType('numeric-string', $user->floatButRoundedDecimalString); + assertType('int', $user->unsigned_integer); + assertType('int', $user->unsigned_integer_method); + + // Model Casts + assertType('int', $user->int); + assertType('int', $user->integer); + assertType('float', $user->real); + assertType('float', $user->float); + assertType('float', $user->double); + assertType('numeric-string', $user->decimal); + assertType('numeric-string|null', $user->nullable_decimal); + assertType('string', $user->string); + assertType('bool', $user->bool); + assertType('bool', $user->boolean); + assertType('stdClass', $user->object); + assertType('array', $user->array); + assertType('array', $user->json); + assertType(Collection::class, $user->collection); + assertType(Collection::class.'|null', $user->nullable_collection); + assertType(CarbonInterface::class, $user->date); + assertType(CarbonInterface::class, $user->datetime); + assertType(CarbonImmutable::class, $user->immutable_date); + assertType(CarbonImmutable::class, $user->immutable_datetime); + assertType('int', $user->timestamp); + assertType("'active'|'don\'t know'|'inactive'", $user->enum_status); + assertType(BasicEnumeration::class, $user->basic_enum); + assertType(BackedEnumeration::class, $user->backed_enum); + + // Castable + assertType(Stringable::class, $user->castable_with_argument); + + // CastsAttributes + assertType('App\ValueObjects\Favorites', $user->favorites); + + assertType('int', $user->id); + assertType('bool', $user->blocked); + assertType('Carbon\CarbonInterface|null', $user->created_at); + assertType('array', $user->meta); + assertType('string', $account->active); + assertType('string', $role->id); + assertType('string', $team->id); + assertType('int', $group->id); + assertType('string', $guardedModel->name); + assertType('string', $thread->custom_property); + assertType('Hypervel\Database\Eloquent\Casts\ArrayObject', $user->options); + assertType('int<0, max>', count($user->options)); + assertType('(Hypervel\Support\Collection<(int|string), mixed>|null)', $user->properties); + assertType('int<0, max>', count($user->properties)); + assertType('mixed', $user->properties->first()); + assertType('Hypervel\Support\Collection<(int|string), App\Casts\BackedEnumeration>|null', $user->enum_collection); + assertType('App\Casts\BackedEnumeration|null', $user->enum_collection?->first()); + assertType('Hypervel\Database\Eloquent\Casts\ArrayObject<(int|string), App\Casts\BasicEnumeration>|null', $user->enum_array_object); + assertType('non-falsy-string|null', $user->deleted_at?->format('d/m/Y')); + assertType('int', $address->user_id); + assertType('int', $address->custom_foreign_id_for_name); + assertType('string', $address->address_id); + assertType('string', $address->nullable_address_id); // overridden by a @property + assertType('int', $address->foreign_id_constrained); + assertType('int|null', $address->nullable_foreign_id_constrained); + assertType('string', $address->foreign_uuid); + assertType('string|null', $address->nullable_foreign_uuid); + assertType('string', $address->foreign_ulid); + assertType('string|null', $address->nullable_foreign_ulid); + assertType('App\ValueObjects\Favorites', $user->favorites); + assertType('string', $address->uuid); + assertType('string', $roleUser->role_id); + assertType('int', $roleUser->user_id); + + assertType('bool', $modelWithCasts->integer); + assertType(Stringable::class, $modelWithCasts->string); +} + +function testUnknownSchema(UnknownSchemaUser $user): void +{ + // Unknown schema facts do not hide a declared relation, and only a trailing _count names a relation count. + assertType('Hypervel\Database\Eloquent\Collection', $user->posts); + assertType('int<0, max>', $user->stock_count_items_count); + assertType('mixed', $user->name); +} + +class UnknownSchemaUser extends User +{ + protected UnitEnum|string|null $connection = 'unconfigured'; + + /** + * @return HasMany + */ + public function stock_count_items(): HasMany + { + return $this->hasMany(Account::class); + } +} + +class ModelWithCasts extends Model +{ + protected array $casts = [ + 'integer' => 'int', + ]; + + /** + * @return array{integer: 'bool', string: 'Hypervel\\Database\\Eloquent\\Casts\\AsStringable:argument'} + */ + public function casts(): array + { + $argument = 'argument'; + + return [ + 'integer' => 'bool', // overrides the cast from the property + 'string' => AsStringable::class.':'.$argument, + ]; + } +} diff --git a/tests/PHPStan/Type/data/model-serialization-l13.php b/tests/PHPStan/Type/data/model-serialization-l13.php new file mode 100644 index 000000000..539724c1f --- /dev/null +++ b/tests/PHPStan/Type/data/model-serialization-l13.php @@ -0,0 +1,49 @@ +}', $model->attributesToArray()); + assertType('array{id?: int, label?: string, ...}', $inherited->toArray()); + assertType('array{id?: int, name?: string, ...}', $replaced->attributesToArray()); +} diff --git a/tests/PHPStan/Type/data/model-serialization.php b/tests/PHPStan/Type/data/model-serialization.php new file mode 100644 index 000000000..9761bf4a1 --- /dev/null +++ b/tests/PHPStan/Type/data/model-serialization.php @@ -0,0 +1,334 @@ + 'datetime']; + + public function getLabelAttribute(): string + { + return 'label'; + } +} + +// Unlike upstream, ids read int throughout: the test connection is SQLite, which does not enforce unsigned. +function serialization(SerializationModel $model): void +{ + assertType('array{id?: int, name?: string, email_verified_at?: string|null, label?: string, ...}', $model->attributesToArray()); + assertType('array{id?: int, name?: string, email_verified_at?: string|null, label?: string, ...}', $model->toArray()); + assertType('mixed', $model->attributesToArray()['posts_count']); +} + +class HiddenSerializationModel extends SerializationModel +{ + protected array $hidden = ['name', 'label']; +} + +class AccessorSerializationModel extends SerializationModel +{ + protected array $casts = ['name' => 'integer']; + + public function getNameAttribute(): bool + { + return true; + } +} + +enum SerializedStatus: string +{ + case Active = 'active'; + case Inactive = 'inactive'; +} + +enum SerializedNumber: int +{ + case One = 1; + case Two = 2; +} + +enum SerializedUnit +{ + case Active; +} + +class CastSerializationModel extends Model +{ + protected ?string $table = 'users'; + + protected array $visible = ['backed_enum', 'basic_enum', 'integer', 'date', 'datetime', 'collection']; + + protected array $casts = [ + 'backed_enum' => SerializedStatus::class, + 'basic_enum' => SerializedUnit::class, + 'integer' => SerializedNumber::class, + 'date' => 'immutable_date', + 'datetime' => 'datetime:Y-m-d', + 'collection' => 'collection', + ]; +} + +/** + * Unlike upstream, this reads a known table, limited to its appends: a model whose table is + * unknown keeps the declared type (see UnknownTableSerializationModel). + */ +class AppendedSerializationModel extends Model +{ + protected ?string $table = 'users'; + + protected array $visible = ['modern_date', 'legacy_date', 'tags', 'label']; + + protected array $appends = ['modern_date', 'legacy_date', 'tags', 'label']; + + /** @return Attribute */ + protected function modernDate(): Attribute + { + return Attribute::get(static fn () => new Carbon('2024-01-01')); + } + + public function getLegacyDateAttribute(): Carbon + { + return new Carbon('2024-01-01'); + } + + /** @return Collection */ + public function getTagsAttribute(): Collection + { + return collect(['tag']); + } + + public function getLabelAttribute(): ?string + { + return null; + } +} + +class UnknownTableSerializationModel extends Model +{ + protected ?string $table = 'no_serialization_schema'; + + protected array $appends = ['label']; + + public function getLabelAttribute(): string + { + return 'label'; + } +} + +class OverriddenSerializationModel extends SerializationModel +{ + /** @return array{custom: true} */ + public function attributesToArray(): array + { + return ['custom' => true]; + } +} + +class OverriddenToArrayModel extends SerializationModel +{ + /** @return array{custom: true} */ + public function toArray(): array + { + return ['custom' => true]; + } +} + +class OverriddenDateModel extends SerializationModel +{ + /** + * Unlike upstream, the override returns a narrower string: Hypervel declares serializeDate() as returning string. + * + * @return 'date' + */ + protected function serializeDate(\DateTimeInterface $date): string + { + return 'date'; + } +} + +function boundaries( + HiddenSerializationModel $hidden, + AccessorSerializationModel $accessor, + CastSerializationModel $casts, + AppendedSerializationModel $appended, + OverriddenSerializationModel $override, + OverriddenToArrayModel $toArrayOverride, + OverriddenDateModel $dateOverride, + Model $base, + UnknownTableSerializationModel $unknownTable, +): void { + assertType('array{id?: int, email_verified_at?: string|null, ...}', $hidden->attributesToArray()); + assertType('mixed', $hidden->attributesToArray()['name']); + assertType('bool', $accessor->attributesToArray()['name']); + assertType("'active'|'inactive'", $casts->attributesToArray()['backed_enum']); + assertType("'Active'", $casts->attributesToArray()['basic_enum']); + assertType('1|2', $casts->attributesToArray()['integer']); + assertType('string', $casts->attributesToArray()['date']); + assertType('string', $casts->attributesToArray()['datetime']); + assertType('array', $casts->attributesToArray()['collection']); + assertType('array{modern_date?: string, legacy_date?: Carbon\\Carbon, tags?: array, label?: string|null, ...}', $appended->attributesToArray()); + assertType('array{custom: true}', $override->attributesToArray()); + assertType('array', $override->toArray()); + assertType('array{custom: true}', $toArrayOverride->toArray()); + assertType('array{id?: int, name?: string, email_verified_at?: string|null, label?: string, ...}', $toArrayOverride->attributesToArray()); + assertType("'date'|null", $dateOverride->attributesToArray()['email_verified_at']); + assertType('array', $base->toArray()); + assertType('array', $unknownTable->toArray()); + + $partial = SerializationModel::query()->select('id')->firstOrFail(); + assertType('array{id?: int, name?: string, email_verified_at?: string|null, label?: string, ...}', $partial->toArray()); + + if (isset($partial->toArray()['name'])) { + assertType('string', $partial->toArray()['name']); + } +} + +class ModernColumnSerializationModel extends SerializationModel +{ + protected array $casts = ['name' => 'integer']; + + /** @return Attribute */ + protected function name(): Attribute + { + return Attribute::get(static fn () => 'name'); + } + + public function serializeInside(): void + { + assertType('string', $this->attributesToArray()['name']); + assertType('string', $this->toArray()['name']); + } +} + +class SerializationValue implements Arrayable +{ + /** @return array{value: int} */ + public function toArray(): array + { + return ['value' => 1]; + } +} + +/** @implements CastsAttributes */ +class SerializationValueCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): SerializationValue + { + return new SerializationValue(); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + return 'value'; + } + + /** @return array{serialized: true} */ + public function serialize(Model $model, string $key, mixed $value, array $attributes): array + { + return ['serialized' => true]; + } +} + +class CustomCastSerializationModel extends SerializationModel +{ + protected array $casts = ['name' => SerializationValueCast::class]; +} + +class ArrayableSerializationModel extends AppendedSerializationModel +{ + protected array $visible = ['value', 'values']; + + protected array $appends = ['value', 'values']; + + public function getValueAttribute(): SerializationValue + { + return new SerializationValue(); + } + + /** @return Collection */ + public function getValuesAttribute(): Collection + { + return collect(['first' => new SerializationValue(), 'second' => 'value']); + } +} + +interface SerializationMarker +{ +} + +/** @template TModel of SerializationModel */ +class GenericSerialization +{ + /** @param TModel $model */ + public function serialize(Model $model): void + { + assertType('array{id?: int, name?: string, email_verified_at?: string|null, label?: string, ...}', $model->attributesToArray()); + } +} + +function additionalBoundaries( + CustomCastSerializationModel $custom, + ArrayableSerializationModel $arrayable, + SerializationModel&SerializationMarker $intersection, + ModernColumnSerializationModel|AccessorSerializationModel $union, + ?SerializationModel $nullable, +): void { + assertType('array{serialized: true}', $custom->attributesToArray()['name']); + assertType('array{value: int}', $arrayable->attributesToArray()['value']); + assertType('array', $arrayable->attributesToArray()['values']); + assertType('array{id?: int, name?: string, email_verified_at?: string|null, label?: string, ...}', $intersection->attributesToArray()); + assertType('bool|string', $union->attributesToArray()['name']); + assertType('array{id?: int, name?: string, email_verified_at?: string|null, label?: string, ...}|null', $nullable?->attributesToArray()); +} + +class CustomCastAccessorSerializationModel extends CustomCastSerializationModel +{ + public function getNameAttribute(): string + { + return 'accessor'; + } +} + +function customCastAccessor(CustomCastAccessorSerializationModel $model): void +{ + assertType('array{value: int}', $model->attributesToArray()['name']); +} + +/** @implements CastsAttributes */ +class SerializationEnumCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): SerializedStatus + { + return SerializedStatus::Active; + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + return 'active'; + } +} + +class CustomEnumSerializationModel extends SerializationModel +{ + protected array $casts = ['name' => SerializationEnumCast::class]; +} + +function customEnumSerialization(CustomEnumSerializationModel $model): void +{ + assertType('ModelSerialization\\SerializedStatus', $model->attributesToArray()['name']); +} diff --git a/tests/PHPStan/Unit/ConnectionConfigResolverTest.php b/tests/PHPStan/Unit/ConnectionConfigResolverTest.php new file mode 100644 index 000000000..b5b3da6fe --- /dev/null +++ b/tests/PHPStan/Unit/ConnectionConfigResolverTest.php @@ -0,0 +1,90 @@ +withConfig(['database.connections.split' => [ + 'driver' => 'mysql', + 'database' => 'app', + 'read' => ['host' => 'replica', 'options' => [PDO::ATTR_STRINGIFY_FETCHES => true]], + 'write' => ['host' => 'primary'], + ]], function (): void { + $resolver = new ConnectionConfigResolver; + $stringified = fn (string $name): array => array_map( + static fn (array $options): mixed => $options[PDO::ATTR_STRINGIFY_FETCHES], + $this->connection($resolver, $name)->fetchOptions, + ); + + $this->assertSame([true, false], $stringified('split')); + $this->assertSame([true], $stringified('split::read')); + $this->assertSame([false], $stringified('split::write')); + }); + } + + /** + * A pool's records can reach different hosts, but a lookup cannot tell which of several databases a query reaches. + */ + #[Test] + public function itResolvesFactsOnlyWhenEveryRecordReachesTheSameDatabase(): void + { + $this->withConfig([ + 'database.connections.pooled' => ['driver' => 'mysql', 'database' => 'app', 'read' => ['host' => 'replica'], 'write' => ['host' => 'primary']], + 'database.connections.diverging' => ['driver' => 'mysql', 'database' => 'app', 'read' => ['database' => 'reporting'], 'write' => ['host' => 'primary']], + ], function (): void { + $resolver = new ConnectionConfigResolver; + + $this->assertSame('app', $this->connection($resolver, 'pooled')->defaultSchema); + $this->assertNull($resolver->connection('diverging')); + $this->assertSame('reporting', $this->connection($resolver, 'diverging::read')->defaultSchema); + }); + } + + /** + * The schema facts belong to the connection the migrations run on, such as a direct connection behind a pooler. + */ + #[Test] + public function itResolvesTheConnectionMigrationsRunOn(): void + { + $this->withConfig([ + 'database.connections.pooler' => ['driver' => 'pgsql', 'database' => 'app', 'migrations_connection' => 'direct'], + 'database.connections.direct' => ['driver' => 'pgsql', 'database' => 'app'], + ], function (): void { + $resolver = new ConnectionConfigResolver; + + $this->assertSame('direct', $this->connection($resolver, 'pooler')->owner); + $this->assertSame('direct', $this->connection($resolver, 'direct')->owner); + }); + } + + /** + * Get the facts of a connection name, failing when they cannot be resolved. + */ + private function connection(ConnectionConfigResolver $resolver, string $name): ConnectionConfig + { + $connection = $resolver->connection($name); + + $this->assertNotNull($connection, "The connection [{$name}] cannot be resolved."); + + return $connection; + } +} diff --git a/tests/PHPStan/Unit/DataTypeToPhpTypeConverterTest.php b/tests/PHPStan/Unit/DataTypeToPhpTypeConverterTest.php new file mode 100644 index 000000000..1ccfd738e --- /dev/null +++ b/tests/PHPStan/Unit/DataTypeToPhpTypeConverterTest.php @@ -0,0 +1,81 @@ +getByType(DataTypeToPhpTypeConverter::class) + ->convert($driver, $type, [], new SchemaConnection($driver, $driver), ['public']); + + $this->assertSame([$readable, $writable], [ + $types['readable']->describe(VerbosityLevel::precise()), + $types['writable']->describe(VerbosityLevel::precise()), + ]); + } + + /** @return iterable */ + public static function columnTypes(): iterable + { + yield 'PostgreSQL bytea, fetched as a stream' => ['pgsql', new ColumnType('bytea'), 'resource', 'string']; + yield 'MySQL unsigned bigint, above PHP_INT_MAX as a string' => ['mysql', new ColumnType('bigint', unsigned: true), 'int<0, max>|numeric-string', 'int<0, max>|numeric-string']; + yield 'MySQL bit(64)' => ['mysql', new ColumnType('bit', modifiers: ['64']), 'int<0, max>|numeric-string', 'int<0, max>|numeric-string']; + yield 'MySQL bit' => ['mysql', new ColumnType('bit'), 'int<0, max>', 'int<0, max>']; + } + + /** + * @param list> $fetchOptions + */ + #[Test] + #[DataProvider('fetchOptions')] + public function itAppliesFetchOptions(string $driver, Type $type, array $fetchOptions, string $expected): void + { + $this->assertSame( + $expected, + self::getContainer()->getByType(DataTypeToPhpTypeConverter::class) + ->applyFetchOptions($type, $driver, $fetchOptions) + ->describe(VerbosityLevel::precise()), + ); + } + + /** @return iterable>, string}> */ + public static function fetchOptions(): iterable + { + $stringify = [[PDO::ATTR_STRINGIFY_FETCHES => true]]; + + yield 'stringified integer' => ['pgsql', new IntegerType, $stringify, 'numeric-string']; + yield 'stringified MySQL double, never NaN' => ['mysql', new FloatType, $stringify, 'numeric-string']; + yield 'stringified PostgreSQL float' => ['pgsql', new FloatType, $stringify, 'string']; + yield 'stringified boolean' => ['pgsql', new BooleanType, $stringify, "'0'|'1'"]; + yield 'nulls fetched as empty strings' => ['mysql', TypeCombinator::addNull(new IntegerType), [[PDO::ATTR_ORACLE_NULLS => PDO::NULL_TO_STRING]], "''|int"]; + yield 'empty strings fetched as nulls' => ['mysql', new StringType, [[PDO::ATTR_ORACLE_NULLS => PDO::NULL_EMPTY_STRING]], 'string|null']; + yield 'empty strings fetched as nulls, for a column without them' => ['mysql', new IntegerType, [[PDO::ATTR_ORACLE_NULLS => PDO::NULL_EMPTY_STRING]], 'int']; + yield 'each option set a pool can select' => ['mysql', new IntegerType, [[PDO::ATTR_STRINGIFY_FETCHES => false], ...$stringify], 'int|numeric-string']; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Unit/DirectoryResolverTest.php b/tests/PHPStan/Unit/DirectoryResolverTest.php new file mode 100644 index 000000000..6eace7613 --- /dev/null +++ b/tests/PHPStan/Unit/DirectoryResolverTest.php @@ -0,0 +1,86 @@ +fileHelper = self::getContainer()->getByType(FileHelper::class); + $this->root = ParallelTesting::tempDir('PHPStanDirectoryResolverTest'); + + (new Filesystem)->deleteDirectory($this->root); + mkdir($this->root); + mkdir($this->root . '/a'); + mkdir($this->root . '/b'); + mkdir($this->root . '/[literal]'); + touch($this->root . '/c.php'); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->root); + + parent::tearDown(); + } + + #[Test] + public function itKeepsAPathWithoutGlobCharactersEvenIfItDoesNotExist(): void + { + self::assertSame( + [$this->root . '/a', $this->root . '/missing'], + DirectoryResolver::resolve([$this->root . '/a', $this->root . '/missing'], $this->fileHelper), + ); + } + + #[Test] + public function itExpandsAGlobToTheDirectoriesItMatches(): void + { + self::assertSame( + [$this->root . '/[literal]', $this->root . '/a', $this->root . '/b'], + DirectoryResolver::resolve([$this->root . '/*'], $this->fileHelper), + ); + } + + #[Test] + public function itDropsAGlobThatMatchesNoDirectory(): void + { + self::assertSame([], DirectoryResolver::resolve([$this->root . '/missing-*', $this->root . '/c.*'], $this->fileHelper)); + } + + #[Test] + public function itTakesAnExistingDirectoryWithGlobCharactersLiterally(): void + { + self::assertSame( + [$this->root . '/[literal]'], + DirectoryResolver::resolve([$this->root . '/[literal]'], $this->fileHelper), + ); + } + + #[Test] + public function itListsADirectoryOnce(): void + { + self::assertSame( + [$this->root . '/a', $this->root . '/b'], + DirectoryResolver::resolve([$this->root . '/a', $this->root . '/[ab]', $this->root . '/a/'], $this->fileHelper), + ); + } +} diff --git a/tests/PHPStan/Unit/MigrationCacheTest.php b/tests/PHPStan/Unit/MigrationCacheTest.php new file mode 100644 index 000000000..9bb99b62d --- /dev/null +++ b/tests/PHPStan/Unit/MigrationCacheTest.php @@ -0,0 +1,287 @@ +cacheDirectory = ParallelTesting::tempDir('PHPStanMigrationCacheTest'); + (new Filesystem)->deleteDirectory($this->cacheDirectory); + mkdir($this->cacheDirectory, 0777, true); + + $container = new Container; + $this->config = new Repository; + $container->instance('config', $this->config); + + $this->resolver = new ConnectionConfigResolver; + $this->resolver->setContainer($container); + $this->cache = new MigrationCache($this->resolver, $this->cacheDirectory, true); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->cacheDirectory); + + parent::tearDown(); + } + + public function testRememberExecutesCallbackOnFirstCall(): void + { + $migrationFiles = [new SplFileInfo($this->createTempFile('mig1.php'))]; + $schemaFiles = []; + + $called = false; + $result = $this->cache->remember($migrationFiles, $schemaFiles, [], static function () use (&$called) { + $called = true; + + return self::schema(['users' => new SchemaConnection('users', 'mysql')]); + }); + + $this->assertTrue($called); + $this->assertArrayHasKey('users', $result['connections']); + $this->assertSame('users', $result['connections']['users']->name); + } + + public function testRememberUsesCacheOnSecondCall(): void + { + $migrationFiles = [new SplFileInfo($this->createTempFile('mig1.php'))]; + $schemaFiles = []; + + $this->cache->remember($migrationFiles, $schemaFiles, [], static fn () => self::schema(['users' => new SchemaConnection('users', 'mysql')])); + + $called = false; + $result = $this->cache->remember($migrationFiles, $schemaFiles, [], static function () use (&$called) { + $called = true; + + return self::schema([]); + }); + + $this->assertFalse($called, 'Callback should not be called on cache hit'); + $this->assertArrayHasKey('users', $result['connections']); + } + + public function testFingerprintChangesOnMtimeChange(): void + { + $file1 = $this->createTempFile('mig1.php'); + $migrationFiles = [new SplFileInfo($file1)]; + $schemaFiles = []; + + $this->cache->remember($migrationFiles, $schemaFiles, [], static fn () => self::schema(['users' => new SchemaConnection('users', 'mysql')])); + + touch($file1, $migrationFiles[0]->getMTime() + 1); + clearstatcache(true, $file1); + + $called = false; + $result = $this->cache->remember($migrationFiles, $schemaFiles, [], static function () use (&$called) { + $called = true; + + return self::schema(['posts' => new SchemaConnection('posts', 'mysql')]); + }); + + $this->assertTrue($called, 'Callback should be called when file changed'); + $this->assertArrayHasKey('posts', $result['connections']); + $this->assertArrayNotHasKey('users', $result['connections']); + } + + /** + * The modification time has whole-second resolution, so the content decides an edit within the same second. + */ + public function testFingerprintChangesOnSameSecondContentChange(): void + { + $file1 = $this->createTempFile('mig1.php'); + $migrationFiles = [new SplFileInfo($file1)]; + $modified = $migrationFiles[0]->getMTime(); + + $this->cache->remember($migrationFiles, [], [], static fn () => self::schema(['users' => new SchemaConnection('users', 'mysql')])); + + file_put_contents($file1, 'cache->remember($migrationFiles, [], [], static function () use (&$called) { + $called = true; + + return self::schema([]); + }); + + $this->assertTrue($called, 'Callback should be called when the content changed within the same second'); + } + + public function testDisabledCacheAlwaysExecutesCallback(): void + { + $cache = new MigrationCache($this->resolver, $this->cacheDirectory, false); + $migrationFiles = [new SplFileInfo($this->createTempFile('mig1.php'))]; + + $cache->remember($migrationFiles, [], [], static fn () => self::schema(['users' => new SchemaConnection('users', 'mysql')])); + + $called = false; + $cache->remember($migrationFiles, [], [], static function () use (&$called) { + $called = true; + + return self::schema(['users' => new SchemaConnection('users', 'mysql')]); + }); + + $this->assertTrue($called, 'Callback should be called when cache is disabled'); + } + + public function testOldCacheFilesAreCleanedUp(): void + { + $file1 = $this->createTempFile('mig1.php'); + $migrationFiles = [new SplFileInfo($file1)]; + $schemaFiles = []; + + $this->cache->remember($migrationFiles, $schemaFiles, [], static fn () => self::schema(['v1' => new SchemaConnection('v1', 'mysql')])); + + $files = glob($this->cacheDirectory . '/hypervel_migrations_*.cache'); + $this->assertCount(1, $files); + $firstCacheFile = $files[0]; + + touch($file1, $migrationFiles[0]->getMTime() + 1); + clearstatcache(true, $file1); + + $this->cache->remember($migrationFiles, $schemaFiles, [], static fn () => self::schema(['v2' => new SchemaConnection('v2', 'mysql')])); + + $files = glob($this->cacheDirectory . '/hypervel_migrations_*.cache'); + $this->assertCount(1, $files, 'Should only be one cache file'); + $this->assertNotEquals($firstCacheFile, $files[0], 'New cache file should have different name'); + } + + /** + * Migrations read connection names from config keys that only aggregating them finds, so a + * changed value, or a key becoming present, invalidates the entry. + */ + public function testChangedConfigKeysTheMigrationsReadInvalidateTheEntry(): void + { + $migrationFiles = [new SplFileInfo($this->createTempFile('mig1.php'))]; + $calls = 0; + $aggregate = function () use (&$calls): array { + ++$calls; + $this->resolver->getConfiguredName('analytics.connection'); + + return self::schema(['users' => new SchemaConnection('users', 'mysql')]); + }; + + $this->cache->remember($migrationFiles, [], [], $aggregate); + $this->cache->remember($migrationFiles, [], [], $aggregate); + $this->assertSame(1, $calls); + + $this->config->set('analytics.connection', null); + $this->cache->remember($migrationFiles, [], [], $aggregate); + $this->assertSame(2, $calls, 'A key that became present can no longer select a default'); + + $this->config->set('analytics.connection', 'pgsql'); + $this->cache->remember($migrationFiles, [], [], $aggregate); + $this->cache->remember($migrationFiles, [], [], $aggregate); + $this->assertSame(3, $calls); + } + + /** + * The schema's config dependencies are declared from the consumed keys, so a hit in a new + * analysis must consume the keys the migrations read as aggregating them did. + */ + public function testCacheHitConsumesTheConfigKeysTheMigrationsRead(): void + { + $migrationFiles = [new SplFileInfo($this->createTempFile('mig1.php'))]; + + $this->cache->remember($migrationFiles, [], [], function (): array { + $this->resolver->getConfiguredName('analytics.connection'); + + return self::schema(['users' => new SchemaConnection('users', 'mysql')]); + }); + + $container = new Container; + $container->instance('config', $this->config); + $resolver = new ConnectionConfigResolver; + $resolver->setContainer($container); + + (new MigrationCache($resolver, $this->cacheDirectory, true))->remember($migrationFiles, [], [], function (): never { + $this->fail('The cached schema should be used'); + }); + + $this->assertSame(['analytics.connection'], $resolver->getConsumedKeys()); + } + + /** + * The PHP files the migrations read, such as one declaring a constant, invalidate the entry when they + * change or are deleted, and a hit returns them so their dependencies are declared again. + */ + public function testChangedSourcesTheMigrationsReadInvalidateTheEntry(): void + { + $migrationFiles = [new SplFileInfo($this->createTempFile('mig1.php'))]; + $source = $this->createTempFile('Constants.php'); + $tableSource = $this->createTempFile('Author.php'); + $calls = 0; + $aggregate = static function () use (&$calls, $source, $tableSource): array { + ++$calls; + $connection = new SchemaConnection('mysql', 'mysql'); + $connection->tables['users'] = new SchemaTable('users'); + $connection->tables['users']->sources = [$tableSource => true]; + + return self::schema(['mysql' => $connection], [$source => true]); + }; + + $this->cache->remember($migrationFiles, [], [], $aggregate); + $this->assertSame([$source => true], $this->cache->remember($migrationFiles, [], [], $aggregate)['sources']); + $this->assertSame(1, $calls); + + file_put_contents($source, 'cache->remember($migrationFiles, [], [], $aggregate); + $this->assertSame(2, $calls); + + unlink($tableSource); + $this->cache->remember($migrationFiles, [], [], $aggregate); + $this->assertSame(3, $calls); + } + + /** + * Get an aggregation result with the given connections and sources. + * + * @param array $connections + * @param array $sources + * @return array{connections: array, sources: array} + */ + private static function schema(array $connections, array $sources = []): array + { + return ['connections' => $connections, 'sources' => $sources]; + } + + private function createTempFile(string $name): string + { + $path = $this->cacheDirectory . '/' . $name; + touch($path); + + return $path; + } +} diff --git a/tests/PHPStan/Unit/MigrationHelperTest.php b/tests/PHPStan/Unit/MigrationHelperTest.php new file mode 100644 index 000000000..e9243b2a8 --- /dev/null +++ b/tests/PHPStan/Unit/MigrationHelperTest.php @@ -0,0 +1,384 @@ +parser = self::getContainer()->getService('currentPhpVersionSimpleDirectParser'); + $this->fileHelper = self::getContainer()->getByType(FileHelper::class); + $this->reflectionProvider = $this->createReflectionProvider(); + $this->initializerExprTypeResolver = self::getContainer()->getByType(InitializerExprTypeResolver::class); + $this->connectionConfigResolver = self::getContainer()->getByType(ConnectionConfigResolver::class); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } + + #[Test] + public function itKeepsAMigrationDirectoryThatDoesNotExist(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/missing'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + self::assertSame([$this->fileHelper->absolutizePath(__DIR__ . '/data/missing') => '*.[pP][hH][pP]'], $migrationHelper->getMigrationPaths()); + self::assertSame([], $migrationHelper->getMigrationFiles()); + } + + #[Test] + public function itHasNoMigrationDirectoriesWhenTheMigrationScanIsDisabled(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/basic_migration'], $this->fileHelper, true, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + self::assertSame([], $migrationHelper->getMigrationPaths()); + } + + #[Test] + public function itWillReturnEmptyArrayIfMigrationsPathIsNotADirectory(): void + { + $migrationHelper = new MigrationHelper($this->parser, ['foobar'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + self::assertSame([], $migrationHelper->initializeConnections()); + } + + #[Test] + public function itCanReadBasicMigrationsAndCreateTableStructure(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/basic_migration'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + $this->assertUsersTableSchema($connections); + } + + #[Test] + public function itCanReadSchemaDefinitionsFromAnyMethodInClass(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/migrations_with_different_methods'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + $this->assertUsersTableSchema($connections); + } + + #[Test] + public function itCanReadSchemaDefinitionsWithMultipleCreateAndDropMethodsForOneTable(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/complex_migrations'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertCount(1, $this->findSchema($connections)->tables); + self::assertSame([ + 'id' => ['int', false], + 'email' => ['string', false], + 'birthday' => ['string', false], + 'city' => ['string', true], + 'zip' => ['int', false], + 'created_at' => ['string', true], + 'updated_at' => ['string', true], + 'active' => ['int', false], + ], $this->describeColumns($connections, 'users')); + } + + #[Test] + public function itCanReadAdditionalDirectories(): void + { + $migrationHelper = new MigrationHelper($this->parser, [ + __DIR__ . '/data/basic_migration', + __DIR__ . '/data/additional_migrations', + ], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertCount(2, $this->findSchema($connections)->tables); + self::assertNotNull($this->findTable($connections, 'users')); + self::assertNotNull($this->findTable($connections, 'teams')); + } + + #[Test] + public function itCanHandleUseOfAfterMethodInMigration(): void + { + $migrationHelper = new MigrationHelper($this->parser, [ + __DIR__ . '/data/migrations_using_after_method', + ], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertCount(1, $this->findSchema($connections)->tables); + self::assertSame([ + 'id' => ['int', false], + 'name' => ['string', true], + 'created_at' => ['string', true], + 'updated_at' => ['string', true], + 'email' => ['string', false], + ], $this->describeColumns($connections, 'users')); + } + + #[Test] + public function itCanHandleAlterTableAndColumnRename(): void + { + $migrationHelper = new MigrationHelper($this->parser, [ + __DIR__ . '/data/rename_migrations', + ], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertCount(1, $this->findSchema($connections)->tables); + self::assertNull($this->findTable($connections, 'users')); + $columns = $this->describeColumns($connections, 'accounts'); + self::assertArrayNotHasKey('name', $columns); + self::assertSame(['string', true], $columns['full_name']); + } + + #[Test] + public function itCanHandleMigrationsWithSoftDeletes(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/migrations_using_soft_deletes'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertCount(1, $this->findSchema($connections)->tables); + self::assertCount(6, $this->describeColumns($connections, 'users')); + self::assertSame(['string', true], $this->describeColumns($connections, 'users')['deleted_at']); + } + + #[Test] + public function itCanHandleMigrationsWithSoftDeletesTz(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/migrations_using_soft_deletes_tz'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertCount(1, $this->findSchema($connections)->tables); + self::assertCount(6, $this->describeColumns($connections, 'users')); + self::assertSame(['string', true], $this->describeColumns($connections, 'users')['deleted_at']); + } + + #[Test] + public function itCanHandleMigrationsWithNullableTimestampsTz(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/migrations_using_nullable_timestamps_tz'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + $this->assertUsersTableSchema($connections); + } + + #[Test] + public function itCanHandleMigrationsWithDefaultArguments(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/migration_with_default_arguments'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertCount(1, $this->findSchema($connections)->tables); + self::assertSame([ + 'id' => ['int', false], + 'ip_address' => ['string', false], + 'custom_ip_address' => ['string', false], + 'mac_address' => ['string', false], + 'custom_mac_address' => ['string', false], + 'uuid' => ['string', false], + 'custom_uuid' => ['string', false], + 'ulid' => ['string', false], + 'custom_ulid' => ['string', false], + 'deleted_at' => ['string', true], + 'custom_soft_deletes' => ['string', true], + ], $this->describeColumns($connections, 'users')); + } + + /** + * Unlike upstream, which collected tables regardless of connection, the facts belong to the selected + * connection, so the fixture selects a configured one. + */ + #[Test] + public function itCanHandleConnectionBeforeSchemaCreate(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/migration_with_schema_connection'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertNull($this->findSchema($connections)); + self::assertCount(1, $this->findSchema($connections, 'mysql')->tables); + self::assertSame([ + 'id' => ['int<0, max>|numeric-string', false], + 'name' => ['string', true], + 'email' => ['string', false], + 'created_at' => ['string', true], + 'updated_at' => ['string', true], + ], $this->describeColumns($connections, 'users', 'mysql')); + } + + #[Test] + public function itCanDisableMigrationScanning(): void + { + $migrationHelper = new MigrationHelper($this->parser, [ + __DIR__ . '/data/basic_migration', + __DIR__ . '/data/additional_migrations', + ], $this->fileHelper, true, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertSame([], $connections); + } + + #[Test] + public function itCanHandleNullableInMigrations(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/migrations_using_nullable'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $columns = $this->describeColumns($migrationHelper->initializeConnections(), 'users'); + + self::assertSame(['string', false], $columns['name']); + self::assertSame(['string', true], $columns['email']); + self::assertSame(['string', true], $columns['address1']); + } + + /** + * Assert the default connection holds only the users table the basic migration creates. + * + * @param array $connections + */ + private function assertUsersTableSchema(array $connections): void + { + self::assertCount(1, $this->findSchema($connections)->tables); + self::assertSame([ + 'id' => ['int', false], + 'name' => ['string', true], + 'email' => ['string', false], + 'created_at' => ['string', true], + 'updated_at' => ['string', true], + ], $this->describeColumns($connections, 'users')); + } + + #[Test] + public function itCanHandleMigrationsWithArrayPassedToDropColumn(): void + { + $migrationHelper = new MigrationHelper($this->parser, [ + __DIR__ . '/data/migrations_using_drop_column', + ], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + $this->assertUsersTableSchema($connections); + } + + #[Test] + public function itCanHandleMigrationsWithDropTimestamps(): void + { + $migrationHelper = new MigrationHelper($this->parser, [ + __DIR__ . '/data/migrations_using_drop_timestamps', + ], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertCount(1, $this->findSchema($connections)->tables); + self::assertSame([ + 'id' => ['int', false], + 'name' => ['string', true], + 'email' => ['string', false], + ], $this->describeColumns($connections, 'users')); + } + + #[Test] + public function itCanHandleMigrationsWithDropRememberToken(): void + { + $migrationHelper = new MigrationHelper($this->parser, [ + __DIR__ . '/data/migrations_using_drop_remember_token', + ], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertCount(1, $this->findSchema($connections)->tables); + self::assertSame([ + 'id' => ['int', false], + 'name' => ['string', true], + 'email' => ['string', false], + ], $this->describeColumns($connections, 'users')); + } + + #[Test] + public function itCanHandleMigrationsWithIfStatements(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/conditional_migrations'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + self::assertSame([ + 'id' => ['int', false], + 'name' => ['string', true], + 'address1' => ['string', false], + 'address2' => ['string', true], + 'email' => ['string', false], + ], $this->describeColumns($connections, 'users')); + } + + #[Test] + public function itCanHandleMigrationsWithConstAsTable(): void + { + $migrationHelper = new MigrationHelper($this->parser, [__DIR__ . '/data/migration_with_const'], $this->fileHelper, false, $this->reflectionProvider, $this->initializerExprTypeResolver, $this->connectionConfigResolver); + + $connections = $migrationHelper->initializeConnections(); + + $this->assertUsersTableSchema($connections); + } + + /** + * Unlike upstream, the column is named after the model's custom key, as Blueprint::foreignIdFor() names it. + */ + #[Test] + public function itResolvesForeignIdForColumnTypeUsingTablePhpAttribute(): void + { + $migrationHelper = new MigrationHelper( + $this->parser, + [ + __DIR__ . '/data/basic_migration', + __DIR__ . '/data/migration_with_foreign_id_for', + ], + $this->fileHelper, + false, + $this->reflectionProvider, + $this->initializerExprTypeResolver, + $this->connectionConfigResolver, + ); + + $connections = $migrationHelper->initializeConnections(); + + self::assertSame(['string', false], $this->describeColumns($connections, 'articles')['member_with_custom_key_uuid']); + } +} diff --git a/tests/PHPStan/Unit/ModelCastHelperTest.php b/tests/PHPStan/Unit/ModelCastHelperTest.php new file mode 100644 index 000000000..50fcbb8ff --- /dev/null +++ b/tests/PHPStan/Unit/ModelCastHelperTest.php @@ -0,0 +1,81 @@ +reflectionProvider = $this->createReflectionProvider(); + $this->parser = self::getContainer()->getService('cachedRichParser'); + } + + #[Test] + public function itUsesCustomPrimaryKeyFromTablePhpAttributeWhenResolvingCasts(): void + { + $modelCastHelper = new ModelCastHelper( + $this->reflectionProvider, + $this->parser, + false, + self::getContainer()->getByType(ScopeFactory::class), + self::getContainer()->getByType(DeclarationDependencyTracker::class), + self::getContainer()->getByType(ModelHelper::class), + ); + + $classReflection = $this->reflectionProvider->getClass(MemberWithCustomKey::class); + + // The model uses #[Table(key: 'uuid', keyType: 'string')], so getCasts() returns + // ['uuid' => 'string']. Without initializeModelAttributes(), primaryKey stays 'id' + // and the cast for 'uuid' is never registered. + self::assertSame('string', $modelCastHelper->getCastForProperty($classReflection, 'uuid')); + self::assertNull($modelCastHelper->getCastForProperty($classReflection, 'id')); + } + + /** + * Unlike upstream, the backing values the cast stores as they are are accepted too, as AsEnumCollection declares. + */ + #[Test] + public function itResolvesTheEnumClassWhenWritingAnEnumCollectionCast(): void + { + $modelCastHelper = new ModelCastHelper( + $this->reflectionProvider, + $this->parser, + false, + self::getContainer()->getByType(ScopeFactory::class), + self::getContainer()->getByType(DeclarationDependencyTracker::class), + self::getContainer()->getByType(ModelHelper::class), + ); + + $writeableType = $modelCastHelper->getWriteableType(AsEnumCollection::class . ':' . BackedEnumeration::class, new MixedType); + + self::assertSame('iterable|null', $writeableType->describe(VerbosityLevel::precise())); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Unit/ModelPropertyHelperTest.php b/tests/PHPStan/Unit/ModelPropertyHelperTest.php new file mode 100644 index 000000000..4835f1107 --- /dev/null +++ b/tests/PHPStan/Unit/ModelPropertyHelperTest.php @@ -0,0 +1,307 @@ +reflectionProvider = $this->createReflectionProvider(); + $this->parser = self::getContainer()->getService('currentPhpVersionSimpleDirectParser'); + $this->fileHelper = self::getContainer()->getByType(FileHelper::class); + } + + #[Test] + public function itUsesCustomTableNameFromTablePhpAttributeWhenCheckingDatabaseProperty(): void + { + $modelPropertyHelper = $this->buildModelPropertyHelper( + [__DIR__ . '/data/basic_migration'], + ); + + $classReflection = $this->reflectionProvider->getClass(Member::class); + + // The model uses #[Table(name: 'users')], not the default 'members' derived from + // the class name. The fix ensures initializeModelAttributes() is called so the correct + // table is resolved and columns from the users table are found. + self::assertTrue($modelPropertyHelper->hasDatabaseProperty($classReflection, 'email')->yes()); + self::assertTrue($modelPropertyHelper->hasDatabaseProperty($classReflection, 'nonexistent_column')->no()); + } + + #[Test] + public function itUsesCustomKeyMetadataFromTablePhpAttribute(): void + { + $modelPropertyHelper = $this->buildModelPropertyHelper([__DIR__ . '/data/basic_migration']); + $classReflection = $this->reflectionProvider->getClass(MemberWithCustomKey::class); + + self::assertTrue($modelPropertyHelper->hasDatabaseProperty($classReflection, 'uuid')->yes()); + self::assertInstanceOf( + StringType::class, + $modelPropertyHelper->getDatabaseProperty($classReflection, 'uuid')->getReadableType(), + ); + } + + #[Test] + public function itAppliesNonIncrementingStringKeyMetadata(): void + { + $modelPropertyHelper = $this->buildModelPropertyHelper([__DIR__ . '/data/basic_migration']); + $classReflection = $this->reflectionProvider->getClass(MemberWithNonIncrementingStringKey::class); + + self::assertInstanceOf( + StringType::class, + $modelPropertyHelper->getDatabaseProperty($classReflection, 'id')->getReadableType(), + ); + } + + #[Test] + public function itAppliesAttributesThatDisableTimestamps(): void + { + $modelPropertyHelper = $this->buildModelPropertyHelper([__DIR__ . '/data/basic_migration']); + + foreach ([MemberWithoutTimestampsTable::class, MemberWithoutTimestampsAttribute::class] as $modelClass) { + $classReflection = $this->reflectionProvider->getClass($modelClass); + + self::assertTrue($modelPropertyHelper->hasDatabaseProperty($classReflection, 'created_at')->yes()); + self::assertInstanceOf( + StringType::class, + TypeCombinator::removeNull( + $modelPropertyHelper->getDatabaseProperty($classReflection, 'created_at')->getReadableType(), + ), + ); + } + } + + /** + * Attributes are named as the connection's ATTR_CASE fetches the columns, though the written name still names a column. + */ + #[Test] + public function itNamesAttributesAsTheConnectionFetchesColumns(): void + { + $this->withConfig(['database.connections.' . config('database.default') . '.options' => [PDO::ATTR_CASE => PDO::CASE_UPPER]], function (): void { + $modelPropertyHelper = $this->buildModelPropertyHelper( + [__DIR__ . '/data/basic_migration'], + connectionConfigResolver: new ConnectionConfigResolver, + ); + $member = $this->reflectionProvider->getClass(Member::class); + + self::assertTrue($modelPropertyHelper->hasDatabaseProperty($member, 'EMAIL')->yes()); + self::assertSame('string', $modelPropertyHelper->getDatabaseProperty($member, 'EMAIL')->getReadableType()->describe(VerbosityLevel::precise())); + self::assertSame('null', $modelPropertyHelper->getDatabaseProperty($member, 'email')->getReadableType()->describe(VerbosityLevel::precise())); + }); + } + + /** + * The key type comes from the memoized instance, so it does not depend on whether casts were read first. + */ + #[Test] + public function itReadsAUniqueStringKeyBeforeAnyCasts(): void + { + $modelPropertyHelper = $this->buildModelPropertyHelper([]); + + self::assertSame( + 'string', + $modelPropertyHelper->getDatabaseProperty($this->reflectionProvider->getClass(UuidModel::class), 'id')->getReadableType()->describe(VerbosityLevel::precise()), + ); + } + + /** + * Unlike upstream, which reported the columns of unknown tables as missing, a model whose schema is + * not known has its properties assumed. + */ + #[Test] + public function itLoadsMigrationsOnlyOnceWhenNoTablesAreFound(): void + { + $migrationHelper = new class($this->parser, ['foobar'], $this->fileHelper, false, $this->reflectionProvider, self::getContainer()->getByType(InitializerExprTypeResolver::class), self::getContainer()->getByType(ConnectionConfigResolver::class)) extends MigrationHelper { + public int $migrationFileLookups = 0; + + /** @return SplFileInfo[] */ + public function getMigrationFiles(): array + { + ++$this->migrationFileLookups; + + return parent::getMigrationFiles(); + } + }; + + $modelPropertyHelper = $this->buildModelPropertyHelper([], $migrationHelper, ['foobar']); + $member = $this->reflectionProvider->getClass(Member::class); + + self::assertTrue($modelPropertyHelper->hasDatabaseProperty($member, 'email')->maybe()); + $lookupsAfterFirstLoad = $migrationHelper->migrationFileLookups; + + // An empty result still counts as loaded, so later lookups must not scan again. + self::assertTrue($modelPropertyHelper->hasDatabaseProperty($member, 'name')->maybe()); + self::assertTrue($modelPropertyHelper->hasDatabaseProperty($this->reflectionProvider->getClass(MemberWithCustomKey::class), 'title')->maybe()); + self::assertSame($lookupsAfterFirstLoad, $migrationHelper->migrationFileLookups); + } + + /** + * A cast key is an attribute without a column, but casts() that cannot be read do not make every name one. + */ + #[Test] + public function itTreatsCastKeysAsAttributes(): void + { + $modelPropertyHelper = self::getContainer()->getByType(ModelPropertyHelper::class); + + self::assertTrue($modelPropertyHelper->hasDatabaseProperty($this->reflectionProvider->getClass(VirtualCastModel::class), 'dimensions')->yes()); + self::assertTrue($modelPropertyHelper->hasDatabaseProperty($this->reflectionProvider->getClass(UnknownCastsModel::class), 'misspelled')->no()); + } + + #[Test] + public function itTracksTheSchemaDirectoriesForFilesUsingAModel(): void + { + $container = self::getContainer(); + $collector = $container->getByType(ValueDependencyCollector::class); + + $expected = []; + + foreach ($container->getByType(MigrationHelper::class)->getMigrationPaths() as $directory => $pattern) { + self::assertSame('*_*.php', $pattern); + $expected[] = ValueDependencyCollector::getId(DirectoryResultCacheValueExtension::class, $collector->getDirectoryKey($directory, $pattern)); + } + + foreach ($container->getByType(SquashedMigrationHelper::class)->getSchemaDirectories() as $directory) { + $expected[] = ValueDependencyCollector::getId(DirectoryResultCacheValueExtension::class, $collector->getDirectoryKey($directory, '*')); + } + + // The application's migration and schema directories, and the workbench migrations a provider loads. + self::assertCount(3, $expected); + + $usingModel = $this->analyseValueDependencies(__DIR__ . '/data/model-schema-dependency.php'); + + foreach ($expected as $id) { + self::assertContains($id, $usingModel); + } + + self::assertSame([], $this->analyseValueDependencies(__DIR__ . '/data/no-model-schema-dependency.php')); + } + + /** @return list the ids of the values the analysis of the file depends on */ + private function analyseValueDependencies(string $file): array + { + $file = $this->fileHelper->normalizePath($file); + $result = self::getContainer()->getByType(FileAnalyser::class)->analyseFile( + $file, + [$file => true], + new DirectRuleRegistry([]), + new CollectorRegistry([]), + null, + ); + + self::assertSame([], $result->getErrors()); + + return $result->getValueDependencies()['dependents'][$file]['analysis']; + } + + /** + * @param string[] $migrationPaths + * @param string[] $schemaPaths + */ + private function buildModelPropertyHelper( + array $migrationPaths, + ?MigrationHelper $migrationHelper = null, + array $schemaPaths = [], + ?ConnectionConfigResolver $connectionConfigResolver = null, + ): ModelPropertyHelper { + $connectionConfigResolver ??= self::getContainer()->getByType(ConnectionConfigResolver::class); + + $migrationHelper ??= new MigrationHelper( + $this->parser, + $migrationPaths, + $this->fileHelper, + false, + $this->reflectionProvider, + self::getContainer()->getByType(InitializerExprTypeResolver::class), + $connectionConfigResolver, + ); + + $squashedMigrationHelper = new SquashedMigrationHelper( + $schemaPaths, + $this->fileHelper, + $connectionConfigResolver, + false, + ); + + $modelHelper = self::getContainer()->getByType(ModelHelper::class); + + $modelCastHelper = new ModelCastHelper( + $this->reflectionProvider, + self::getContainer()->getService('cachedRichParser'), + false, + self::getContainer()->getByType(ScopeFactory::class), + self::getContainer()->getByType(DeclarationDependencyTracker::class), + $modelHelper, + ); + + return new ModelPropertyHelper( + self::getContainer()->getByType(TypeStringResolver::class), + $migrationHelper, + $squashedMigrationHelper, + $modelCastHelper, + $modelHelper, + new MigrationCache($connectionConfigResolver, sys_get_temp_dir(), false), + self::getContainer()->getByType(DeclarationDependencyTracker::class), + $this->reflectionProvider, + $connectionConfigResolver, + self::getContainer()->getByType(DataTypeToPhpTypeConverter::class), + self::getContainer()->getByType(ConfigSourceResolver::class), + ); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Unit/ModelPropertyTypeNodeResolverExtensionTest.php b/tests/PHPStan/Unit/ModelPropertyTypeNodeResolverExtensionTest.php new file mode 100644 index 000000000..3c6acf82e --- /dev/null +++ b/tests/PHPStan/Unit/ModelPropertyTypeNodeResolverExtensionTest.php @@ -0,0 +1,55 @@ +getByType(ValueDependencyCollector::class); + $file = $container->getByType(FileHelper::class)->normalizePath(__DIR__ . '/data/model-property-schema-dependency.php'); + + $result = $container->getByType(FileAnalyser::class)->analyseFile( + $file, + [$file => true], + new DirectRuleRegistry([]), + new CollectorRegistry([]), + null, + ); + + self::assertSame([], $result->getErrors()); + + $migrationPaths = $container->getByType(MigrationHelper::class)->getMigrationPaths(); + + // The workbench migrations a provider loads and the application's migrations. + self::assertCount(2, $migrationPaths); + + foreach ($migrationPaths as $directory => $pattern) { + self::assertSame('*_*.php', $pattern); + self::assertContains( + ValueDependencyCollector::getId(DirectoryResultCacheValueExtension::class, $collector->getDirectoryKey($directory, $pattern)), + $result->getValueDependencies()['dependents'][$file]['analysis'], + ); + } + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../Type/data/config-check-model-properties.neon']; + } +} diff --git a/tests/PHPStan/Unit/SchemaAggregatorTest.php b/tests/PHPStan/Unit/SchemaAggregatorTest.php new file mode 100644 index 000000000..75936f06c --- /dev/null +++ b/tests/PHPStan/Unit/SchemaAggregatorTest.php @@ -0,0 +1,1162 @@ +aggregate(<<id(); + }); + + Schema::table({$constant}, function (Blueprint \$table) { + \$table->string('email')->nullable(); + }); + PHP); + + $this->assertSame([ + 'id' => ['int<0, max>|numeric-string', false], + 'email' => ['string', true], + ], $this->columns($aggregator, 'users')); + } + + /** @return iterable */ + public static function tableConstants(): iterable + { + yield 'untyped' => ['Constants::USERS']; + yield 'PHPDoc type' => ['Constants::DOCUMENTED_USERS']; + yield 'inherited initializer' => ['InheritedConstants::DOCUMENTED_USERS']; + yield 'native type' => ['TypedConstants::USERS']; + yield 'typed expression' => ['TypedConstants::CONCATENATED_USERS']; + } + + #[Test] + public function itAppliesCreateTableSqlFromStatementCalls(): void + { + $aggregator = $this->aggregate(<<<'PHP' + DB::statement('CREATE TABLE `users` ( + `id` int unsigned NOT NULL, + `email` varchar(255) DEFAULT NULL + )'); + + Schema::table('users', function (Blueprint $table) { + $table->string('name'); + }); + + DB::connection('mysql')->statement('CREATE TABLE `reports` (`id` int unsigned NOT NULL)'); + + \Hypervel\Support\Facades\DB::unprepared(<<<'SQL' + CREATE TABLE `posts` (`id` int NOT NULL); + CREATE TABLE `comments` (`id` int NOT NULL, `body` varchar(255) NULL); + SQL); + PHP); + + $this->assertSame([ + 'id' => ['int<0, max>', false], + 'email' => ['string', true], + 'name' => ['string', false], + ], $this->columns($aggregator, 'users')); + $this->assertSame(['id' => ['int<0, max>', false]], $this->columns($aggregator, 'reports')); + $this->assertSame(['id' => ['int', false]], $this->columns($aggregator, 'posts')); + $this->assertSame(['id' => ['int', false], 'body' => ['string', true]], $this->columns($aggregator, 'comments')); + $this->assertTrue($this->table($aggregator, 'comments')->complete); + } + + /** + * Unlike upstream, which kept the Blueprint columns, statements that change a known relation make it unknown. + */ + #[Test] + public function itForgetsRelationsRawSqlChanges(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', function (Blueprint $table) { + $table->string('name'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->string('title'); + }); + + DB::statement('ALTER TABLE users RENAME COLUMN name TO full_name'); + DB::statement('INSERT INTO posts (title) VALUES (1)'); + PHP); + + $this->assertSame([], $this->columns($aggregator, 'users')); + $this->assertFalse($this->table($aggregator, 'users')->complete); + $this->assertSame(['title' => ['string', false]], $this->columns($aggregator, 'posts')); + $this->assertTrue($this->schema($aggregator)->complete); + } + + #[Test] + #[DataProvider('unreadableSql')] + public function itForgetsTheConnectionUnreadableSqlRunsOn(string $statement): void + { + $aggregator = $this->aggregate(<<string('name'); + }); + + {$statement} + PHP); + + $this->assertNull($this->columns($aggregator, 'users')); + $this->assertFalse($this->schema($aggregator)->complete); + } + + /** @return iterable */ + public static function unreadableSql(): iterable + { + yield 'rejected by the parser' => ["DB::statement('CREATE TABLE `broken (id INT)');"]; + yield 'not a literal' => ["\$sql = 'CREATE TABLE `skipped` (`id` int NOT NULL)';\nDB::statement(\$sql);"]; + } + + #[Test] + #[DataProvider('indexMethodCalls')] + public function itKeepsColumnsUnchangedByIndexMethods(string $indexMethodCall): void + { + $aggregator = $this->aggregate(<<id(); + \$table->string('body')->nullable(); + \$table->geometry('location', 'point')->nullable(); + \$table->vector('embedding', 3)->nullable(); + }); + + Schema::table('posts', function (Blueprint \$table) { + {$indexMethodCall} + }); + PHP); + + $this->assertSame([ + 'id' => ['int<0, max>|numeric-string', false], + 'body' => ['string', true], + 'location' => [null, true], + 'embedding' => [null, true], + ], $this->columns($aggregator, 'posts')); + $this->assertTrue($this->table($aggregator, 'posts')->complete); + } + + /** @return iterable */ + public static function indexMethodCalls(): iterable + { + yield 'fullText' => ["\$table->fullText('body');"]; + yield 'dropFullText' => ["\$table->dropFullText('posts_body_fulltext');"]; + yield 'spatialIndex' => ["\$table->spatialIndex('location');"]; + yield 'dropSpatialIndex' => ["\$table->dropSpatialIndex('posts_location_spatialindex');"]; + yield 'vectorIndex' => ["\$table->vectorIndex('embedding');"]; + yield 'rawIndex' => ["\$table->rawIndex('lower(body)', 'posts_body_lower_index');"]; + } + + #[Test] + public function itResolvesColumnNameConstants(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('compensations', function (Blueprint $table) { + $table->id(); + $table->decimal(Constants::AMOUNT, 10, 2)->nullable(); + $table->string(Constants::MISSING); + }); + PHP); + + // An added column with an unknown name keeps the known columns. + $this->assertSame([ + 'id' => ['int<0, max>|numeric-string', false], + 'amount' => ['numeric-string', true], + ], $this->columns($aggregator, 'compensations')); + $this->assertFalse($this->table($aggregator, 'compensations')->complete); + + $aggregator = $this->aggregate(<<<'PHP' + Schema::table('compensations', function (Blueprint $table) { + $table->string(Constants::MISSING)->change(); + }); + PHP, aggregator: $aggregator); + + // change() may modify any known column. + $this->assertSame([], $this->columns($aggregator, 'compensations')); + } + + /** + * The files a table's columns are read from are its sources, while the files that can change which tables exist + * or which connection a migration runs on are sources of every fact. + */ + #[Test] + public function itRecordsTheFilesFactsAreReadFrom(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create(InheritedConstants::DOCUMENTED_USERS, function (Blueprint $table) { + $table->decimal(Constants::AMOUNT, 10, 2); + $table->foreignIdFor(Author::class); + }); + PHP); + + // The nested trait makes the key a UUID. + $this->assertSame([ + 'amount' => ['numeric-string', false], + 'author_id' => ['string', false], + ], $this->columns($aggregator, 'users')); + // The model's sources include the constant its table property reads. + $this->assertEqualsCanonicalizing( + $this->fileNames(Constants::class, Author::class, Person::class, KeyedByUuid::class, HasUuids::class, HasUniqueStringIds::class, TypedConstants::class), + array_keys($this->table($aggregator, 'users')->sources), + ); + // A constant read through a subclass changes when the subclass overrides it. + $this->assertEqualsCanonicalizing($this->fileNames(InheritedConstants::class, Constants::class), array_keys($aggregator->sources)); + + $aggregator = $this->apply(<<<'PHP' + return new class extends ConnectionPropertyMigration + { + public function up(): void + { + } + }; + PHP); + + $this->assertSame($this->fileNames(ConnectionPropertyMigration::class), array_keys($aggregator->sources)); + } + + #[Test] + public function itReadsColumnsFromMorphMethods(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('attachments', function (Blueprint $table) { + $table->numericMorphs('numeric'); + $table->nullableNumericMorphs('nullable_numeric'); + $table->uuidMorphs('uuid'); + $table->nullableUuidMorphs('nullable_uuid'); + $table->ulidMorphs('ulid'); + $table->nullableUlidMorphs('nullable_ulid'); + }); + PHP); + + $this->assertSame([ + 'numeric_type' => ['string', false], + 'numeric_id' => ['int<0, max>|numeric-string', false], + 'nullable_numeric_type' => ['string', true], + 'nullable_numeric_id' => ['int<0, max>|numeric-string', true], + 'uuid_type' => ['string', false], + 'uuid_id' => ['string', false], + 'nullable_uuid_type' => ['string', true], + 'nullable_uuid_id' => ['string', true], + 'ulid_type' => ['string', false], + 'ulid_id' => ['string', false], + 'nullable_ulid_type' => ['string', true], + 'nullable_ulid_id' => ['string', true], + ], $this->columns($aggregator, 'attachments')); + } + + /** + * Unlike upstream, which skipped the calls, names that are not strings make what they change unknown. + */ + #[Test] + #[DataProvider('enumCases')] + public function itForgetsWhatEnumCaseNamesChange(string $case): void + { + $aggregator = $this->aggregate(<<id(); + \$table->string({$case}); + }); + PHP); + + $this->assertSame(['id' => ['int<0, max>|numeric-string', false]], $this->columns($aggregator, 'statuses')); + $this->assertFalse($this->table($aggregator, 'statuses')->complete); + + $aggregator = $this->aggregate(<<id(); + }); + PHP, aggregator: $aggregator); + + $this->assertFalse($this->schema($aggregator)->complete); + } + + /** @return iterable */ + public static function enumCases(): iterable + { + yield 'pure' => ['PureStatus::Draft']; + yield 'backed' => ['BackedStatus::Draft']; + } + + #[Test] + public function itReadsArrowFunctionMigrationCallbacks(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', fn (Blueprint $table) => $table->id()); + + Schema::table('users', fn (Blueprint $table) => $table->string('kyc_status')->nullable()); + + Schema::table('users', function (Blueprint $table) { + $table->after('id', fn (Blueprint $table) => $table->integer('age')); + }); + PHP); + + $this->assertSame([ + 'id' => ['int<0, max>|numeric-string', false], + 'kyc_status' => ['string', true], + 'age' => ['int', false], + ], $this->columns($aggregator, 'users')); + } + + /** + * Unlike upstream, which skipped them, callbacks the aggregator cannot follow make their table unknown. + */ + #[Test] + public function itForgetsTablesUnfollowableCallbacksChange(): void + { + foreach ([ + "Schema::table('users', fn (NotABlueprint \$table) => \$table->string('name'));", + "Schema::table('users', fn () => null);", + "Schema::table('users', function (Blueprint \$table) {\n \$table->after('id', fn () => null);\n});", + "Schema::table('users', \$callback);", + ] as $statement) { + $aggregator = $this->aggregate(<< \$table->id()); + + Schema::create('posts', fn (Blueprint \$table) => \$table->id()); + + {$statement} + PHP); + + $this->assertSame([], $this->columns($aggregator, 'users'), $statement); + $this->assertFalse($this->table($aggregator, 'users')->complete, $statement); + $this->assertSame(['id' => ['int<0, max>|numeric-string', false]], $this->columns($aggregator, 'posts'), $statement); + } + } + + #[Test] + public function itLeavesNoMissingColumnClaimAfterRawDropsAndRenames(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', fn (Blueprint $table) => $table->string('name')); + Schema::create('posts', fn (Blueprint $table) => $table->string('title')); + Schema::create('tags', fn (Blueprint $table) => $table->string('label')); + + DB::statement('DROP TABLE users'); + DB::statement('ALTER TABLE posts RENAME TO articles'); + DB::statement('CREATE INDEX tags_label_index ON tags (label)'); + PHP); + + foreach (['users', 'posts', 'articles'] as $table) { + $this->assertSame([], $this->columns($aggregator, $table), $table); + $this->assertFalse($this->table($aggregator, $table)->complete, $table); + } + + // Index statements keep the facts. + $this->assertSame(['label' => ['string', false]], $this->columns($aggregator, 'tags')); + $this->assertTrue($this->table($aggregator, 'tags')->complete); + + // A table the same input creates and alters is unknown, so an alteration the parser could not apply leaves no column behind. + $aggregator = $this->aggregate("DB::statement('CREATE TABLE users (id INTEGER, name TEXT); ALTER TABLE users DROP COLUMN name;');", 'sqlite'); + + $this->assertSame([], $this->columns($aggregator, 'users', 'sqlite')); + $this->assertFalse($this->table($aggregator, 'users', 'sqlite')->complete); + } + + /** + * The snapshot does not distinguish IF NOT EXISTS, so a definition of a relation that may exist leaves it unknown. + */ + #[Test] + #[DataProvider('redefinitions')] + public function itForgetsARelationRawSqlMayRedefine(string $statement): void + { + $aggregator = $this->aggregate(<< \$table->string('name')); + + {$statement} + PHP); + + $this->assertSame([], $this->columns($aggregator, 'users')); + $this->assertFalse($this->table($aggregator, 'users')->complete); + } + + /** @return iterable */ + public static function redefinitions(): iterable + { + yield 'if not exists' => ["DB::statement('CREATE TABLE IF NOT EXISTS users (id int)');"]; + yield 'drop and recreate' => ["DB::unprepared('DROP TABLE users; CREATE TABLE users (id int)');"]; + yield 'case variant' => ["DB::statement('CREATE TABLE Users (id int)');"]; + } + + /** + * MySQL compares table names without case on some servers, so changing one spelling forgets the others. + */ + #[Test] + public function itForgetsMySqlCaseVariantsAChangeMayReach(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', fn (Blueprint $table) => $table->string('name')); + Schema::create('posts', fn (Blueprint $table) => $table->string('title')); + + DB::statement('ALTER TABLE Users ADD COLUMN age int'); + + Schema::table('POSTS', fn (Blueprint $table) => $table->string('body')); + PHP); + + $this->assertSame([], $this->columns($aggregator, 'users')); + $this->assertSame([], $this->columns($aggregator, 'posts')); + $this->assertSame(['body' => ['string', false]], $this->columns($aggregator, 'POSTS')); + } + + /** + * SQLite compares names without case, so its identities do too. + */ + #[Test] + public function itMatchesSqliteNamesWithoutCase(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('Users', fn (Blueprint $table) => $table->string('name')); + Schema::table('users', fn (Blueprint $table) => $table->string('email')); + PHP, 'sqlite'); + + $this->assertSame(['name' => ['string', false], 'email' => ['string', false]], $this->columns($aggregator, 'USERS', 'sqlite')); + } + + /** + * MySQL and SQLite match the column a rename, drop or change() names in any letter case. + */ + #[Test] + public function itMatchesChangedColumnsWithoutCase(): void + { + foreach (['mysql', 'sqlite'] as $connection) { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', function (Blueprint $table) { + $table->string('Name'); + $table->string('Email'); + $table->string('Title'); + }); + + Schema::table('users', function (Blueprint $table) { + $table->renameColumn('name', 'full_name'); + $table->dropColumn('email'); + }); + PHP, $connection); + + $this->assertSame(['Title' => ['string', false], 'full_name' => ['string', false]], $this->columns($aggregator, 'users', $connection), $connection); + + // The spelling a column keeps after change() depends on the database. + $aggregator = $this->aggregate("Schema::table('users', fn (Blueprint \$table) => \$table->text('title')->change());", $connection, $aggregator); + + $this->assertSame(['full_name' => ['string', false]], $this->columns($aggregator, 'users', $connection), $connection); + $this->assertFalse($this->table($aggregator, 'users', $connection)->complete); + } + } + + /** + * addColumn() merges literal parameters over its type and name, and the chain's modifiers override them. + */ + #[Test] + public function itAppliesAddColumnParameters(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', function (Blueprint $table) { + $table->addColumn('string', 'name', ['nullable' => true]); + $table->addColumn('string', 'email', ['nullable' => true])->nullable(false); + $table->addColumn('string', 'replaced', ['type' => 'integer', 'name' => 'votes', 'unsigned' => true]); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->string('title'); + $table->addColumn('string', 'body', $this->parameters); + }); + PHP, members: 'public array $parameters = [];'); + + $this->assertSame( + ['name' => ['string', true], 'email' => ['string', false], 'votes' => ['int<0, max>', false]], + $this->columns($aggregator, 'users'), + ); + // Unknown parameters can set any attribute, so the table is unknown. + $this->assertFalse($this->table($aggregator, 'posts')->complete); + $this->assertSame([], $this->columns($aggregator, 'posts')); + } + + /** + * Unlike upstream's drop, removeColumn() only cancels the definitions the Blueprint queued. + */ + #[Test] + public function itKeepsAColumnRemoveColumnCancels(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', fn (Blueprint $table) => $table->string('name')); + + Schema::table('users', function (Blueprint $table) { + $table->integer('name')->change(); + $table->removeColumn('name'); + }); + PHP); + + $this->assertSame(['name' => [null, true]], $this->columns($aggregator, 'users')); + $this->assertTrue($this->table($aggregator, 'users')->complete); + } + + #[Test] + public function itDecidesColumnGuardsAndFollowsBothOutcomesOtherwise(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', function (Blueprint $table) { + $table->string('name'); + $table->string('nickname'); + }); + + Schema::table('users', fn (Blueprint $table) => $table->removeColumn('nickname')); + + Schema::whenTableHasColumn('users', 'NAME', fn (Blueprint $table) => $table->string('present')); + Schema::whenTableDoesntHaveColumn('users', 'missing', fn (Blueprint $table) => $table->string('absent')); + Schema::whenTableHasColumn('users', 'missing', fn (Blueprint $table) => $table->string('skipped')); + Schema::whenTableHasColumn('users', 'nickname', fn (Blueprint $table) => $table->string('undecided')); + Schema::whenTableHasIndex('users', 'users_name_index', fn (Blueprint $table) => $table->string('indexed')); + PHP); + + $this->assertSame([ + 'name' => ['string', false], + 'nickname' => [null, true], + 'present' => ['string', false], + 'absent' => ['string', false], + 'undecided' => [null, true], + 'indexed' => [null, true], + ], $this->columns($aggregator, 'users')); + $this->assertTrue($this->table($aggregator, 'users')->complete); + } + + #[Test] + public function itJoinsBranchesAndEarlyReturns(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', fn (Blueprint $table) => $table->string('name')); + + if ($this->flag) { + Schema::table('users', fn (Blueprint $table) => $table->string('both')); + } else { + Schema::table('users', function (Blueprint $table) { + $table->string('both'); + $table->string('one'); + }); + } + + if ($this->flag) { + return; + } + + Schema::create('posts', fn (Blueprint $table) => $table->string('title')); + PHP, members: 'public bool $flag = true;'); + + $this->assertSame([ + 'name' => ['string', false], + 'both' => ['string', false], + 'one' => [null, true], + ], $this->columns($aggregator, 'users')); + $this->assertTrue($this->table($aggregator, 'users')->complete); + + // A table one outcome created may not exist. + $this->assertSame(['title' => ['string', false]], $this->columns($aggregator, 'posts')); + $this->assertFalse($this->table($aggregator, 'posts')->complete); + } + + #[Test] + public function itAppliesHelperMethodsAndForeignKeyFreeCallbacks(): void + { + $aggregator = $this->aggregate(<<<'PHP' + $this->createUsers(); + + Schema::withoutForeignKeyConstraints(function () { + Schema::table('users', fn (Blueprint $table) => $table->string('email')); + }); + PHP, members: <<<'PHP' + private function createUsers(): void + { + Schema::create('users', fn (Blueprint $table) => $table->string('name')); + + $this->createUsers(); + } + PHP); + + $this->assertSame(['name' => ['string', false], 'email' => ['string', false]], $this->columns($aggregator, 'users')); + } + + /** + * Closures run where the analysis cannot follow, so what they change is unknown. + */ + #[Test] + public function itForgetsTablesUnfollowedClosuresChange(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('posts', fn (Blueprint $table) => $table->string('title')); + + DB::transaction(fn () => Schema::create('users', fn (Blueprint $table) => $table->id())); + PHP); + + $this->assertSame([], $this->columns($aggregator, 'users')); + $this->assertFalse($this->table($aggregator, 'users')->complete); + $this->assertSame(['title' => ['string', false]], $this->columns($aggregator, 'posts')); + $this->assertTrue($this->schema($aggregator)->complete); + } + + #[Test] + public function itIgnoresDiscardedFirstClassCallables(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', fn (Blueprint $table) => $table->string('name')); + + Schema::table(...); + + Schema::table('users', function (Blueprint $table) { + $table->string(...); + $table->string('email')->nullable(...); + }); + PHP); + + $this->assertSame(['name' => ['string', false], 'email' => ['string', false]], $this->columns($aggregator, 'users')); + $this->assertTrue($this->table($aggregator, 'users')->complete); + } + + /** + * A kept callable can run later where the analysis cannot see it. + */ + #[Test] + public function itForgetsWhatKeptFirstClassCallablesCanChange(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', fn (Blueprint $table) => $table->string('name')); + Schema::create('posts', fn (Blueprint $table) => $table->string('title')); + + Schema::table('users', function (Blueprint $table) { + $add = $table->string(...); + }); + PHP); + + $this->assertSame([], $this->columns($aggregator, 'users')); + $this->assertSame(['title' => ['string', false]], $this->columns($aggregator, 'posts')); + + foreach (['$create = Schema::create(...);', '$run = DB::statement(...);'] as $statement) { + $aggregator = $this->aggregate(<< \$table->string('title')); + + {$statement} + PHP); + + $this->assertNull($this->columns($aggregator, 'posts'), $statement); + $this->assertFalse($this->schema($aggregator)->complete, $statement); + } + } + + /** + * Only MySQL reads the sign, so an unknown sign only makes its column's type unknown there. + */ + #[Test] + public function itForgetsTheTypeOfAColumnWithAnUnknownSign(): void + { + foreach (['mysql' => [null, false], 'pgsql' => ['int', false]] as $connection => $expected) { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', function (Blueprint $table) { + $table->string('name'); + $table->integer('votes')->unsigned($this->unsigned); + }); + PHP, $connection, members: 'public bool $unsigned = true;'); + + $this->assertSame(['name' => ['string', false], 'votes' => $expected], $this->columns($aggregator, 'users', $connection), $connection); + } + } + + /** + * Like raw CREATE EXTENSION, an extension can create relations the analysis does not know. + */ + #[Test] + public function itForgetsTheConnectionAnExtensionIsEnsuredOn(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', fn (Blueprint $table) => $table->string('name')); + + Schema::ensureExtensionExists('vector'); + PHP, 'pgsql'); + + $this->assertNull($this->columns($aggregator, 'users', 'pgsql')); + $this->assertFalse($this->schema($aggregator, 'pgsql')->complete); + } + + #[Test] + public function itAppliesStatementsToTheConnectionTheyRunOn(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('users', fn (Blueprint $table) => $table->string('name')); + + Schema::connection('sqlite')->create('posts', fn (Blueprint $table) => $table->string('title')); + PHP, 'pgsql'); + + $this->assertSame(['name' => ['string', false]], $this->columns($aggregator, 'users', 'pgsql')); + $this->assertNull($this->columns($aggregator, 'posts', 'pgsql')); + $this->assertSame(['title' => ['string', false]], $this->columns($aggregator, 'posts', 'sqlite')); + } + + /** + * A migration that chooses its connection in a way the analysis cannot read could change any connection. + * + * @param array $config + */ + #[Test] + #[DataProvider('connectionMethods')] + public function itEvaluatesTheConnectionGetConnectionReturns(string $method, array $config, ?string $expected): void + { + $aggregator = $this->withConfig($config, fn (): SchemaAggregator => $this->aggregate(<<<'PHP' + Schema::create('posts', fn (Blueprint $table) => $table->string('title')); + PHP, null, $this->aggregate("Schema::create('users', fn (Blueprint \$table) => \$table->string('name'));"), $method)); + + if ($expected === null) { + $this->assertNull($this->columns($aggregator, 'users')); + $this->assertFalse($this->schema($aggregator)->complete); + } else { + $this->assertSame(['name' => ['string', false]], $this->columns($aggregator, 'users')); + $this->assertSame(['title' => ['string', false]], $this->columns($aggregator, 'posts', $expected === 'default' ? config('database.default') : $expected)); + } + } + + /** + * @return iterable, ?string}> the getConnection() method, the config + * values and the connection, null when unknown + */ + public static function connectionMethods(): iterable + { + $coalesce = "public function getConnection(): ?string { return \$this->connection ?? config('schema_aggregator.connection'); }"; + $typed = "public function getConnection(): string { return config()->string('schema_aggregator.connection'); }"; + + yield 'property or configured connection' => [$coalesce, ['schema_aggregator.connection' => 'pgsql'], 'pgsql']; + yield 'property or missing config' => [$coalesce, [], 'default']; + yield 'typed configured connection' => [$typed, ['schema_aggregator.connection' => 'pgsql'], 'pgsql']; + yield 'typed missing config' => [$typed, [], null]; + yield 'facade default' => ["public function getConnection(): ?string { return Config::get('schema_aggregator.connection', 'pgsql'); }", [], 'pgsql']; + yield 'null for a string return type' => ["public function getConnection(): string { return config('schema_aggregator.connection'); }", [], null]; + yield 'unreadable' => ['public function getConnection(): ?string { return $this->name(); }', [], null]; + } + + /** + * A class between the migration and Migration can choose the connection in a way the analysis does not read. + */ + #[Test] + public function itReadsOnlyTheConnectionInheritedFromMigration(): void + { + foreach (['Migration' => true, 'ConnectionPropertyMigration' => false, 'ConnectionMethodMigration' => false] as $parent => $known) { + $aggregator = $this->apply(<< \$table->string('title')); + } + }; + PHP, $this->aggregate("Schema::create('users', fn (Blueprint \$table) => \$table->string('name'));", config('database.default'))); + + $this->assertSame($known, $this->schema($aggregator, config('database.default'))->complete, $parent); + $this->assertSame($known ? ['title' => ['string', false]] : null, $this->columns($aggregator, 'posts', config('database.default')), $parent); + } + } + + /** + * Calls on a variable holding a builder are followed when every write selects one connection and + * every other use calls a method on it. + */ + #[Test] + public function itFollowsSchemaBuildersHeldInVariables(): void + { + $aggregator = $this->withConfig(['schema_aggregator.connection' => 'pgsql'], fn (): SchemaAggregator => $this->aggregate(<<<'PHP' + $schema = Schema::connection($this->getConnection()); + + $schema->create('users', fn (Blueprint $table) => $table->string('name')); + + if (! $schema->hasTable('users')) { + $schema->create('skipped', fn (Blueprint $table) => $table->string('name')); + } + + $builder = DB::connection('sqlite')->getSchemaBuilder(); + $builder->create('posts', fn (Blueprint $table) => $table->string('title')); + + DB::connection('sqlite')->table('posts')->update(['title' => 'Draft']); + PHP, members: <<<'PHP' + public function getConnection(): string + { + return config()->string('schema_aggregator.connection'); + } + PHP)); + + $this->assertSame(['name' => ['string', false]], $this->columns($aggregator, 'users', 'pgsql')); + $this->assertNull($this->columns($aggregator, 'skipped', 'pgsql')); + $this->assertTrue($this->schema($aggregator, 'pgsql')->complete); + $this->assertSame(['title' => ['string', false]], $this->columns($aggregator, 'posts', 'sqlite')); + } + + /** + * A builder or connection that escapes can change its connection wherever it is used. + */ + #[Test] + public function itForgetsTheConnectionsEscapedSelectorsReach(): void + { + $create = "Schema::create('accounts', fn (Blueprint \$table) => \$table->string('name'));\n" + . "Schema::connection('pgsql')->create('users', fn (Blueprint \$table) => \$table->string('name'));"; + $helper = 'private function prepare(mixed $schema): void {}'; + + foreach ([ + 'argument' => "\$schema = Schema::connection('pgsql');\n\$this->prepare(\$schema);", + 'two connections' => "\$schema = Schema::connection('pgsql');\n\$schema = Schema::connection('sqlite');", + 'database connection' => "\$db = DB::connection('pgsql');", + 'kept callable' => '$select = Schema::connection(...);', + ] as $case => $statements) { + $aggregator = $this->aggregate("{$create}\n{$statements}", members: $helper); + + $this->assertNull($this->columns($aggregator, 'users', 'pgsql'), $case); + $this->assertSame($case === 'kept callable', ! $this->schema($aggregator)->complete, $case); + } + + // A later use forgets what the connection established since the selector escaped. + foreach (["\$schema->drop('posts');", '$this->prepare($schema);'] as $use) { + $aggregator = $this->aggregate(<<prepare(\$schema); + + Schema::connection('pgsql')->create('posts', fn (Blueprint \$table) => \$table->string('title')); + {$use} + PHP, members: $helper); + + $this->assertNull($this->columns($aggregator, 'posts', 'pgsql'), $use); + } + } + + #[Test] + public function itFollowsTheMorphKeyType(): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::morphUsingUuids(); + Schema::create('images', fn (Blueprint $table) => $table->morphs('imageable')); + + Schema::defaultMorphKeyType($this->type); + Schema::create('videos', fn (Blueprint $table) => $table->morphs('videoable')); + PHP, members: "public string \$type = 'int';"); + + $this->assertSame(['imageable_type' => ['string', false], 'imageable_id' => ['string', false]], $this->columns($aggregator, 'images')); + $this->assertSame(['videoable_type' => ['string', false], 'videoable_id' => [null, false]], $this->columns($aggregator, 'videos')); + } + + /** + * PostgreSQL applies ALTER TABLE without ONLY to the inheritance and partition descendants too. + */ + #[Test] + public function itForgetsTheDescendantsOfAChangedPostgresTable(): void + { + $aggregator = $this->aggregate(<<<'PHP' + DB::unprepared(<<<'SQL' + CREATE TABLE parents (id int); + CREATE TABLE children (age int) INHERITS (parents); + CREATE TABLE others (id int); + CREATE TABLE measurements (taken date) PARTITION BY RANGE (taken); + CREATE TABLE measurements_2026 PARTITION OF measurements FOR VALUES FROM ('2026-01-01') TO ('2027-01-01'); + SQL); + + DB::statement('ALTER TABLE parents ALTER COLUMN id TYPE bigint'); + + Schema::table('measurements', fn (Blueprint $table) => $table->string('unit')); + PHP, 'pgsql'); + + $this->assertSame([], $this->columns($aggregator, 'parents', 'pgsql')); + $this->assertSame([], $this->columns($aggregator, 'children', 'pgsql')); + $this->assertFalse($this->table($aggregator, 'children', 'pgsql')->complete); + $this->assertSame(['id' => ['int', true]], $this->columns($aggregator, 'others', 'pgsql')); + $this->assertSame(['taken' => ['string', true], 'unit' => ['string', false]], $this->columns($aggregator, 'measurements', 'pgsql')); + $this->assertSame([], $this->columns($aggregator, 'measurements_2026', 'pgsql')); + } + + /** + * An altered or dropped type makes only that type uncertain, which stops its search along the path + * and is not bypassed through the base a domain column embeds. A type dropped from a schema off + * the path cannot be one defined without a schema. + */ + #[Test] + public function itMakesOnlyTheUserTypesRawSqlChangesUncertain(): void + { + $aggregator = $this->withSearchPath(<<<'PHP' + DB::unprepared(<<<'SQL' + CREATE TYPE a.status AS ENUM ('old'); + CREATE TYPE b.status AS ENUM ('new'); + CREATE TYPE mood AS ENUM ('happy', 'sad'); + CREATE TYPE feeling AS ENUM ('good'); + CREATE DOMAIN a.code AS integer; + CREATE DOMAIN a.tags AS integer[]; + CREATE TABLE a.people (mood mood NOT NULL, feeling feeling NOT NULL, code code NOT NULL, codes code[] NOT NULL, tags tags NOT NULL); + CREATE TABLE a.others (name text NOT NULL); + SQL); + + DB::statement('DROP TYPE a.status'); + DB::statement('CREATE TABLE b.items (status status NOT NULL)'); + DB::statement("ALTER TYPE a.mood ADD VALUE 'calm'"); + DB::statement('DROP TYPE c.feeling'); + DB::statement('ALTER DOMAIN code SET NOT NULL'); + PHP); + + $this->assertSame(['status' => ['mixed', false]], $this->columns($aggregator, 'b.items', 'pgsql_paths')); + $this->assertSame([ + 'mood' => ['mixed', false], + 'feeling' => ["'good'", false], + 'code' => ['mixed', false], + 'codes' => ['string', false], + 'tags' => ['string', false], + ], $this->columns($aggregator, 'a.people', 'pgsql_paths')); + $this->assertSame(['name' => ['string', false]], $this->columns($aggregator, 'a.others', 'pgsql_paths')); + $this->assertTrue($this->schema($aggregator, 'pgsql_paths')->complete); + } + + /** + * A type that raw SQL with issues defines, or that one outcome defines, may exist in its schema. + */ + #[Test] + public function itStopsTheTypeSearchAtAnUncertainDefinition(): void + { + $aggregator = $this->withSearchPath(<<<'PHP' + DB::unprepared(<<<'SQL' + CREATE TYPE b.status AS ENUM ('new'); + CREATE TYPE b.level AS ENUM ('low'); + SQL); + + DB::unprepared(<<<'SQL' + CREATE TYPE a.status AS ENUM ('draft'); + ALTER TYPE a.status ADD VALUE 'final'; + SQL); + + if ($this->flag) { + DB::statement("CREATE TYPE a.level AS ENUM ('high')"); + } + + DB::statement('CREATE TABLE b.items (status status NOT NULL, level level NOT NULL)'); + PHP, members: 'public bool $flag = true;'); + + $this->assertSame([ + 'status' => ['mixed', false], + 'level' => ['mixed', false], + ], $this->columns($aggregator, 'b.items', 'pgsql_paths')); + } + + /** + * @param array $expected + */ + #[Test] + #[DataProvider('blueprintTypes')] + public function itMapsBlueprintTypesToTheTypesEachGrammarCreates(string $connection, array $expected): void + { + $aggregator = $this->aggregate(<<<'PHP' + Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->boolean('active'); + $table->decimal('price', 8, 2); + $table->json('meta'); + $table->uuid('token'); + $table->enum('status', ['draft', 'published']); + $table->timestamp('created_at')->nullable(); + }); + PHP, $connection); + + $this->assertSame($expected, $this->columns($aggregator, 'orders', $connection)); + } + + /** @return iterable}> */ + public static function blueprintTypes(): iterable + { + yield 'mysql' => ['mysql', [ + 'id' => ['int<0, max>|numeric-string', false], + 'name' => ['string', false], + 'active' => ['int', false], + 'price' => ['numeric-string', false], + 'meta' => ['string', false], + 'token' => ['string', false], + 'status' => ["'draft'|'published'", false], + 'created_at' => ['string', true], + ]]; + yield 'pgsql' => ['pgsql', [ + 'id' => ['int', false], + 'name' => ['string', false], + 'active' => ['bool', false], + 'price' => ['string', false], + 'meta' => ['string', false], + 'token' => ['string', false], + 'status' => ["'draft'|'published'", false], + 'created_at' => ['string', true], + ]]; + yield 'sqlite' => ['sqlite', [ + 'id' => ['int', false], + 'name' => ['string', false], + 'active' => ['int', false], + 'price' => ['float|int', false], + 'meta' => ['string', false], + 'token' => ['string', false], + 'status' => ["'draft'|'published'", false], + 'created_at' => ['string', true], + ]]; + } + + /** + * SQLite fetches a JSON number from a json or jsonb column as a PHP number. + */ + #[Test] + public function itReadsNumbersFromNativeSqliteJsonColumns(): void + { + $sqlite = config('database.connections.sqlite'); + + $aggregator = $this->withConfig( + ['database.connections.native_json' => [...$sqlite, 'use_native_json' => true, 'use_native_jsonb' => true]], + fn (): SchemaAggregator => $this->aggregate(<<<'PHP' + Schema::create('orders', function (Blueprint $table) { + $table->json('meta'); + $table->jsonb('options'); + }); + PHP, 'native_json'), + ); + + $this->assertSame( + ['meta' => ['float|int|string', false], 'options' => ['float|int|string', false]], + $this->columns($aggregator, 'orders', 'native_json'), + ); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } + + /** + * Apply a migration with the given up() body, connection property and optionally other members, to the aggregator. + */ + private function aggregate(string $body, ?string $connection = 'mysql', ?SchemaAggregator $aggregator = null, string $members = ''): SchemaAggregator + { + $property = $connection === null ? 'null' : "'{$connection}'"; + + return $this->apply(<<createReflectionProvider(), + self::getContainer()->getByType(InitializerExprTypeResolver::class), + self::getContainer()->getByType(ConnectionConfigResolver::class), + ); + + /** @var Parser $parser */ + $parser = self::getContainer()->getService('currentPhpVersionSimpleDirectParser'); + + $aggregator->addStatements($parser->parseString(<<withConfig( + ['database.connections.pgsql_paths' => ['driver' => 'pgsql', 'search_path' => 'a,b']], + fn (): SchemaAggregator => $this->aggregate($body, 'pgsql_paths', members: $members), + ); + } + + /** + * Get the files declaring the given classes. + * + * @param class-string ...$classes + * @return list + */ + private function fileNames(string ...$classes): array + { + return array_map(static fn (string $class): string => (string) (new ReflectionClass($class))->getFileName(), $classes); + } + + /** + * Get the facts of the connection a connection name's migrations run on. + */ + private function schema(SchemaAggregator $aggregator, string $connection = 'mysql'): SchemaConnection + { + $schema = $this->findSchema($aggregator->connections, $connection); + + $this->assertNotNull($schema); + + return $schema; + } + + /** + * Get the facts of a table, failing when it is not known. + */ + private function table(SchemaAggregator $aggregator, string $table, string $connection = 'mysql'): SchemaTable + { + $tableFacts = $this->findTable($aggregator->connections, $table, $connection); + + $this->assertNotNull($tableFacts, "The table [{$table}] is not known."); + + return $tableFacts; + } + + /** + * Get a table's columns as their read types (null when unknown) and nullability, or null when the table is not known. + * + * @return null|array + */ + private function columns(SchemaAggregator $aggregator, string $table, string $connection = 'mysql'): ?array + { + return $this->describeColumns($aggregator->connections, $table, $connection); + } +} diff --git a/tests/PHPStan/Unit/SourceFilesTest.php b/tests/PHPStan/Unit/SourceFilesTest.php new file mode 100644 index 000000000..d1cabcdc4 --- /dev/null +++ b/tests/PHPStan/Unit/SourceFilesTest.php @@ -0,0 +1,25 @@ +createReflectionProvider(); + $class = $reflectionProvider->getClass(SelfReferencingConstants::class); + + $this->assertSame([(string) $class->getFileName()], SourceFiles::forConstant($reflectionProvider, $class, 'FIRST')); + } +} diff --git a/tests/PHPStan/Unit/SquashedMigrationHelperTest.php b/tests/PHPStan/Unit/SquashedMigrationHelperTest.php new file mode 100644 index 000000000..c366d9dcf --- /dev/null +++ b/tests/PHPStan/Unit/SquashedMigrationHelperTest.php @@ -0,0 +1,304 @@ + ['int<0, max>|numeric-string', false], + 'name' => ['string', false], + 'active' => ['string', false], + 'description' => ['string', true], + 'created_at' => ['string', true], + 'updated_at' => ['string', true], + ]; + + #[Test] + public function itCanParseSchemaDumpForABasicSchema(): void + { + $connections = $this->createHelper([__DIR__ . '/data/schema/basic_schema'])->initializeConnections(); + + $this->assertSame(['mysql'], array_keys($connections)); + $this->assertCount(1, $connections['mysql']->tables); + $this->assertSame([ + 'id' => ['int<0, max>|numeric-string', false], + 'name' => ['string', false], + 'active' => ['string', false], + 'description' => ['string', true], + 'notes' => ['string', true], + 'profile_text' => ['string', false], + 'balance' => ['float', true], + 'label' => ['string', true], + 'enum_status' => ["'a very long string that exceeds the ConstantStringType DESCRIBE_LIMIT'|'active'|'don\\'t know'|'inactive'", false], + 'created_at' => ['string', true], + 'updated_at' => ['string', true], + ], $this->describeColumns($connections, 'accounts', 'mysql')); + } + + /** + * Unlike upstream, which kept the first file's table, only a connection's .dump file is + * read when it also has a .sql file, as in MigrateCommand::schemaPath(). + */ + #[Test] + public function itReadsTheDumpFileOfAConnectionWithASqlFileToo(): void + { + $connections = $this->createHelper([__DIR__ . '/data/schema/multiple_schemas_for_same_table'])->initializeConnections(); + + $this->assertSame(['mysql'], array_keys($connections)); + $this->assertSame(self::ACCOUNT_COLUMNS, $this->describeColumns($connections, 'accounts', 'mysql')); + } + + /** + * Two dumps of one connection, such as in two schema directories, give no single definition. + */ + #[Test] + public function itForgetsATableThatTwoDumpsOfOneConnectionDefine(): void + { + $connections = $this->createHelper([ + __DIR__ . '/data/schema/basic_schema', + __DIR__ . '/data/schema/multiple_schemas_for_same_table', + ])->initializeConnections(); + + $table = $this->findTable($connections, 'accounts', 'mysql'); + + $this->assertNotNull($table); + $this->assertSame([], $table->columns); + $this->assertFalse($table->complete); + $this->assertTrue($connections['mysql']->complete); + } + + #[Test] + public function itCanFindSchemasWithSqlSuffix(): void + { + $connections = $this->createHelper([__DIR__ . '/data/schema/basic_schema_with_sql_extension'])->initializeConnections(); + + $this->assertSame(['mysql'], array_keys($connections)); + $this->assertSame(self::ACCOUNT_COLUMNS, $this->describeColumns($connections, 'accounts', 'mysql')); + } + + #[Test] + public function itCanFindSchemasWithDifferentExtensions(): void + { + $connections = $this->createHelper([__DIR__ . '/data/schema/multiple_schemas_with_different_extensions'])->initializeConnections(); + + $this->assertSame(['mysql', 'mariadb'], array_keys($connections)); + $this->assertSame(self::ACCOUNT_COLUMNS, $this->describeColumns($connections, 'accounts', 'mysql')); + $this->assertSame(self::ACCOUNT_COLUMNS, $this->describeColumns($connections, 'users', 'mariadb')); + } + + /** + * Unlike upstream, which skipped a dump it could not parse, the dump's connection is incomplete + * and the dump is recorded for diagnosis. + */ + #[Test] + public function itRecordsDumpsThatCannotBeParsed(): void + { + $directory = __DIR__ . '/data/schema/unusable_dumps'; + $connections = $this->createHelper([$directory])->initializeConnections(); + + $this->assertSame([], $connections['mysql']->tables); + $this->assertFalse($connections['mysql']->complete); + $this->assertSame( + [['path' => $directory . '/mysql-schema.sql', 'line' => 3, 'message' => 'Unterminated string literal at SQL line 3']], + $connections['mysql']->unusableDumps, + ); + $this->assertSame([], $connections['pgsql']->tables); + $this->assertFalse($connections['pgsql']->complete); + $this->assertSame( + [['path' => $directory . '/pgsql-schema.dump', 'line' => 1, 'message' => 'PostgreSQL archives must be exported as plain SQL before parsing.']], + $connections['pgsql']->unusableDumps, + ); + } + + /** + * A schema-level issue means represented definitions may be wrong. + */ + #[Test] + public function itDoesNotUseTheTypesOfADumpWithIssues(): void + { + $connections = $this->createHelper([__DIR__ . '/data/schema/pgsql_with_issues'])->initializeConnections(); + + $this->assertFalse($connections['pgsql']->complete); + $this->assertFalse($this->findTable($connections, 'accounts', 'pgsql')?->complete); + $this->assertSame([ + 'id' => ['int', false], + 'status' => ['mixed', false], + ], $this->describeColumns($connections, 'accounts', 'pgsql')); + $this->assertSame([], $connections['pgsql']->unusableDumps); + } + + /** + * An alteration the parser could not apply can leave a removed column behind, so its table is unknown. + */ + #[Test] + public function itForgetsATableWithAnAlterationThatCouldNotBeApplied(): void + { + $connections = $this->createHelper([__DIR__ . '/data/schema/unapplied_alteration'])->initializeConnections(); + + $this->assertSame([], $this->describeColumns($connections, 'users', 'sqlite')); + $this->assertFalse($this->findTable($connections, 'users', 'sqlite')?->complete); + $this->assertSame(['id' => ['int', true], 'title' => ['string', true]], $this->describeColumns($connections, 'posts', 'sqlite')); + } + + #[Test] + public function itResolvesUserTypesAndKeepsViewsUnknown(): void + { + $connections = $this->createHelper([__DIR__ . '/data/schema/pgsql_types_and_views'])->initializeConnections(); + + $this->assertTrue($connections['pgsql']->complete); + $this->assertSame([ + 'id' => ['int', false], + 'status' => ["'active'|'inactive'", false], + ], $this->describeColumns($connections, 'accounts', 'pgsql')); + $this->assertSame([], $this->describeColumns($connections, 'active_accounts', 'pgsql')); + $this->assertFalse($this->findTable($connections, 'active_accounts', 'pgsql')?->complete); + } + + /** + * A type that two dumps of one connection define differently has no single definition, even + * for a column that embeds its domain's base. An identical definition stays known. + */ + #[Test] + public function itMakesTypesTwoDumpsDefineDifferentlyUncertain(): void + { + $connections = $this->createHelper([ + __DIR__ . '/data/schema/pgsql_types_and_views', + __DIR__ . '/data/schema/pgsql_conflicting_types', + ])->initializeConnections(); + + $this->assertSame([ + 'status' => ['mixed', false], + 'level' => ["'high'|'low'", false], + 'code' => ['mixed', false], + ], $this->describeColumns($connections, 'events', 'pgsql')); + } + + #[Test] + #[DataProvider('fixtureConnections')] + public function itReadsTheFixtureApplicationDumpsAsItsMigrationsDefineThem(string $connection): void + { + // The fixture application's dumps are real schema:dump output of its migrations, so the + // Blueprint mapping must give each table the types the driver's grammar created. + $default = config('database.default'); + config(['database.default' => $connection]); + + try { + $migrations = (new MigrationHelper( + self::getContainer()->getService('currentPhpVersionSimpleDirectParser'), + [database_path('migrations')], + self::getContainer()->getByType(FileHelper::class), + false, + self::getContainer()->getByType(ReflectionProvider::class), + self::getContainer()->getByType(InitializerExprTypeResolver::class), + self::getContainer()->getByType(ConnectionConfigResolver::class), + ))->initializeConnections(); + } finally { + config(['database.default' => $default]); + } + + $dumps = $this->createHelper([])->initializeConnections(); + $fromMigrations = []; + $fromDumps = []; + + foreach ($this->findSchema($migrations, $connection)->tables as $table) { + $fromMigrations[$table->name] = $this->describeColumns($migrations, $table->name, $connection); + $fromDumps[$table->name] = $this->describeColumns($dumps, $table->name, $connection); + } + + // rawColumn() reads mixed from a migration, while the dump records the type it created. + $this->assertSame([null, false], $fromMigrations['users']['unknown_column']); + $fromMigrations['users']['unknown_column'] = ['string', false]; + + if (in_array($connection, ['pgsql', 'sqlite'], true)) { + // A Blueprint enum is a CHECK constraint on these drivers; only the migration knows its labels. + $this->assertSame(["'active'|'don\\'t know'|'inactive'", false], $fromMigrations['users']['enum_status']); + $fromMigrations['users']['enum_status'] = ['string', false]; + } + + $this->assertSame($fromMigrations, $fromDumps); + } + + /** @return iterable */ + public static function fixtureConnections(): iterable + { + yield 'mysql' => ['mysql']; + yield 'mariadb' => ['mariadb']; + yield 'pgsql' => ['pgsql']; + yield 'sqlite' => ['sqlite']; + } + + #[Test] + public function itCanDisableSchemaScanning(): void + { + $this->assertSame([], $this->createHelper([__DIR__ . '/data/schema/multiple_schemas_with_different_extensions'], true)->initializeConnections()); + } + + #[Test] + public function itListsTheDefaultSchemaFilesBeforeTheTablesAreInitialized(): void + { + $schemaParser = $this->createHelper([]); + + $this->assertSame([database_path('schema')], $schemaParser->getSchemaDirectories()); + $this->assertNotSame([], $schemaParser->getSchemaFiles()); + } + + #[Test] + public function itKeepsASchemaDirectoryThatDoesNotExist(): void + { + $fileHelper = self::getContainer()->getByType(FileHelper::class); + + $this->assertSame( + [$fileHelper->absolutizePath(__DIR__ . '/data/schema/missing')], + $this->createHelper([__DIR__ . '/data/schema/missing'])->getSchemaDirectories(), + ); + $this->assertSame([], $this->createHelper([__DIR__ . '/data/schema/missing'])->getSchemaFiles()); + } + + #[Test] + public function itHasNoSchemaDirectoriesWhenTheSchemaScanIsDisabled(): void + { + $this->assertSame([], $this->createHelper([__DIR__ . '/data/schema/basic_schema'], true)->getSchemaDirectories()); + } + + /** @param string[] $schemaPaths */ + private function createHelper(array $schemaPaths, bool $disableSchemaScan = false): SquashedMigrationHelper + { + return new SquashedMigrationHelper( + $schemaPaths, + self::getContainer()->getByType(FileHelper::class), + self::getContainer()->getByType(ConnectionConfigResolver::class), + $disableSchemaScan, + ); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Unit/Support/ConfigSourceResolverTest.php b/tests/PHPStan/Unit/Support/ConfigSourceResolverTest.php index 476a74178..66f17a63c 100644 --- a/tests/PHPStan/Unit/Support/ConfigSourceResolverTest.php +++ b/tests/PHPStan/Unit/Support/ConfigSourceResolverTest.php @@ -6,6 +6,7 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Foundation\Bootstrap\LoadConfiguration; use Hypervel\Foundation\Configuration\ConfigMutationTracker; use Hypervel\PHPStan\Support\ConfigSourceResolver; use Hypervel\Support\ServiceProvider; @@ -27,6 +28,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use function dirname; +use function ksort; class ConfigSourceResolverTest extends TestCase { @@ -104,6 +106,33 @@ public function testDynamicMergeKeyKeepsItsPackageConfigFile(): void $this->assertTrue($resolver->isPackageConfigFile(dirname(__DIR__, 2) . '/Fixtures/Config/config/package.php')); } + /** + * A key's value can come from a file for any prefix of it, files below it, the framework's + * file and a package file merged into an overlapping key. + */ + public function testKeySources(): void + { + $resolver = $this->createResolver(MergeCallableProvider::class); + $fileHelper = PHPStanTestCase::getContainer()->getByType(FileHelper::class); + + $expected = [ + '/app/config/partial.php' => false, + '/app/config/partial/option.php' => false, + '/app/config/partial/option' => true, + $fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath() . '/partial.php') => false, + $fileHelper->normalizePath(dirname(__DIR__, 2) . '/Fixtures/Config/config/partial.php') => false, + ]; + $sources = $resolver->getKeySources('partial.option', ['/app/config']); + ksort($expected); + ksort($sources); + + $this->assertSame($expected, $sources); + $this->assertArrayNotHasKey( + $fileHelper->normalizePath(dirname(__DIR__, 2) . '/Fixtures/Config/config/partial.php'), + $resolver->getKeySources('other', ['/app/config']), + ); + } + /** * Create a resolver whose application has loaded only the given provider. * @@ -117,6 +146,7 @@ private function createResolver(string $providerClass): ConfigSourceResolver $app->shouldReceive('getProviders')->with(ServiceProvider::class)->andReturn([$providerClass => $provider]); $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn(new ConfigMutationTracker); $app->shouldReceive('configPath')->andReturn(__DIR__ . '/missing-config'); + $app->shouldReceive('shouldMergeFrameworkConfiguration')->andReturn(true); $container = new Container; $container->instance(Application::class, $app); diff --git a/tests/PHPStan/Unit/data/additional_migrations/2021_02_01_000000_create_teams_table.php b/tests/PHPStan/Unit/data/additional_migrations/2021_02_01_000000_create_teams_table.php new file mode 100644 index 000000000..6a39f1bcb --- /dev/null +++ b/tests/PHPStan/Unit/data/additional_migrations/2021_02_01_000000_create_teams_table.php @@ -0,0 +1,25 @@ +bigIncrements('id'); + $table->string('team')->nullable(); + $table->string('owner_email')->unique(); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/basic_migration/2020_01_30_000000_create_users_table.php b/tests/PHPStan/Unit/data/basic_migration/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..b3b3fab4e --- /dev/null +++ b/tests/PHPStan/Unit/data/basic_migration/2020_01_30_000000_create_users_table.php @@ -0,0 +1,25 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/complex_migrations/2020_01_30_000000_create_users_table.php b/tests/PHPStan/Unit/data/complex_migrations/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..eeac7c129 --- /dev/null +++ b/tests/PHPStan/Unit/data/complex_migrations/2020_01_30_000000_create_users_table.php @@ -0,0 +1,25 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/complex_migrations/2020_01_31_000000_alter_users_table.php b/tests/PHPStan/Unit/data/complex_migrations/2020_01_31_000000_alter_users_table.php new file mode 100644 index 000000000..c6c4a869a --- /dev/null +++ b/tests/PHPStan/Unit/data/complex_migrations/2020_01_31_000000_alter_users_table.php @@ -0,0 +1,53 @@ +createTable(); + + // Stuff + + $this->alterTable(); + } + + private function createTable(): void + { + Schema::create('users', static function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->string('address1')->nullable(); + $table->string('address2')->nullable(); + $table->date('birthday'); + $table->addColumn('string', 'city')->nullable(); + $table->addColumn('integer', 'zip'); + $table->timestamps(); + }); + } + + private function alterTable(): void + { + Schema::table('users', static function (Blueprint $table) { + $table->dropColumn('name'); + $table->dropColumn(['address1', 'address2']); + $table->integer('active'); + }); + } +} diff --git a/tests/PHPStan/Unit/data/conditional_migrations/2020_01_30_000000_create_users_table.php b/tests/PHPStan/Unit/data/conditional_migrations/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..938e6cb72 --- /dev/null +++ b/tests/PHPStan/Unit/data/conditional_migrations/2020_01_30_000000_create_users_table.php @@ -0,0 +1,23 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_address1_to_users_table.php b/tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_address1_to_users_table.php new file mode 100644 index 000000000..7c4982125 --- /dev/null +++ b/tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_address1_to_users_table.php @@ -0,0 +1,26 @@ +string('address1'); + } + }); + } + } +} diff --git a/tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_address2_to_users_table.php b/tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_address2_to_users_table.php new file mode 100644 index 000000000..7a4b179e4 --- /dev/null +++ b/tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_address2_to_users_table.php @@ -0,0 +1,28 @@ +string('address2')->nullable(); + }); + } + } +} diff --git a/tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_email_to_users_table.php b/tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_email_to_users_table.php new file mode 100644 index 000000000..ef9df6fb7 --- /dev/null +++ b/tests/PHPStan/Unit/data/conditional_migrations/2020_01_31_000000_add_email_to_users_table.php @@ -0,0 +1,28 @@ +string('email')->unique(); + } + }); + } +} diff --git a/tests/PHPStan/Unit/data/migration_with_const/2020_01_30_000000_create_users_table.php b/tests/PHPStan/Unit/data/migration_with_const/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..c37701302 --- /dev/null +++ b/tests/PHPStan/Unit/data/migration_with_const/2020_01_30_000000_create_users_table.php @@ -0,0 +1,25 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migration_with_const/Table.php b/tests/PHPStan/Unit/data/migration_with_const/Table.php new file mode 100644 index 000000000..e95b55283 --- /dev/null +++ b/tests/PHPStan/Unit/data/migration_with_const/Table.php @@ -0,0 +1,8 @@ +id(); + $table->ipAddress(); + $table->ipAddress('custom_ip_address'); + $table->macAddress(); + $table->macAddress('custom_mac_address'); + $table->uuid(); + $table->uuid('custom_uuid'); + $table->ulid(); + $table->ulid('custom_ulid'); + $table->softDeletes(); + $table->softDeletes('custom_soft_deletes'); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migration_with_foreign_id_for/2020_01_31_000000_create_articles_table.php b/tests/PHPStan/Unit/data/migration_with_foreign_id_for/2020_01_31_000000_create_articles_table.php new file mode 100644 index 000000000..ff7707af5 --- /dev/null +++ b/tests/PHPStan/Unit/data/migration_with_foreign_id_for/2020_01_31_000000_create_articles_table.php @@ -0,0 +1,27 @@ +string('uuid'); + }); + + Schema::create('articles', static function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('title'); + $table->foreignIdFor(MemberWithCustomKey::class); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migration_with_schema_connection/2020_01_30_000000_create_users_table.php b/tests/PHPStan/Unit/data/migration_with_schema_connection/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..6cade12d9 --- /dev/null +++ b/tests/PHPStan/Unit/data/migration_with_schema_connection/2020_01_30_000000_create_users_table.php @@ -0,0 +1,22 @@ +create('users', static function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migrations_using_after_method/2020_01_30_000000_create_users_table.php b/tests/PHPStan/Unit/data/migrations_using_after_method/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..9535c8f70 --- /dev/null +++ b/tests/PHPStan/Unit/data/migrations_using_after_method/2020_01_30_000000_create_users_table.php @@ -0,0 +1,24 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migrations_using_after_method/2020_01_31_000000_add_to_users_table.php b/tests/PHPStan/Unit/data/migrations_using_after_method/2020_01_31_000000_add_to_users_table.php new file mode 100644 index 000000000..62fe33578 --- /dev/null +++ b/tests/PHPStan/Unit/data/migrations_using_after_method/2020_01_31_000000_add_to_users_table.php @@ -0,0 +1,24 @@ +after('name', function (Blueprint $table) { + $table->string('email')->unique(); + }); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migrations_using_drop_column/2023_08_07_000000_create_add_drop_to_users_table.php b/tests/PHPStan/Unit/data/migrations_using_drop_column/2023_08_07_000000_create_add_drop_to_users_table.php new file mode 100644 index 000000000..2d31d13eb --- /dev/null +++ b/tests/PHPStan/Unit/data/migrations_using_drop_column/2023_08_07_000000_create_add_drop_to_users_table.php @@ -0,0 +1,31 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->string('country')->nullable(); + $table->string('city')->nullable(); + $table->timestamps(); + }); + + Schema::table('users', static function (Blueprint $table) { + $table->dropColumn(['country', 'city']); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migrations_using_drop_remember_token/2020_01_30_000000_create_users_table.php b/tests/PHPStan/Unit/data/migrations_using_drop_remember_token/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..c3d8c5f7f --- /dev/null +++ b/tests/PHPStan/Unit/data/migrations_using_drop_remember_token/2020_01_30_000000_create_users_table.php @@ -0,0 +1,29 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->rememberToken(); + }); + + Schema::table('users', static function (Blueprint $table) { + $table->dropRememberToken(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migrations_using_drop_timestamps/2020_01_30_000000_create_users_table.php b/tests/PHPStan/Unit/data/migrations_using_drop_timestamps/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..d5ffa1d8a --- /dev/null +++ b/tests/PHPStan/Unit/data/migrations_using_drop_timestamps/2020_01_30_000000_create_users_table.php @@ -0,0 +1,29 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + + Schema::table('users', static function (Blueprint $table) { + $table->dropTimestamps(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migrations_using_nullable/2020_01_30_000000_create_users_table.php b/tests/PHPStan/Unit/data/migrations_using_nullable/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..222030e72 --- /dev/null +++ b/tests/PHPStan/Unit/data/migrations_using_nullable/2020_01_30_000000_create_users_table.php @@ -0,0 +1,26 @@ +bigIncrements('id'); + $table->string('name')->nullable(false); + $table->string('email')->unique()->nullable(); + $table->string('address1')->nullable(true); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migrations_using_nullable_timestamps_tz/2020_01_30_000000_create_users_table.php b/tests/PHPStan/Unit/data/migrations_using_nullable_timestamps_tz/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..c359a3a58 --- /dev/null +++ b/tests/PHPStan/Unit/data/migrations_using_nullable_timestamps_tz/2020_01_30_000000_create_users_table.php @@ -0,0 +1,25 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->nullableTimestampsTz(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migrations_using_soft_deletes/2020_01_30_000000_create_users_table_with_soft_deletes.php b/tests/PHPStan/Unit/data/migrations_using_soft_deletes/2020_01_30_000000_create_users_table_with_soft_deletes.php new file mode 100644 index 000000000..e1304aff4 --- /dev/null +++ b/tests/PHPStan/Unit/data/migrations_using_soft_deletes/2020_01_30_000000_create_users_table_with_soft_deletes.php @@ -0,0 +1,26 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + $table->softDeletes(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migrations_using_soft_deletes_tz/2020_01_31_000000_alter_users_table_to_use_soft_deletes_tz.php b/tests/PHPStan/Unit/data/migrations_using_soft_deletes_tz/2020_01_31_000000_alter_users_table_to_use_soft_deletes_tz.php new file mode 100644 index 000000000..9b57dd365 --- /dev/null +++ b/tests/PHPStan/Unit/data/migrations_using_soft_deletes_tz/2020_01_31_000000_alter_users_table_to_use_soft_deletes_tz.php @@ -0,0 +1,26 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + $table->softDeletesTz(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/migrations_with_different_methods/2020_01_30_000000_create_users_table.php b/tests/PHPStan/Unit/data/migrations_with_different_methods/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..665a552e5 --- /dev/null +++ b/tests/PHPStan/Unit/data/migrations_with_different_methods/2020_01_30_000000_create_users_table.php @@ -0,0 +1,35 @@ +createUsersTable(); + } + + public function down(): void + { + Schema::drop('users'); + } + + private function createUsersTable(): void + { + Schema::create('users', static function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/Unit/data/model-property-schema-dependency.php b/tests/PHPStan/Unit/data/model-property-schema-dependency.php new file mode 100644 index 000000000..4f2bf3a5a --- /dev/null +++ b/tests/PHPStan/Unit/data/model-property-schema-dependency.php @@ -0,0 +1,10 @@ +orderBy('email'); +} diff --git a/tests/PHPStan/Unit/data/model-schema-dependency.php b/tests/PHPStan/Unit/data/model-schema-dependency.php new file mode 100644 index 000000000..582e7f745 --- /dev/null +++ b/tests/PHPStan/Unit/data/model-schema-dependency.php @@ -0,0 +1,10 @@ +email; +} diff --git a/tests/PHPStan/Unit/data/no-model-schema-dependency.php b/tests/PHPStan/Unit/data/no-model-schema-dependency.php new file mode 100644 index 000000000..27ef763a7 --- /dev/null +++ b/tests/PHPStan/Unit/data/no-model-schema-dependency.php @@ -0,0 +1,8 @@ +bigIncrements('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + + Schema::table('users', function (Blueprint $table) { + $table->renameColumn('name', 'full_name'); + $table->rename('accounts'); + }); + } +} diff --git a/tests/PHPStan/Unit/data/schema/basic_schema/mysql-schema.dump b/tests/PHPStan/Unit/data/schema/basic_schema/mysql-schema.dump new file mode 100644 index 000000000..ae1d51bcc --- /dev/null +++ b/tests/PHPStan/Unit/data/schema/basic_schema/mysql-schema.dump @@ -0,0 +1,24 @@ +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +DROP TABLE IF EXISTS `accounts`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `accounts` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `active` varchar(1) COLLATE utf8mb4_unicode_ci NOT NULL, + `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `notes` text COLLATE utf8mb4_unicode_ci, + `profile_text` text COLLATE utf8mb4_unicode_ci NOT NULL, + `balance` float DEFAULT 0, + `label` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 'x', + `enum_status` enum('active','inactive','don''t know','a very long string that exceeds the ConstantStringType DESCRIBE_LIMIT') COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; diff --git a/tests/PHPStan/Unit/data/schema/basic_schema_with_sql_extension/mysql-schema.sql b/tests/PHPStan/Unit/data/schema/basic_schema_with_sql_extension/mysql-schema.sql new file mode 100644 index 000000000..499d5be82 --- /dev/null +++ b/tests/PHPStan/Unit/data/schema/basic_schema_with_sql_extension/mysql-schema.sql @@ -0,0 +1,19 @@ +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +DROP TABLE IF EXISTS `accounts`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `accounts` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `active` varchar(1) COLLATE utf8mb4_unicode_ci NOT NULL, + `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; diff --git a/tests/PHPStan/Unit/data/schema/multiple_schemas_for_same_table/mysql-schema.dump b/tests/PHPStan/Unit/data/schema/multiple_schemas_for_same_table/mysql-schema.dump new file mode 100644 index 000000000..499d5be82 --- /dev/null +++ b/tests/PHPStan/Unit/data/schema/multiple_schemas_for_same_table/mysql-schema.dump @@ -0,0 +1,19 @@ +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +DROP TABLE IF EXISTS `accounts`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `accounts` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `active` varchar(1) COLLATE utf8mb4_unicode_ci NOT NULL, + `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; diff --git a/tests/PHPStan/Unit/data/schema/multiple_schemas_for_same_table/mysql-schema.sql b/tests/PHPStan/Unit/data/schema/multiple_schemas_for_same_table/mysql-schema.sql new file mode 100644 index 000000000..94b0903c8 --- /dev/null +++ b/tests/PHPStan/Unit/data/schema/multiple_schemas_for_same_table/mysql-schema.sql @@ -0,0 +1,14 @@ +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +DROP TABLE IF EXISTS `accounts`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `accounts` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; diff --git a/tests/PHPStan/Unit/data/schema/multiple_schemas_with_different_extensions/mariadb-schema.sql b/tests/PHPStan/Unit/data/schema/multiple_schemas_with_different_extensions/mariadb-schema.sql new file mode 100644 index 000000000..fae8f8c4a --- /dev/null +++ b/tests/PHPStan/Unit/data/schema/multiple_schemas_with_different_extensions/mariadb-schema.sql @@ -0,0 +1,19 @@ +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `users` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `active` varchar(1) COLLATE utf8mb4_unicode_ci NOT NULL, + `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; diff --git a/tests/PHPStan/Unit/data/schema/multiple_schemas_with_different_extensions/mysql-schema.dump b/tests/PHPStan/Unit/data/schema/multiple_schemas_with_different_extensions/mysql-schema.dump new file mode 100644 index 000000000..499d5be82 --- /dev/null +++ b/tests/PHPStan/Unit/data/schema/multiple_schemas_with_different_extensions/mysql-schema.dump @@ -0,0 +1,19 @@ +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +DROP TABLE IF EXISTS `accounts`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `accounts` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `active` varchar(1) COLLATE utf8mb4_unicode_ci NOT NULL, + `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; diff --git a/tests/PHPStan/Unit/data/schema/pgsql_conflicting_types/pgsql-schema.sql b/tests/PHPStan/Unit/data/schema/pgsql_conflicting_types/pgsql-schema.sql new file mode 100644 index 000000000..90cc5b308 --- /dev/null +++ b/tests/PHPStan/Unit/data/schema/pgsql_conflicting_types/pgsql-schema.sql @@ -0,0 +1,18 @@ +CREATE TYPE public.status AS ENUM ( + 'active', + 'inactive', + 'archived' +); + +CREATE TYPE public.level AS ENUM ( + 'low', + 'high' +); + +CREATE DOMAIN public.code AS integer; + +CREATE TABLE public.events ( + status public.status NOT NULL, + level public.level NOT NULL, + code public.code NOT NULL +); diff --git a/tests/PHPStan/Unit/data/schema/pgsql_types_and_views/pgsql-schema.sql b/tests/PHPStan/Unit/data/schema/pgsql_types_and_views/pgsql-schema.sql new file mode 100644 index 000000000..96cbae573 --- /dev/null +++ b/tests/PHPStan/Unit/data/schema/pgsql_types_and_views/pgsql-schema.sql @@ -0,0 +1,21 @@ +CREATE TYPE public.status AS ENUM ( + 'active', + 'inactive' +); + +CREATE TYPE public.level AS ENUM ( + 'low', + 'high' +); + +CREATE DOMAIN public.code AS text; + +CREATE TABLE public.accounts ( + id bigint NOT NULL, + status public.status NOT NULL +); + +CREATE VIEW public.active_accounts AS + SELECT accounts.id + FROM public.accounts + WHERE (accounts.status = 'active'::public.status); diff --git a/tests/PHPStan/Unit/data/schema/pgsql_with_issues/pgsql-schema.sql b/tests/PHPStan/Unit/data/schema/pgsql_with_issues/pgsql-schema.sql new file mode 100644 index 000000000..bec333c96 --- /dev/null +++ b/tests/PHPStan/Unit/data/schema/pgsql_with_issues/pgsql-schema.sql @@ -0,0 +1,11 @@ +CREATE TYPE public.status AS ENUM ( + 'active', + 'inactive' +); + +CREATE TABLE public.accounts ( + id bigint NOT NULL, + status public.status NOT NULL +); + +ALTER DOMAIN public.missing SET NOT NULL; diff --git a/tests/PHPStan/Unit/data/schema/unapplied_alteration/sqlite-schema.sql b/tests/PHPStan/Unit/data/schema/unapplied_alteration/sqlite-schema.sql new file mode 100644 index 000000000..1cf299512 --- /dev/null +++ b/tests/PHPStan/Unit/data/schema/unapplied_alteration/sqlite-schema.sql @@ -0,0 +1,3 @@ +CREATE TABLE users (id INTEGER, name TEXT); +CREATE TABLE posts (id INTEGER, title TEXT); +ALTER TABLE users DROP COLUMN name; diff --git a/tests/PHPStan/Unit/data/schema/unusable_dumps/mysql-schema.sql b/tests/PHPStan/Unit/data/schema/unusable_dumps/mysql-schema.sql new file mode 100644 index 000000000..f8a9a2795 --- /dev/null +++ b/tests/PHPStan/Unit/data/schema/unusable_dumps/mysql-schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE `accounts` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `label` varchar(255) DEFAULT 'unterminated, + PRIMARY KEY (`id`) +); diff --git a/tests/PHPStan/Unit/data/schema/unusable_dumps/pgsql-schema.dump b/tests/PHPStan/Unit/data/schema/unusable_dumps/pgsql-schema.dump new file mode 100644 index 0000000000000000000000000000000000000000..5c5c302e1879d6fc052f05632df8188a61cfb0ad GIT binary patch literal 14 VcmWG=ckvBi */ +class FormattedDate implements CastsAttributes +{ + /** @param array $attributes */ + public function get(Model $model, string $key, mixed $value, array $attributes): array + { + return [ + 'raw' => is_string($value) ? $value : null, + 'formatted' => is_string($value) ? date('jS M Y G:i', (int) strtotime($value)) : null, + ]; + } + + /** @param array $attributes */ + public function set(Model $model, string $key, mixed $value, array $attributes): string|null + { + return is_string($value) ? $value : null; + } +} diff --git a/tests/PHPStan/application/app/ChildTeamBuilder.php b/tests/PHPStan/application/app/ChildTeamBuilder.php new file mode 100644 index 000000000..35c13cc5a --- /dev/null +++ b/tests/PHPStan/application/app/ChildTeamBuilder.php @@ -0,0 +1,9 @@ + $attributes + */ + public function __construct(array $attributes = []) + { + $attributes['name'] = $attributes['name'] ?? 'foo'; + + parent::__construct($attributes); + } +} diff --git a/tests/PHPStan/application/app/Member.php b/tests/PHPStan/application/app/Member.php new file mode 100644 index 000000000..83e389a20 --- /dev/null +++ b/tests/PHPStan/application/app/Member.php @@ -0,0 +1,11 @@ + $column */ + public function orderBy(string $column): void + { + } +} diff --git a/tests/PHPStan/application/app/ModelWithDateColumnCasts.php b/tests/PHPStan/application/app/ModelWithDateColumnCasts.php new file mode 100644 index 000000000..7a9303d18 --- /dev/null +++ b/tests/PHPStan/application/app/ModelWithDateColumnCasts.php @@ -0,0 +1,22 @@ + FormattedDate::class, + 'updated_at' => 'timestamp', + 'deleted_at' => 'immutable_datetime', + ]; +} diff --git a/tests/PHPStan/application/app/RoleUser.php b/tests/PHPStan/application/app/RoleUser.php new file mode 100644 index 000000000..a3982f940 --- /dev/null +++ b/tests/PHPStan/application/app/RoleUser.php @@ -0,0 +1,27 @@ + + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } +} diff --git a/tests/PHPStan/application/app/Team.php b/tests/PHPStan/application/app/Team.php new file mode 100644 index 000000000..1d079000f --- /dev/null +++ b/tests/PHPStan/application/app/Team.php @@ -0,0 +1,37 @@ + */ +class TeamBuilder extends Builder +{ +} diff --git a/tests/PHPStan/application/app/Thread.php b/tests/PHPStan/application/app/Thread.php new file mode 100644 index 000000000..95a3e34ab --- /dev/null +++ b/tests/PHPStan/application/app/Thread.php @@ -0,0 +1,61 @@ +where('name', true); + } + + // Unlike upstream, which returns a nonexistent Tests\Features\Methods\Thread. + public static function testFirstOnStaticSelf(): ?Thread + { + return self::query()->where('name', 'bar')->first(); + } + + public function getCustomPropertyAttribute(): string + { + return 'thread'; + } + + /** @phpstan-return mixed[] */ + public static function asSelect(): array + { + return self::all()->pluck('name', 'id')->toArray(); + } + + /** + * @phpstan-return Collection + */ + public static function methodReturningCollectionOfAnotherModel(): Collection + { + return new Collection([]); + } + + /** + * @phpstan-return Collection|Thread + */ + public static function methodReturningUnionWithCollection() + { + return new Collection([]); + } + + /** + * @phpstan-return Collection|User + */ + public static function methodReturningUnionWithCollectionOfAnotherModel() + { + return new Collection([]); + } +} diff --git a/tests/PHPStan/application/app/UlidModel.php b/tests/PHPStan/application/app/UlidModel.php new file mode 100644 index 000000000..782280532 --- /dev/null +++ b/tests/PHPStan/application/app/UlidModel.php @@ -0,0 +1,13 @@ +bigIncrements('id'); + + // Testing property casts + $table->integer('int'); + $table->integer('integer'); + $table->float('real'); + $table->float('float'); + $table->double('double'); + $table->decimal('decimal'); + $table->string('string'); + $table->boolean('bool'); + $table->boolean('boolean'); + $table->json('object'); + $table->json('array'); + $table->json('json'); + $table->json('collection'); + $table->json('nullable_collection')->nullable(); + $table->date('date'); + $table->dateTime('datetime'); + $table->date('immutable_date'); + $table->dateTime('immutable_datetime'); + $table->timestamp('timestamp'); + + $table->timestamps(); + $table->softDeletes(); + }); + } +} diff --git a/tests/PHPStan/application/database/migrations/2020_01_30_000000_create_users_table.php b/tests/PHPStan/application/database/migrations/2020_01_30_000000_create_users_table.php new file mode 100644 index 000000000..d73953980 --- /dev/null +++ b/tests/PHPStan/application/database/migrations/2020_01_30_000000_create_users_table.php @@ -0,0 +1,70 @@ +bigIncrements('id'); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->string('stringButInt'); + $table->float('floatButRoundedDecimalString'); + $table->json('allowed_ips'); + $table->json('meta'); + $table->json('options'); + $table->json('properties'); + $table->json('enum_collection'); + $table->json('enum_array_object'); + $table->json('favorites'); + $table->string('secret'); + $table->boolean('blocked'); + // Unlike upstream's unregistered macro, which would make the whole table unknown, a raw column keeps only its own type unknown. + $table->rawColumn('unknown_column', 'text'); + $table->rememberToken(); + $table->enum('enum_status', ['active', 'inactive', 'don\'t know']); + $table->string('basic_enum'); + $table->string('backed_enum'); + $table->string('castable_with_argument'); + + // Testing property casts + $table->integer('int'); + $table->integer('integer'); + $table->unsignedInteger('unsigned_integer'); + $table->integer('unsigned_integer_method')->unsigned(); + $table->float('real'); + $table->float('float'); + $table->double('double'); + $table->decimal('decimal'); + $table->decimal('nullable_decimal')->nullable(); + $table->string('string'); + $table->boolean('bool'); + $table->boolean('boolean'); + $table->json('object'); + $table->json('array'); + $table->json('json'); + $table->json('collection'); + $table->json('nullable_collection')->nullable(); + $table->date('date'); + $table->dateTime('datetime'); + $table->date('immutable_date'); + $table->dateTime('immutable_datetime'); + $table->timestamp('timestamp'); + + $table->timestamps(); + $table->softDeletes(); + }); + } +} diff --git a/tests/PHPStan/application/database/migrations/2020_03_20_000000_create_accounts_table.php b/tests/PHPStan/application/database/migrations/2020_03_20_000000_create_accounts_table.php new file mode 100644 index 000000000..c59ff254d --- /dev/null +++ b/tests/PHPStan/application/database/migrations/2020_03_20_000000_create_accounts_table.php @@ -0,0 +1,24 @@ +bigIncrements('id'); + $table->string('name'); + $table->string('active', 1); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/application/database/migrations/2020_03_25_000000_create_roles_table.php b/tests/PHPStan/application/database/migrations/2020_03_25_000000_create_roles_table.php new file mode 100644 index 000000000..f67a6c1a7 --- /dev/null +++ b/tests/PHPStan/application/database/migrations/2020_03_25_000000_create_roles_table.php @@ -0,0 +1,24 @@ +uuid('id'); + $table->string('name'); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/application/database/migrations/2020_09_25_190200_create_guarded_models_table.php b/tests/PHPStan/application/database/migrations/2020_09_25_190200_create_guarded_models_table.php new file mode 100644 index 000000000..3ff4eb9bb --- /dev/null +++ b/tests/PHPStan/application/database/migrations/2020_09_25_190200_create_guarded_models_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->string('text'); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/application/database/migrations/2020_10_04_000000_create_posts_table.php b/tests/PHPStan/application/database/migrations/2020_10_04_000000_create_posts_table.php new file mode 100644 index 000000000..cda3c1d00 --- /dev/null +++ b/tests/PHPStan/application/database/migrations/2020_10_04_000000_create_posts_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('posts_users', static function (Blueprint $table) { + $table->string('user_id'); + $table->string('post_id'); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/application/database/migrations/2021_09_10_000000_create_addresses_table.php b/tests/PHPStan/application/database/migrations/2021_09_10_000000_create_addresses_table.php new file mode 100644 index 000000000..e38dffed1 --- /dev/null +++ b/tests/PHPStan/application/database/migrations/2021_09_10_000000_create_addresses_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->uuid(); + $table->foreignIdFor(User::class); + $table->foreignIdFor(User::class, 'custom_foreign_id_for_name'); + $table->foreignIdFor(Address::class); + $table->foreignIdFor(Address::class, 'nullable_address_id')->nullable(); + $table->foreignId('foreign_id_constrained')->constrained('users'); + $table->foreignId('nullable_foreign_id_constrained')->nullable()->constrained('users'); + // Unlike upstream, these are not constrained to addresses.id, an integer key that databases reject as their reference. + $table->foreignUlid('foreign_ulid'); + $table->foreignUlid('nullable_foreign_ulid')->nullable(); + $table->foreignUuid('foreign_uuid'); + $table->foreignUuid('nullable_foreign_uuid')->nullable(); + }); + } +} diff --git a/tests/PHPStan/application/database/migrations/2023_06_12_000000_create_role_user_table.php b/tests/PHPStan/application/database/migrations/2023_06_12_000000_create_role_user_table.php new file mode 100644 index 000000000..c665d1c0a --- /dev/null +++ b/tests/PHPStan/application/database/migrations/2023_06_12_000000_create_role_user_table.php @@ -0,0 +1,25 @@ +foreignIdFor(Role::class); + $table->foreignIdFor(User::class); + }); + } +} diff --git a/tests/PHPStan/application/database/migrations/2025_02_12_000000_create_ulid_models_table.php b/tests/PHPStan/application/database/migrations/2025_02_12_000000_create_ulid_models_table.php new file mode 100644 index 000000000..20f4000a9 --- /dev/null +++ b/tests/PHPStan/application/database/migrations/2025_02_12_000000_create_ulid_models_table.php @@ -0,0 +1,23 @@ +ulid('id'); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/application/database/migrations/2025_02_12_000000_create_uuid_models_table.php b/tests/PHPStan/application/database/migrations/2025_02_12_000000_create_uuid_models_table.php new file mode 100644 index 000000000..8b6d31e26 --- /dev/null +++ b/tests/PHPStan/application/database/migrations/2025_02_12_000000_create_uuid_models_table.php @@ -0,0 +1,23 @@ +uuid('id'); + $table->timestamps(); + }); + } +} diff --git a/tests/PHPStan/application/database/migrations/Schema.php b/tests/PHPStan/application/database/migrations/Schema.php new file mode 100644 index 000000000..97886c52e --- /dev/null +++ b/tests/PHPStan/application/database/migrations/Schema.php @@ -0,0 +1,9 @@ +addPsr4('App\\', __DIR__ . '/application/app/'); $loader->addPsr4('Database\Factories\\', __DIR__ . '/application/database/factories/'); +$loader->addPsr4('Database\Migrations\\', __DIR__ . '/application/database/migrations/'); // The analyzed application is this process's disposable Testbench runtime copy, so // its resources become the fixture application's before it boots. @@ -28,7 +29,7 @@ $filesystem = new Filesystem; -foreach (['app/Console/Commands', 'lang', 'resources/views'] as $directory) { +foreach (['app/Console/Commands', 'database/migrations', 'database/schema', 'lang', 'resources/views'] as $directory) { $filesystem->deleteDirectory(BASE_PATH . '/' . $directory); $filesystem->copyDirectory(__DIR__ . '/application/' . $directory, BASE_PATH . '/' . $directory); } From d238158a6b28a7af4527aac5ce570cd2a38615ce Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:21:33 +0000 Subject: [PATCH 108/303] Record the reviewed model schema checkpoint and remaining port work Update the implementation design and remaining-file checklist for the completed schema, casts, accessors and forwarding dependency flow. Retain the required shared boot-input tracking, final cache reuse checks and efficiency review. Record the owner's approved immediate follow-up: remove unnecessary final declarations only after this checkpoint is committed, then complete a separate review loop and commit before continuing the remaining package implementation. --- .../2026-11-04-hypervel-phpstan-checklist.md | 21 +++-- docs/plans/2026-11-04-hypervel-phpstan.md | 92 +++++++++++++++---- 2 files changed, 85 insertions(+), 28 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index 02ea03d5e..e060c89f6 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -8,7 +8,7 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## Package root -- `extension.neon` services: restore upstream's `AbortIfFunctionTypeSpecifyingExtension` (abort/throw), `AppEnvironmentReturnTypeExtension`, `CheckDispatchArgumentTypesCompatibleWithClassConstructorRule` (bus/events `Dispatchable`) and `migrationsParser` entries with their classes; omit the container ArrayAccess entries +- `extension.neon` services: restore upstream's `CheckDispatchArgumentTypesCompatibleWithClassConstructorRule` (bus/events `Dispatchable`) entries with their class; omit the container ArrayAccess entries - `extension.neon` and Components integration: set `reportIgnoresWithoutComments: true`; add `phpstan-deprecation-rules` includes to `phpstan.neon.dist`/`phpstan.types.neon.dist` - omit: `phpcs.xml.dist`, `phpstan.neon.dist`, `phpstan-baseline.neon`, `phpunit.xml.dist`, `phpunit-bootstrap.php`, `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, `RELEASE.md`, `UPGRADE.md`, `assets/` (repository tooling; Components owns its own) - merge into `src/docs/static-analysis.md`: `docs/custom-config-parameters.md`, `docs/custom-types.md`, `docs/errors-to-ignore.md`, `docs/features.md`, `docs/rules.md` @@ -25,15 +25,15 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - omit: `Internal/ComposerHelper.php` (`Support\Composer::vendorPath()` resolves the actual install) - copy: `Methods/{BuilderHelper,ModelFactoryMethodsClassReflectionExtension}.php` - merge into moved forwarding extensions: `Methods/{EloquentBuilderForwardsCallsExtension,ModelForwardsCallsExtension,RelationForwardsCallsExtension}.php` -- copy: `Properties/*` except the ported `HigherOrderCollectionProxyPropertyExtension` (16 files, including `Schema/MySqlDataTypeToPhpTypeConverter.php`) +- omit: `Properties/ReflectionTypeContainer.php` (unused since upstream removed container ArrayAccess support) - copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection}.php` - merge into moved scope reflections: `Reflection/ModelScopeMethodReflection.php` -- copy: `ReturnTypes/{EloquentBuilderExtension,FactoryDynamicMethodReturnTypeExtension,ModelDynamicStaticMethodReturnTypeExtension,ModelFactoryDynamicStaticMethodReturnTypeExtension,ModelOnlyDynamicMethodReturnTypeExtension,ModelSerializationDynamicMethodReturnTypeExtension,RelationCollectionExtension}.php` +- copy: `ReturnTypes/{EloquentBuilderExtension,FactoryDynamicMethodReturnTypeExtension,ModelDynamicStaticMethodReturnTypeExtension,ModelFactoryDynamicStaticMethodReturnTypeExtension,ModelOnlyDynamicMethodReturnTypeExtension,RelationCollectionExtension}.php` - copy with the Eloquent collection port: `ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php`, moving `common/EloquentCollection.stub`'s `map()`/`mapWithKeys()` conditionals into the Eloquent `Collection` source - copy: `Rules/*` except below and the ported `ArrKeyExistenceRule`, auth request-scope, config, console, `NoEnvCallsOutsideOfConfigRule`, `NoMissingTranslationsRule`, `NoUnnecessaryEnumerableToArrayCallsRule`, `UnusedViewsRule` and `UselessConstructs/` rules (including `Queue/`) - omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` - omit: `SQL/*` (`hypervel/schema-parser` replaces the parser boundary and its iamcal/phpMyAdmin adapters) -- copy: `Types/*` except the ported `ArrPullParameterOutTypeExtension` and `ViewString*` files (12 files) +- copy: `Types/*` except the ported `AbortIfFunctionTypeSpecifyingExtension`, `ArrPullParameterOutTypeExtension`, `ModelProperty/` and `ViewString*` files ## New Hypervel source @@ -56,15 +56,16 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## tests - omit: `laravel-test.sh` -- copy: the remaining `application/` fixture app (app, config, factories, migrations, schema dump) +- copy with the Eloquent builder/relation ports: `application/app/{BareRelations/,ChildUser.php,PostComment.php}`; with the Eloquent collection port: `application/app/UserCollection.php`; with the factory flow: `application/app/{Domain/,Shapes/}` and `application/database/factories/` +- omit: `application/app/{ModelWithConflictingInitializer.php,UserResource.php,Providers/}` (no upstream test or source references them) - merge into `Integration/IntegrationTest.php`: the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties - copy: `Rules/*Test.php` except below and the ported `ArrKeyExistence`, auth, config, console, environment, enumerable `toArray()`, view, translation and useless-construct rule tests, the remaining `Rules/data/*`, `Rules/Queue/data/*` - omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` -- copy: `Type/*Test.php` except below, the ported collection and config tests, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php`, with the remaining `Type/data/*` -- merge into `Type/BleedingEdgeTypeTest.php`: `bleeding-edge/collection-usage-inference.php` with the model-properties port (its `push($user->name)` case needs migration properties), recording its `contains()`/`partition()`/`some()`/`reject()` value-widening cases as `REMOVED:` like `collection-keys.php`'s mutation cases; `bleeding-edge/closure-usage-inference.php` (relation callbacks) and `bleeding-edge/eloquent-collection-map.php` with the Eloquent ports -- merge into `Type/CollectionDynamicReturnTypeExtensionsTest.php` with the Eloquent collection port, switching it to `config-with-migrations.neon` (they need model properties and the custom collections): `collection-generic-static-methods{,-l12}.php`, `eloquent-collection-map.php` +- copy: `Type/*Test.php` except below, the ported collection, config and model-property tests, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php`, with the remaining `Type/data/*` +- merge into `Type/BleedingEdgeTypeTest.php` with the Eloquent ports: `bleeding-edge/closure-usage-inference.php` (relation callbacks) and `bleeding-edge/eloquent-collection-map.php` +- merge into `Type/CollectionDynamicReturnTypeExtensionsTest.php` with the Eloquent collection port (they need model properties and the custom collections; its `phpstan-tests.neon` already matches upstream's `config-with-migrations.neon`): `collection-generic-static-methods{,-l12}.php`, `eloquent-collection-map.php` - merge into `Type/CollectionDynamicReturnTypeExtensionTest.php` with the model fixtures: `collection-filter.php`, `collection-reject.php`, `collection-where.php` (they need `accounts()->active()->get()` to return `AccountCollection`) - omit: `Type/data/collection-generic-static-methods-l11.php` (Laravel 11's Eloquent `partition()` returned `static`; Hypervel follows the Laravel 12 base collection) -- merge: the remaining `Type/GeneralTypeTest.php` data entries, and its `data/config-with-migrations.neon` configuration once migrations are ported; `higher-order-collection-proxy-methods.php` needs model properties and Hypervel's proxy results (`avg` is `float|int|null`, `max`/`min` include `null`) -- copy: `Unit/*Test.php` (9 classes; upstream's `IamcalSqlParser` becomes `MySqlParser`), `Unit/data/*` +- merge: the remaining `Type/GeneralTypeTest.php` data entries (its `phpstan-tests.neon` already matches upstream's `config-with-migrations.neon`) +- copy: `Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php` with the factory flow - omit: `Unit/SQL/*` (parser factory and phpMyAdmin adapter tests; `hypervel/schema-parser` has its own) diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 46f256a84..4ac9cea7f 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -22,7 +22,9 @@ Remaining work: - Also completed: Components' stale-ignore audit, with its `reportUnmatchedIgnoredErrors: false` override removed. The two private packages' overrides go with their analyzer adoption (see Consumer migration), because their ignores must be audited against this analyzer. - Also completed: the macro and forwarding flow (Macros; facade, storage and manager forwarding; factory-helper bindings), with the `Support\Manager::getCreatorMethod()` runtime fix and its regression tests. - Also completed: the request, environment and storage flow, Redis command casing and the connection-bound Redis rule, with their source fixes and tests (see Requests, environment and storage). -- Next: the model-properties/schema flow (Properties on `hypervel/schema-parser`, `Types/ModelProperty`, `ModelSerializationDynamicMethodReturnTypeExtension`), because the Eloquent stubs (including `Factory.stub`, which drops the source `trashed()` tag) need `model-property`. After it, port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. +- Also completed and reviewed: the model-properties/schema flow, including schema-parser impact, casts/accessors, fetched types, schema and forwarding dependencies, the schema-dump diagnostic, the routing and SQLite JSON fixes, Attribute variance, inherited method-template declarations, tests and documentation. +- Immediate next checkpoint, explicitly approved by the owner: after the model-properties/schema checkpoint is committed, remove unnecessary `final` declarations from the PHPStan package and the branch-added `BrokenRewindDirectoryStream` and `ConditionableTarget` fixtures. Include `ConfigArray`, retaining `readonly`; retain the pre-existing `Comment`/`Comments` type fixtures. Bulk editing is approved for this cleanup. Complete its checks, self-review and peer review loop, then have the orchestrator commit it before any other implementation resumes. +- Then port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -93,7 +95,7 @@ Omit `checkOctaneCompatibility`, `generalizeEnvReturnType` and `redisDriver` wit | `noPipeSeparatedValidationRules` | `true` | `hypervel.pipeSeparatedValidationRules` | | `checkRedisConnectionBoundMethods` | `true` | `hypervel.redisConnectionBoundMethod` | -`ConfigTypeRule` adds `hypervel.configType` under the retained `checkConfigTypes` switch (see Upstream rule disposition). +`ConfigTypeRule` adds `hypervel.configType` under the retained `checkConfigTypes` switch (see Upstream rule disposition). `UnusableSchemaDumpRule` adds `hypervel.schemaDump` with no switch (see Model schemas). The behavior and soundness limits of each new check are specified under Hypervel rules; a public name does not authorize speculative detection. Report one actionable diagnostic for the same underlying write rather than duplicating worker/static/config errors. @@ -263,7 +265,7 @@ Do not copy `ContractsMethodsExtension`, container ArrayAccess/dynamic-property - `Storage::disk()`/`drive()` and `FilesystemManager` receivers are typed per selected disk without building it; the `Factory` contract keeps its declaration and `cloud()` stays omitted. Upstream's fixed `FilesystemAdapter` is wrong for pooled (s3/gcs `ClientPooledFilesystem`, ftp/sftp `FilesystemPoolProxy`) and custom disks. One helper follows the bound manager's stock dispatch through declared returns, never method bodies: the `filesystems.disks` entry normalized by the private `prepareConstructionDescriptor()` (called in `FilesystemManager` scope); a custom creator gives `createDriverPooledDisk()`'s declared return for a poolable driver (the live poolable list), else the overriding `callCustomCreator()`'s or the closure's; poolable s3/gcs give `createClientPooledDisk()`'s, other poolable drivers `createDriverPooledDisk()`'s, otherwise the creator method's (creators carry truthful `@return` PHPDoc). Overridden `get()`/`resolve()`/`getConfig()`, an overridden `createScopedDriver()` for a scoped selection, a normalization exception, an unknown or non-constant name, and a cached or set disk the computed type rejects keep the declaration; an overridden `disk()`/`drive()` keeps its own. Constant names, enum cases and their unions are supported; null and `''` select the default disk. Results are cached per selection. A cached disk, like `ManagersMethodsExtension`'s cached driver, keeps the computed type only when that type definitely accepts the object's exact class (`->yes()`), so a parent-class instance declines. `StorageMethodsClassReflectionExtension` resolves a `Storage` facade call on the manager's native methods first, then on the default disk's type; the manager's declared adapter mixins (upstream's surface) apply only when that type is unknown, including when the bound manager overrides `disk()` or `__call()`. Checking the manager with its mixins first would shadow pooled disks' signatures (their `getDriver()` is `never`). - Redis commands are case-insensitive at runtime (`RedisConnection::__call()` and `RedisProxy::__call()` lowercase the name after the exact-macro check), but PHPStan matches `@method` tags exactly. `RedisCommandMethodsExtension` covers `RedisConnection` and subclasses (and through the `@mixin` chain `RedisProxy`/`RedisManager`) and the `Redis` facade: a memoized lowercase index of `RedisConnection`'s own tags maps another casing to its tag, resolved through the receiver's normal lookup so the nearest declaration wins. It declines exact names, names outside the index (subclass tags and macros are never case-folded) and exact macros in the receiver's own registry (`static::$macros`; the facade uses the shared `RedisConnection` registry). Remove `RedisConnection`'s 22 case-only duplicate tags (identical signatures; keep the transformed-command block's entry) and regenerate the facade. The held connection's `pipeline` tag is `bool|Redis pipeline()`: only `RedisProxy`'s `MultiExec` takes a callback, and phpredis throws `ArgumentCountError` for one. Remove the `client(string $opt = '', ...)` tag: it shadowed the native `client()`, which returns the underlying phpredis client and ignores arguments (`$connection->client()->client('LIST')` runs CLIENT LIST). A connection's re-cased command is returned as a `ProxiedMethodReflection` declared on the receiver: PHPStan looks the called name up again on a method's declaring class (`ObjectType::getUnresolvedMethodPrototype()`), and `RedisConnection`'s macros can differ from a subclass's own registry. The facade keeps its own static tag. -General type improvements go into source, with dedicated type fixtures and no runtime overhead. Confirmed examples include `Attribute` and the conditional `FormRequest::validated()` result. `Enumerable::keyBy()` already declares a new-key template, and `Builder::make()`/`create()` already return `TModel`; do not duplicate these. Analyzer-only `model-property` refinements on Model/Builder (including `array, mixed>` inputs) and `view-string` refinements on mail/view/route methods live in package stubs, not framework source. The Redis facade's tags and the `RedisManager`/`RedisProxy` mixins already type Hypervel's phpredis-only pooled surface: port the applicable phpredis assertions and add stubs only for demonstrated gaps. Omit Predis stubs, Laravel's client-selection branches and the `redisDriver` parameter; there is no supported alternate transport to select. +General type improvements go into source, with dedicated type fixtures and no runtime overhead. Confirmed examples include `Attribute` and the conditional `FormRequest::validated()` result. `Attribute`'s get callback input is a method template (`TValue`) on `__construct()`, `make()` and `get()`, so typed getters such as Laravel's `fn (string $value) => ucfirst($value)` are accepted; `make()`'s `TMakeGet`/`TMakeSet` default to `never`, so `make(get: ...)` resolves. The runtime bodies stay Laravel's. The owner approved declaration variance: `@template-covariant TGet`, `@template-contravariant TSet` and PHPDoc `@readonly` `$get`/`$set` (no native `readonly`), so a declaration may read wider and accept narrower than its callbacks infer (`Attribute` around a `non-falsy-string` getter). Reassigning a callback is reported (`property.readOnlyByPhpDocAssignOutOfClass`) and may be ignored with a reason; model attribute reads and writes are unaffected. PHPStan leaves the constructor's own template unresolved in the inferred class type (`string&TValue`); fixtures do not assert that leak. At max level, source analysis of `set()` reports `return.type` because a missing getter leaves `TGet` unresolved; the repo analyses source at level 5 and callers get the declared type. Class template defaults (`= never`) would remove that report but also stop `missingType.generics` for bare `Attribute` returns, so they are not used. `Enumerable::keyBy()` already declares a new-key template (its bound includes `null`, which both collections key as `''`), and `Builder::make()`/`create()` already return `TModel`; add no stubs for these. PHPStan 2.3.5 does not inherit a parent's method templates into an override whose PHPDoc omits them, so they stay unresolved when the arguments do not infer them. The affected overrides (`Collection::keyBy()`, `LazyCollection::keyBy()`/`mapToDictionary()`/`crossJoin()`/`merge()`/`mergeRecursive()`, `Sentry\Hub::getIntegration()`/`withScope()`) repeat the parent's declaration with an `@TODO` to remove it once PHPStan inherits them. `duplicates()`/`duplicatesStrict()` cannot leak (a callback always infers their template) and the Eloquent collection's overrides declare their own types, so they repeat nothing. Analyzer-only `model-property` refinements on Model/Builder (including `array, mixed>` inputs) and `view-string` refinements on mail/view/route methods live in package stubs, not framework source. The Redis facade's tags and the `RedisManager`/`RedisProxy` mixins already type Hypervel's phpredis-only pooled surface: port the applicable phpredis assertions and add stubs only for demonstrated gaps. Omit Predis stubs, Laravel's client-selection branches and the `redisDriver` parameter; there is no supported alternate transport to select. Preserve upstream's Request and Optional `universalObjectCratesClasses` entries: `Request::__get()` resolves arbitrary input/route keys, and `Optional::__get()` forwards an arbitrary property name or returns null. These are supported dynamic APIs, not undefined-property mistakes. Do not add blanket crates for models or other classes to hide missing inference; use their dedicated reflection extensions and preserve explicit property types. Include the documented `$request->name` and Optional property access in typing coverage. Removing either dynamic API would require owner approval. @@ -302,52 +304,106 @@ Retain `configDirectories`, `viewDirectories`, `translationDirectories`, migrati Port migration aggregation and `MigrationCache`, covering MySQL, MariaDB, PostgreSQL and SQLite without a live database connection. Dumps and literal migration SQL are parsed by `hypervel/schema-parser` ([docs](../../src/docs/schema-parser.md)), with the parser chosen by the connection's driver. Dialect parsing belongs to that package; the analyzer maps its results to PHP types. Real defects this work exposes in it are fixed in that package on this branch, through the normal second-opinion and review workflow. Package schemas and schemas owned by other services can be supplied through the retained path settings. Fixtures are real `schema:dump` output for each driver, generated from the fixture app's migrations. +Default migration discovery follows `migrate`: the booted migrator's `paths()` (providers' `loadMigrationsFrom()`, which may name files), then `database_path('migrations')`, as `BaseCommand::getMigrationPaths()` orders them, read through `Migrator::getMigrationFiles()`: top-level `*_*.php` files, a later path's file replacing an earlier one with the same migration name (a published migration replaces its package's), applied in name order. A non-empty `databaseMigrationsPath` replaces both and keeps upstream's recursive `.php` scan, ordered by file name; `disableMigrationScan` still applies. One resolved list feeds discovery and dependency tracking: named files as file dependencies, directories with their discovery pattern. + +Runtime fix: `DumpCommand` resolves its connection through `Migrator::resolveMigrationConnectionName()`, as `MigrateCommand::loadSchemaState()` does, so `schema:dump` runs on and names its file after the routed migration connection (the shipped `pgsql-pooled` routes to `pgsql`). Test the routed connection and default filename with default and explicit selection. + +Runtime fix: SQLite fetches a JSON number from a `json`/`jsonb` column as an int or float, which the strict-typed `json_decode()` rejected. `Casts\Json::decode()` returns such a number unchanged after the custom-decoder branch (which still receives the raw value), so every JSON cast reading through it works; `HasAttributes::fromJson()` and `jsonIsEquivalent()` accept `string|int|float|null`, as Laravel's untyped methods do. Covered by an SQLite hydrate, read and dirty-check regression and decoder tests. + - **Identity.** + - Facts belong to an owner: `Migrator::resolveMigrationConnectionName()` of the connection's `ConnectionName::parse()->base`, so a pooled model and its migration connection share facts. A config error the method rejects gives unknown identity. + - Facts are keyed by owner, schema (or none) and physical name: the requesting connection's `prefix` applied once to a logical name's final segment, as `Grammar::wrapTable()` does. Dump and raw-SQL names are already physical. + - Each side uses its own connection's effective config. Model lookups and fetched types use the model connection's. A migration statement (Blueprint or raw) uses its explicit `Schema::connection()`/`DB::connection()` name, which is not redirected, else the routed migration connection (`Migrator::runMethod()`). + - Connection names are evaluated, never executed: string and null literals (empty selects the default), the migration's `$connection` property, a `getConnection()` override that returns one expression, `config('key'[, default])`, `config()->get()`/`string()` and the `Config` facade's with a literal key, and `??` of these, reading only the side that runs. The migration's own `getConnection()` and `$connection` are read from its class, inherited ones only when `Migration` declares them. Config reads use the booted repository and keep a present null distinct from a missing key; `string()` and a `string` return type are known only for a string. Anything else is unknown, as is a kept first-class selector callable. Passport's and Telescope's published migrations are the reference cases. + - Effective config is deterministic: `parseConfig()`, then per-record `configForRead()`/`configForWrite()` as `DatabasePool::roleEndpoints()` does, never the `Arr::random()` selection. A `::read` name uses its read records; a base name uses read and write records, because reads can use either PDO. A `DB::extend()` connection uses its parsed config and reads `mixed`. Driver, prefix, `database`, schema/search path and username must agree across records, otherwise that connection's lookups are unknown; fetched types are the union over the distinct option sets. - A dump is `{connection}-schema.{sql,dump}`, for a booted `database.connections` key, in a `squashedMigrationsPath` directory. `.dump` wins over `.sql`, as in `MigrateCommand::schemaPath()`. - Any other name keeps unknown identity: document the naming requirement and never assume the default connection. - - A raw migration statement belongs to its `DB::connection()` argument, else the migration's connection, else the default (`Migrator::runMethod()`). - - Index tables, views, and PostgreSQL enums and domains by connection, schema (or none) and name, keeping unqualified definitions distinct. Never use the package's unqualified lookups, which throw on ambiguity. + - Index tables, views, and PostgreSQL enums and domains by owner, schema (or none) and name, keeping unqualified definitions distinct. Never use the package's unqualified lookups, which throw on ambiguity. - MySQL/MariaDB: an unqualified definition belongs to the connection's configured `database` (`schema:dump` writes no `USE`, and unqualified DDL targets the current database). SQLite definitions carry `main` or the attached database's name. - - PostgreSQL tables: an unqualified model table resolves through `PostgresBuilder::getCurrentSchemaListing()`'s precedence (`search_path`, then `schema`, then `public`; `$user` is the configured username), read from config only, taking the first schema with a qualified definition. An unqualified definition (from literal migration SQL) matches when no qualified candidate exists, because unqualified creation lands in the first existing path schema. An unqualified definition beside a qualified candidate, or a qualified model table with only an unqualified definition of that name, is ambiguous. - - PostgreSQL types: an unqualified type name resolves through the same precedence, with `pg_catalog` searched first unless the configured path names it explicitly, as PostgreSQL does. A name that resolves to no known built-in or dump-defined type reads `mixed`. + - PostgreSQL tables: an unqualified model table resolves through `PostgresBuilder::getCurrentSchemaListing()`'s precedence (`search_path`, then `schema`, then `public`; `$user` is the configured username), read from config only, taking the first schema with a qualified definition. An unqualified definition (from Blueprint or literal migration SQL) records the defining connection's resolved path and matches only a model connection with the same path, and only when no qualified candidate exists, because unqualified creation lands in the first existing path schema. An unqualified definition beside a qualified candidate, or a qualified model table with only an unqualified definition of that name, is ambiguous. + - PostgreSQL types: an unqualified type name resolves through the same precedence, with `pg_catalog` searched first unless the configured path names it explicitly, as PostgreSQL does. A name that resolves to no known built-in or dump-defined type reads `mixed`. A type fact can be uncertain (`null`: its definition or presence is unknown); reaching one reads `mixed` without searching later schemas. Domains resolve through the type facts, never through the base the parser embeds in a column, and an array (including an array of a domain) reads as its text representation first. - **Completeness.** - - A connection is incomplete when its dump throws `ParseException`, is a pg_restore custom archive (`PGDMP`), has schema-level issues, or its driver has no parser. + - A connection is incomplete when its dump cannot be read, throws `ParseException` (the PostgreSQL parser rejects `PGDMP` custom archives), has schema-level issues, or its driver has no parser. Schema-level issues also make that input's tables incomplete and its types uncertain, as for raw SQL. A relation that two dumps of one owner both define is unknown; a type they define differently is uncertain, and a later dump does not restore it. An owner with no schema source (no dump and no interpreted statement) is unknown, not proven empty. - A table is incomplete when it has issues, is a view (no column facts), is ambiguous, or is affected by the migration-side cases below. - - Known columns keep their types and casts, except where raw migration SQL makes them uncertain. An unknown column on an incomplete table or connection gets no missing-column claim, while complete tables still report one. -- **Raw migration SQL.** Each parse starts empty; its result does not describe changes to the accumulated migration schema. A recognized raw statement (`DB::statement()`/`unprepared()`, directly or through `DB::connection()`) makes earlier inferred facts on its connection uncertain: existence and types as well as missing-column claims. Keep only facts that remain justified; explicit casts and accessors stay authoritative. A colliding raw definition neither preserves nor replaces the earlier one automatically. Regressions: drop-and-recreate versus `CREATE TABLE IF NOT EXISTS` after a Blueprint table, and a raw drop or rename leaving no missing-column claim. + - Known columns keep their types and casts unless a migration-side case makes them uncertain. An incomplete dump table that the same dump alters (`Impact::$relations`) is unknown: the parser could not apply that alteration, so a kept column may be gone or retyped. An unknown column on an incomplete table or connection gets no missing-column claim, while complete tables still report one. + - With no established facts for the model's table, an undeclared name is maybe an attribute: the property extension defers to a declared relation (or its `_count`) before assuming one. A known cast key is an attribute without a column, as `HasAttributes::hasAttribute()` accepts it; undetermined method casts do not make every name one. +- **Uninterpretable migrations.** A construct the aggregator cannot interpret makes the facts it could affect unknown, types included: an unknown Blueprint method (macros), a helper or other escape of `$table`, a non-constant column name in a `change()`, loops and other control flow make the table unknown; an added column with a non-constant name makes the table incomplete, keeping its known columns; an unknown table argument makes the owner unknown; an unknown connection, or a migration file that does not parse (upstream skipped it), makes every owner unknown. Do not build a PHP interpreter. + - Facts come only from code applied as executed: `up()`, the migration's own methods it calls (after their arguments, which are searched), Blueprint callbacks (including `after()`), `withoutForeignKeyConstraints()` and `whenTable(Doesnt)Has(Column|Index)()` (the column guard is decided like `hasColumn()`; index guards join both outcomes). Other code, including closures and helpers in nested positions, is searched for Schema and DB calls, which only make their targets unknown. A first-class callable created as a statement runs nothing; a kept one (assigned or passed) makes its targets unknown, since it can run later with any arguments. + - `removeColumn()` only cancels the definitions its Blueprint queued, so its column becomes uncertain (never a drop). A non-literal `unsigned` makes only that MySQL column's type unknown. + - `addColumn()`'s literal parameter bag applies over its type and name, as `Blueprint::addColumn()` merges it, and later fluent modifiers win; a non-literal bag makes the table unknown. + - Rename, drop and `change()` targets match column names as the driver does: ignoring case on MySQL/MariaDB/SQLite (keeping the stored spelling; an ambiguous match makes the table unknown), exactly on PostgreSQL. A `change()` naming a stored column in another case makes the table incomplete, since the resulting spelling depends on the server. + - Builders in local variables, without tracking data flow: per applied method or closure (cached per function; an arrow function shares its parent's), a variable holds a builder when every write is an assignment statement of a Schema selector (`Schema::connection()`, `DB::getSchemaBuilder()`, `DB::connection()->getSchemaBuilder()`), all selecting one connection, and every other use, nested functions included, calls a method on it. Its calls are followed like `Schema::` calls. Any other variable assigned a Schema or DB selector escapes: the selector and every later use of the variable make the selected connection unknown (every owner when unknown). A Schema selector not consumed by a recognized call or builder variable, and a `DB::connection()` that is not a method receiver, also escape. Query chains on a DB connection (`DB::connection()->table()->update()`) keep facts; this is the supported query-chain boundary, not proof that no method call changes the schema. Aliases of aliases, builders reached through properties, and statements on connections reached other ways (`$this->db`, `app('db')`) are not followed; the docs state this. + - PostgreSQL inheritance and partition descendants change with their parent (Blueprint DDL has no `ONLY`; dropping a partitioned table drops its partitions). Every Blueprint alter, drop or rename and every raw-SQL impact relation also makes the known descendants unknown, found through the parsed `parents`/`partitionOf` links. Regressions: a dump with a parent and child followed by a raw `ALTER COLUMN TYPE` on the parent, and a Blueprint `Schema::table()` on the parent. + - `if`/`elseif`/`else`/`switch`: apply each outcome, including the fallthrough, to cloned state and merge at each boundary. A column or table absent from any outcome becomes uncertain (`mixed`, no claim; a missing attribute reads null). An identical `ColumnType` in every outcome keeps its type with nullability ORed; differing types read `mixed`. A user type that one outcome lacks or defines differently is uncertain. + - `Schema::hasTable()`/`hasColumn()` guards (also through a selector or builder variable, negated or compared with `true`/`false`) are decided only from established facts of a known owner and complete table, ignoring letter case as the framework can; otherwise the condition is unknown. An early `return` ends its outcome for the rest of that body. Each body keeps at most a continuing and a merged returned state. +- **Raw migration SQL** (`DB::statement()`/`unprepared()`, directly or through `DB::connection()` or `Schema::getConnection()`). Each parse starts empty, so its snapshot does not describe changes to the accumulated schema; the parser's `Schema::$impact` summary does. Each relation it lists (altered, dropped or renamed, including both rename names, whether or not the input also defines it) becomes unknown, and `unknown` makes the owner's earlier facts unknown and skips the input's definitions. Each type in its `types` list (an enum label change, a non-renaming `ALTER DOMAIN`, or a drop; renames, schema moves, other type changes and `CASCADE` set `unknown`) becomes uncertain before the input's definitions apply: a qualified name marks its own key, recorded or not, which stops any search through its schema (an unqualified definition was created in a path schema, so it stays known); an unqualified name marks the existing unqualified and configured-path keys. Index and comment statements keep facts. Created definitions add facts; one for a relation that exists or may exist (an incomplete owner) makes only that relation unknown, since the snapshot does not distinguish `IF NOT EXISTS`. Schema-level issues make the input's tables incomplete and its types uncertain. `ensureExtensionExists()` is classified like raw `CREATE EXTENSION`. Explicit casts and accessors stay authoritative. + - MySQL/MariaDB identities stay exact, but each mutation also makes known relations whose database and table names differ only in letter case unknown, because some servers fold them. Lookups are not case-folded. + - Inference uses the configured connection identity and does not track session state carried across separate raw calls (`USE`, `SET search_path`). This is an analysis limitation: the Static Analysis docs state it and recommend a configured connection for another database or schema, and a short WHY comment at the impact handling records it. + - Regressions: drop-and-recreate versus `CREATE TABLE IF NOT EXISTS` after a Blueprint table, a raw drop or rename leaving no missing-column claim, a raw index preserving earlier facts, a MySQL raw `ALTER TABLE Users` after a Blueprint `users`, `removeColumn()` after `->change()` keeping the column, `whenTableHasColumn()` with a decided and an unknown guard, and `DB::transaction(fn () => Schema::create(...))` leaving no missing-table claim. - **Diagnostics.** - - A collector records the dump identity and failure facts that a model-property lookup consulted. The data is serializable and loaded lazily. - - A `CollectedDataNode` rule (`hypervel.schemaDump`, under `checkModelProperties`) reports each unusable dump (a `ParseException` or `PGDMP` archive) once, at the dump file: `ParseException::$sqlLine`, else line 1. Issues and incomplete constructs are not diagnosed. + - A `PropertyFetch` collector (nullsafe fetches included) records the unusable-dump facts (path, line, message) of the model's connection when a model's undeclared property is read. It does not track whether the lookup reached the schema (an `@property` tag, for example), since that would need order-dependent state in the helper. The data is serializable and loaded lazily. + - A `CollectedDataNode` rule (`hypervel.schemaDump`) reports each unusable dump (unreadable or a `ParseException`) once, at the dump file: `ParseException::$sqlLine`, else line 1. Issues and incomplete constructs are not diagnosed. - The installed `AnalyserResultFinalizer` skips `CollectedDataNode` rules when nothing was collected, so a standalone rule cannot serve as the hook. - **Fetched types.** - One mapping, keyed by connection driver and column family and shared by the migration and dump paths, gives a read type (fetched) and a write type (accepted). Nullability applies next, then the fetch-option transformations. Explicit casts, the implicit incrementing-key cast (`HasAttributes::getCasts()`), dates and accessors take precedence. + - Both paths produce schema-parser `ColumnType` facts, resolved at lookup so option changes need no reaggregation. A Blueprint type maps directly to the type its driver's grammar emits, limited to facts inference needs, with PostgreSQL's canonical `pg_catalog` identity. The grammar is not run: MySQL's `typeDate()`/`typeYear()` read the live server version. Blueprint CHECK-backed enum values (pgsql, SQLite) are analyzer column metadata, not `ColumnType::$values`. `rawColumn()` reads `mixed`. + - Dates read `CarbonInterface` (`asDateTime()`), immutable casts `Carbon\CarbonImmutable`; date writes accept `DateTimeInterface|string|int|float` (`fromDateTime()`). + - `ModelHelper` runs the trait initializers (`initializeHasTimestamps()`, `initializeHasUniqueStringIds()`) before `initializeModelAttributes()` on the memoized constructor-less instance, and nothing mutates it later, so key type and incrementing never depend on call order (upstream set `usesUniqueIds` inside `ModelCastHelper`). Cover property-before-casts access. - Upstream differences, each commented at its mapping: - Uncast decimals read `numeric-string` on MySQL/MariaDB, `string` on pgsql (numeric holds NaN/Infinity) and `float|int` on SQLite. They are written as `float|int|numeric-string` (`float|int|string` on pgsql). - Booleans read `int` (`tinyint(1)`) except `bool` on pgsql. - pgsql `bytea` reads `resource`. - - MySQL/MariaDB `BIGINT UNSIGNED` reads `int<0, max>|numeric-string`, because values above `PHP_INT_MAX` fetch as strings. There is no foreign-key or auto-increment shortcut; the implicit key cast types `$model->id`. + - MySQL/MariaDB `BIGINT UNSIGNED` and `BIT(64)` read `int<0, max>|numeric-string`, because values above `PHP_INT_MAX` fetch as strings; shorter `BIT` columns read `int<0, max>` (native and emulated prepares fetch them as integers). There is no foreign-key or auto-increment shortcut; the implicit key cast types `$model->id`. + - MySQL/MariaDB `SET` reads `string`: it holds comma-joined combinations, not one label (upstream used the labels' union). + - SQLite numeric affinity reads `float|int` only for `numeric`/`decimal`; other names with numeric affinity (`uuid`, `string`, `geometry`) and untyped columns read `mixed`, because SQLite keeps non-numeric values as written. + - SQLite `json`/`jsonb` columns read `float|int|string`: numeric affinity stores a JSON number as a number, which PDO fetches as one. Blueprint `json()`/`jsonb()` create them only under the connection's `use_native_json`/`use_native_jsonb`, else `text`; the two flags join the endpoint identity and the aggregation fingerprint. - Booted connection `options`, merged as in `Connector::getOptions()`, adjust the read types: - `ATTR_STRINGIFY_FETCHES`: int families, MySQL/MariaDB decimal and double, and integer-backed booleans become `numeric-string`. pgsql numeric and float and SQLite real become `string` (`'NaN'`, `'NAN'`, `'INF'`). pgsql bool becomes `'0'|'1'`. - `ATTR_ORACLE_NULLS`: `NULL_TO_STRING` replaces null with `''`; `NULL_EMPTY_STRING` adds null where `''` can be read. - `ATTR_CASE`: folds fetched attribute names at the property lookup, not the schema names, cast lookups or write targets. - Drivers without a mapping read `mixed`. - SQLite types describe the framework's standard writes, not engine enforcement; STRICT tables do enforce their types. Explicit casts give stable application types. -- **Assignment.** `ModelProperty::canChangeTypeAfterAssignment()` is true only for raw schema attributes: no cast (including the implicit key cast), not a date, and no accessor or mutator. PHPStan then narrows after assignment, while transforming attributes keep stable read types. +- **Casts.** `ModelCastHelper` merges the runtime `$casts` property (with the implicit key cast) with `casts()`, read statically. It never runs `casts()`: one reading constructor-initialized state fails on the constructor-less instance. + - The framework's default `casts()` is skipped. With `parseModelCastsMethod`, an override is parsed with `@cachedRichParser` (the cleaning parsers strip method bodies), found by its native file and start line (traits, several classes per file). Only a single unconditional top-level array return is read; returns inside nested function-likes are ignored. It is evaluated in the declaring class's scope, so an inherited `self::` resolves and a non-final `static::` stays unknown. + - Values: constant strings verbatim (PHPStan already unescapes PHPDoc strings), the `[$cast, ...$arguments]` list normalized as `ensureCastsAreStringValues()` does, anything else undetermined. The constructor-less instance skips `initializeHasAttributes()`, so its `$casts` property values get the same normalization. The source types declare that form: `$casts` and `casts()` accept `[$cast, ...$arguments]` lists (arguments may be ints), while `getCasts()` stays normalized `array` (`types/Database/Eloquent/Casts/CastDeclarations.php`). + - `AsCollection`/`AsEncryptedCollection` read the collection class `using()` selects, with its templates at their bounds, when no mapper is given; with a mapper, the base `Collection`. An undetermined parsed value falls back to the same required key of the declared return shape; an unusable parse uses the declared shape. + - One shape not explicitly open (`...`) is complete: its required entries override the property casts and its optional entries are undetermined. `isUnsealed()` is `maybe` for ordinary shapes outside bleeding edge, so completeness tests `! yes()`. An open shape, several alternatives or a non-constant type leaves every attribute it does not require undetermined. + - `hasCastForProperty()` includes undetermined casts; `getCastForProperty()` returns null for them. An undetermined cast reads and writes `mixed` (a date column keeps the date write type, since `fromDateTime()` runs first) and serializes as `mixed` even beside an accessor, because class casts serialize first. A key without a known column still applies its casts. + - `CastDefinitionDynamicStaticMethodReturnTypeExtension` types an explicit map of framework helpers (`AsArrayObject::nullable`, `AsBinary::of`/`ulid`/`uuid`, `AsCollection::nullable`/`of`/`using`, `AsEncryptedCollection::of`/`using`, `AsEnumArrayObject::of`, `AsEnumCollection::of`) as the string the real helper returns. It applies only when the receiver is exactly the declaring class (a subclass could redirect `static::` calls) and every argument is a single constant its parameter type accepts; arrays only as callable mappings that `is_callable()` accepts. Otherwise the declared `string` applies. + - PHPStan reanalyses dependents only when declarations change. The model's metadata tracking (Result-cache correctness) covers the method's file; parsing also tracks, on the model, the files the constants and static methods its return uses come from (`SourceFiles`). The helper extension tracks its own source on the caller scope. + - Regressions: `Type/data/model-casts-parsing.php` and the warm body-edit case in `ModelPropertyDependencyTest`. +- **Assignment.** `ModelProperty::canChangeTypeAfterAssignment()` is true only for raw schema attributes: no cast (including the implicit key cast and undetermined casts), not a date, and no accessor or mutator. PHPStan then narrows after assignment, while transforming attributes keep stable read types. +- **Accessors and mutators.** Each direction follows Eloquent's precedence, falling back to the database property (with its casts and dates) when it has no handler: + - Reads: `get{Name}Attribute()`, then an `Attribute` get callback (`TGet` other than `never`). Writes: `set{Name}Attribute()`'s first parameter (`mixed` without one), then `TSet`. + - A virtual attribute's fallback is raw `mixed` with its casts and dates; an uninstantiable model's is `mixed`. + - Recognition matches `HasAttributes::hasAttributeMutator()`: a non-private method whose native return type names `Attribute` exactly. The generics come from PHPDoc; a bare `Attribute` is ``, and PHPStan's `missingType.generics` reports the missing annotation. Public methods count; the visibility rule reports them separately. + - Serialization prefers an `Attribute` getter to `get{Name}Attribute()` (after class casts), as `mutateAttributeForArray()` does, and converts dates only on the `Attribute` branch. A set-only `Attribute` serializes through the column's casts. + - Unlike upstream, a direction without a callback is not `never`, legacy setters type writes, and bare or public `Attribute` methods are not hidden. + - Regressions: get-only and set-only accessors over a column, a virtual fallback with a cast, legacy setter input, legacy getter precedence beside an unrelated camelCase method, bare and public recognition, set-only serialization through a cast, an inherited legacy getter beside a child `Attribute` getter, and the `Attribute`-only date conversion. Track connection/table identity where it changes the result, schema changes across migrations, nullability, defaults, enums/casts and supported custom driver boundaries. If a dynamic migration or unsupported custom-driver construct prevents a complete schema, preserve known declarations and avoid reporting an unproven missing column. Do not turn all unknown properties into `mixed` globally or suppress unrelated mistakes. Preserve upstream extension points instead of inventing a driver plugin platform. ## Result-cache correctness and analysis cost - Retain PHPStan's `DependencyTracker`/`DeclarationDependencyTracker` use for configuration, migrations, schema dumps, views and translations. Track both changed files and directory membership so newly added/deleted files invalidate results. Keep declaration-based dependencies for reflection extensions and scope-based dependencies for return types/rules. +- Config sources come from one place, `ConfigSourceResolver::getKeySources()`: in each config directory the file of every key prefix (existing or not) and the key's own directory (`*.php`, recursive, so a nested file created later is noticed), then the framework file and overlapping package merge files. `ConfigParser` uses it with its config directories and carries directories through its cached dependency records, so a value read through another config file replays them. The schema facts read the booted repository, so `ModelPropertyHelper` declares the sources of every key `ConnectionConfigResolver` read (its own and the migrations' connection keys, also on a `MigrationCache` hit) against the booted directories, once the facts are loaded, on `Model` and on classes using `model-property`. Regression: a warm run after a config edit that moves a migration to another connection. Provenance of runtime-copied files, provider writes and inputs, `.env` and environment identity stay with the shared tracking below; the final warm-cache verification covers all of them, including schema connection config. New directories matching a glob `configDirectories` entry are not covered. +- Schema sources besides migrations and dumps: constants naming tables, columns or connections, `foreignIdFor()` model metadata and a migration's parent class. `SchemaAggregator` records their files: column-level ones (column names, allowed values, `foreignIdFor()` models) on the table, so only models reading that table depend on them; identity-level ones (table names, a rename's target, migration parents) on every model through `Model`. A guard (`hasTable()`, `hasColumn()` and the like) reads one table's facts to decide what happens to any table, so that table's sources become sources of every fact. `MigrationCache` stores their hashes, validates them on a hit and replays them. Regressions: warm edits of a constant a migration reads and of one a guard reads. +- Model metadata. PHPStan's exported declarations omit property defaults and method bodies, and a changed constant reanalyses only the files naming its class. `ModelHelper::getInstance()` therefore tracks, once per model on the model class, the files declaring it and its parents below `Model` with their traits, and the files of constants their property defaults read. A constant read through a class depends on that class and its parents up to the declaring one, which can override it, and on the constants its value reads (`SourceFiles`). An overriding `getConnectionName()` can read any config value and is not interpreted, so that model also depends on every booted config source (`ConfigSourceResolver::getAllSources()`: the config directories, the framework config directory and package merge files), computed once. Regressions: warm edits of a parent's `$connection` default and of the config such an override reads. +- Forwarded builder methods. A caller depends only on the class it calls through and the classes its types name, so a call through a model or relation never depends on the model's custom builder or query class. `BuilderDependencies` declares, per builder instantiation (`getCacheKey()`, since the query class can come from active template types), class dependencies on a custom builder and query class and the file declaring a custom `$passthru` (a property default, so not a declaration). It records them on the owner every caller depends on: the model for model calls, the related model for relation calls (never the shared relation class), the builder for direct calls. Both forwarding extensions register before resolving the method, so restoring a removed method clears the stale error. Macro registrations stay with the shared tracking below. Regressions: warm builder (passthru alone, then a declaration) and query-class edits with recovery, each route calling through its own classes so one route's inputs cannot mask another's. - Track runtime inputs explicitly; normal class dependencies alone do not capture registration code. For macros/mixins, track registered closure/method source files through reflection, plus registration providers and the mixin class itself. For container string keys, track loaded provider source, `bootstrap/app.php`, `bootstrap/providers.php`, Composer's installed metadata/root discovery configuration and package discovery cache, Testbench YAML, `.env` and the relevant pre-boot environment identity. Resolved concrete classes retain normal class dependencies. For console definitions, track command class files and `routes/console.php`. Auth configuration and view/translation namespace registrations inherit their config/provider/resource dependencies. Include files called by registration helpers where they supply a consumed fact; retain an explicit dependency at that boundary rather than assuming the analyzed caller references them. Compute shared inputs once per process, not per symbol. -- Package-mode boot paths point into Testbench's per-process runtime copy, so tracked config, view and translation dependencies vanish between runs. Resolve provenance once at this shared tracking boundary, for all three: track the actual source inputs of copied files and registrations, and keep conservative invalidation where provenance is unknown (the analyzer fixture overwriting runtime `config/auth.php` and `resources/views`, other Testbench file changes, borrowed runtimes, a predefined `BASE_PATH`). No per-parser remapping or blanket prefix substitution. Required before final integration signoff. +- Package-mode boot paths point into Testbench's per-process runtime copy, so tracked config (including the schema facts' connection config), view and translation dependencies vanish between runs. Resolve provenance once at this shared tracking boundary, for all three: track the actual source inputs of copied files and registrations, and keep conservative invalidation where provenance is unknown (the analyzer fixture overwriting runtime `config/auth.php` and `resources/views`, other Testbench file changes, borrowed runtimes, a predefined `BASE_PATH`). No per-parser remapping or blanket prefix substitution. Required before final integration signoff. - Boot-derived config mutation targets (the recorded `ConfigMutationTracker` keys) depend on external conditions, such as Testbench rewriting `database.default` only when the SQLite database file is missing. A stale result can keep a wrong narrow type or an unnecessarily broad one, and provider hashes and environment identity do not notice the file appearing or disappearing. Include that condition (file presence) in the shared tracking, and cover a warm-cache presence transition in both directions against a cold run. No new tracking in the config parser, no boot during cache restoration and no database content hashing. - `HypervelStubFilesExtension` discovers stub files from a directory. PHPStan rechecks the hashes of stubs recorded in the cache but not a changed list from an extension, so track stub-directory membership with the shared inputs and verify that adding a stub with unchanged extension PHP/config invalidates a warm cache. - PHPStan restores its result cache in the main process before application bootstrap. A `ResultCacheMetaExtension` cannot assume a booted container. Use it only for global inputs available before boot, such as selected configuration/environment identity; use existing PHPStan facilities for per-file facts. Do not serialize the live container or eagerly boot it during cache restoration. - Test a changed boot-derived return type with unchanged analyzed source, on a warm cache. A real provider/macro fixture must change a formerly valid call into an error without manually clearing the cache, and recover when the provider is corrected. Include a `mixin()` class referenced only by provider registration. Verify the same result with a cold run. - Verify unchanged warm-run reuse for an application and a Testbench-backed package: a second normal run with no changes restores the result cache and reanalyses nothing, even though Testbench creates a new runtime directory. Identical warm and cold diagnostics alone cannot show reuse. Use PHPStan's ordinary result-cache output; no timing threshold, benchmark, `--debug` or routine cache clearing. -- Preserve the migration cache's locking, second read under lock and atomic replacement. Port upstream `MigrationCacheTest` and dependency tests, then add the changed-fingerprint regression: `getMTime()` has whole-second resolution, so ordinary edit-and-analyze loops can reuse a stale schema after two edits in one second. Keep mtime fixed explicitly in this regression instead of relying on test timing or sleeps. Use existing content fingerprints or a once-per-input `xxh128` digest and add a short WHY comment at the change so an upstream sync preserves it. Preserve upstream's timestamp-change invalidation alongside content changes so existing applicable tests remain meaningful. No new concurrency suite is needed for unchanged locking. Keep one parsed-schema cache, bounded in-process reflection caches and normal PHPStan temp storage; no extra persistent metadata service. -- Keep runtime source changes free of analysis-only request overhead. Parse lazily, reuse reflection/ASTs, and do not resolve every container service or model for every node. Use ordinary bounded correctness runs during development; get fresh owner confirmation that the machine is idle before any benchmark. Compare cold/warm analysis and representative runtime paths only when needed to assess a concrete cost. +- Preserve the migration cache's locking, second read under lock and atomic replacement. Port upstream `MigrationCacheTest` and dependency tests, then add the changed-fingerprint regression: `getMTime()` has whole-second resolution, so ordinary edit-and-analyze loops can reuse a stale schema after two edits in one second. Keep mtime fixed explicitly in this regression instead of relying on test timing or sleeps. Use existing content fingerprints or a once-per-input `xxh128` digest and add a short WHY comment at the change so an upstream sync preserves it. Preserve upstream's timestamp-change invalidation alongside content changes so existing applicable tests remain meaningful. The fingerprint also covers the normalized config the aggregation reads (statement-connection facts, routing, `database.default`, `Builder::$defaultMorphKeyType`) and the installed `hypervel/schema-parser` version and reference. Connection-name keys that migrations read are known only after aggregating, so the entry stores each one's presence and value and is used, on both reads, only while they match. No new concurrency suite is needed for unchanged locking. Keep one parsed-schema cache, bounded in-process reflection caches and normal PHPStan temp storage; no extra persistent metadata service. +- Keep runtime source changes free of analysis-only request overhead. Parse lazily, reuse reflection/ASTs, and do not resolve every container service or model for every node. Use ordinary bounded correctness runs during development; get fresh owner confirmation that the machine is idle before any benchmark. Compare representative runtime paths only when needed to assess a concrete cost. +- Final efficiency pass, after the shared tracking. Agents re-run the analyzer constantly while building and maintaining Components, apps and third-party packages, so re-runs must be fast. Owner requirement: + - An unchanged warm run reanalyses nothing for an application, Components and a Testbench-backed package (this needs the runtime-copy provenance fix above). + - Check every declared dependency for unnecessary invalidation and confirm normal warm reuse. The known conservative boundaries are accepted without evidence against them: whole-file model metadata tracking (a body-only edit to a shared base model reanalyses every model user), the recursive default migration-directory pattern (a nested file migrate never reads invalidates) and all-config tracking for an overriding `getConnectionName()`. No semantic hashing or new value extension unless measurement shows the extra invalidation matters. + - No repeated work per run, and bounded caches. + - Measure cold and warm timings, with fresh owner confirmation that the machine is idle. ## Upstream rule disposition @@ -494,7 +550,7 @@ Port upstream rule/type/reflection/unit/integration tests under `tests/PHPStan`, The process keeps one shared analyzer app. Its PHPUnit bootstrap, `tests/PHPStan/phpunit-bootstrap.php`, loads the extension's bootstrap before any test, so the app's error and exception handlers predate PHPUnit's per-test handler checks (`failOnRisky`); unit-style analyzer tests extend `Hypervel\Tests\PHPStan\TestCase`, which keeps those handlers rather than flushing them through `HandleExceptions::flushState()`. Tests that deliberately change its config, paths, bindings or macros restore their own changes; no blanket flush. Do not run Hypervel Testbench test cases in this process: their teardown flushes the shared app. Boot, cache and install checks run as subprocess fixtures with their own bootstrap/autoload; source runtime fixes stay in their owning framework suites. Every `setUp()` override calls the PHPStan parent initializer. Drop upstream `NoEnvCallsOutsideOfConfigRuleTest::setUp()`/`overrideConfigPath()`: it skips that initializer and permanently changes the app config path, which its explicit `configDirectories` never consume. Verify multiple tests and files in one reused process with existing macro/container cases (a focused ParaTest `-p1` run); no generic lifecycle test framework. -Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime for its assets instead of modifying its committed skeleton, writing into vendor, or always-loading package routes. Keep upstream's `App\`, `Database\Factories\` and `Database\Migrations\` namespaces: `Factory::resolveFactoryName()` derives factories from the app namespace that `Application::getNamespace()` reads from the Testbench skeleton (`App\`). Register those prefixes on Composer's existing loader from `tests/PHPStan/phpunit-bootstrap.php` before PHPStan reflection starts; PHPStan's `AutoloadSourceLocator` resolves them through `ClassLoader::findFile()`. No root `autoload-dev` mapping for these fixture-app prefixes, and no custom autoloader. Root `autoload-dev.classmap` lists `tests/PHPStan/{Integration,Rules,Type}/data`: PHPStan's test source locator discovers functions and inline helper classes declared in analysis fixtures through Composer classmap directories, without executing the fixtures, as upstream's `tests/` classmap does. Keep existing `types/` fixtures as public typing regressions and update moved references. +Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime for its assets instead of modifying its committed skeleton, writing into vendor, or always-loading package routes. Keep upstream's `App\`, `Database\Factories\` and `Database\Migrations\` namespaces: `Factory::resolveFactoryName()` derives factories from the app namespace that `Application::getNamespace()` reads from the Testbench skeleton (`App\`). Register those prefixes on Composer's existing loader from `tests/PHPStan/phpunit-bootstrap.php` before PHPStan reflection starts; PHPStan's `AutoloadSourceLocator` resolves them through `ClassLoader::findFile()`. No root `autoload-dev` mapping for these fixture-app prefixes, and no custom autoloader. Root `autoload-dev.classmap` lists `tests/PHPStan/{Integration,Rules,Type,Unit}/data`: PHPStan's test source locator discovers functions and inline helper classes declared in analysis fixtures through Composer classmap directories, without executing the fixtures, as upstream's `tests/` classmap does. Keep existing `types/` fixtures as public typing regressions and update moved references. Keep type fixtures that intentionally exercise still-supported APIs. In `phpstan.types.neon.dist`, set `noLegacyModelScopes`, `noLegacyModelProperties`, `noLegacyQueueProperties` and `noPipeSeparatedValidationRules` to `false`, with one comment that these fixtures assert supported types rather than authoring policy. Also set `checkModelMethodVisibility` and `noUnnecessaryEnumerableToArrayCalls` to `false` in this harness: `CustomBuilderForwarding.php` deliberately declares public scopes, and Collection/LazyCollection fixtures assert conversion return types. These preferences retain their normal defaults in source/consumer analysis and their dedicated `RuleTestCase` coverage. Preserve type assertions; do not delete compatibility cases. For upstream always-registered diagnostics without a switch, use a local identifier suppression with a fixture-specific reason only at the intentional call (for example the existing `value('foo', 42)` and `with(new User)` helper fixtures). Do not invent public switches or a second rule-registration system solely for the harness. From 46e433e7c08b5ed64cb6f55a6d6d966f74caa2e8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:31:29 +0000 Subject: [PATCH 109/303] Allow extension of PHPStan classes without unnecessary final restrictions Remove final from the analyzer classes and the two branch-added fixtures where preventing inheritance protects no concrete invariant. Preserve ConfigArray's readonly declaration and all existing method bodies, signatures and property declarations. The complete file comparison confirms this is exactly one keyword removal per file. Validation: formatting unchanged, full PHPStan and types checks clean, the directory iterator regression passed, and all 1464 analyzer tests passed. --- src/phpstan/src/ApplicationResolver.php | 2 +- .../src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php | 2 +- src/phpstan/src/Collectors/UsedEmailSendViewCollector.php | 2 +- src/phpstan/src/Collectors/UsedEmailViewCollector.php | 2 +- src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php | 2 +- src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php | 2 +- src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php | 2 +- .../src/Collectors/UsedTranslationTranslatorCollector.php | 2 +- src/phpstan/src/Collectors/UsedTranslationViewCollector.php | 2 +- src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php | 2 +- src/phpstan/src/Collectors/UsedViewFunctionCollector.php | 2 +- src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php | 2 +- src/phpstan/src/Collectors/UsedViewMakeCollector.php | 2 +- src/phpstan/src/HypervelStubFilesExtension.php | 2 +- src/phpstan/src/Internal/ConfigHelper.php | 2 +- src/phpstan/src/Internal/ConsoleApplicationHelper.php | 2 +- src/phpstan/src/Internal/ConsoleApplicationResolver.php | 2 +- src/phpstan/src/Internal/RecursionGuard.php | 2 +- src/phpstan/src/Methods/FacadesMethodsExtension.php | 2 +- src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php | 2 +- src/phpstan/src/Methods/HigherOrderTapProxyExtension.php | 2 +- src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php | 2 +- src/phpstan/src/Methods/Macro.php | 2 +- src/phpstan/src/Methods/ManagersMethodsExtension.php | 2 +- src/phpstan/src/Methods/OptionalExtension.php | 2 +- src/phpstan/src/Methods/RedisCommandMethodsExtension.php | 2 +- src/phpstan/src/Properties/DirectoryResolver.php | 2 +- .../Properties/HigherOrderCollectionProxyPropertyExtension.php | 2 +- .../src/Properties/HigherOrderWhenProxyPropertyExtension.php | 2 +- src/phpstan/src/Properties/MigrationCache.php | 2 +- src/phpstan/src/Properties/ModelAccessorExtension.php | 2 +- src/phpstan/src/Properties/ModelPropertyExtension.php | 2 +- src/phpstan/src/Properties/ModelRelationsExtension.php | 2 +- src/phpstan/src/Properties/SchemaAggregator.php | 2 +- src/phpstan/src/Properties/SchemaColumn.php | 2 +- src/phpstan/src/Properties/SchemaTable.php | 2 +- src/phpstan/src/Properties/SquashedMigrationHelper.php | 2 +- src/phpstan/src/Reflection/DynamicWhereMethodReflection.php | 2 +- src/phpstan/src/Reflection/ProxiedMethodReflection.php | 2 +- src/phpstan/src/Reflection/ProxiedParameterReflection.php | 2 +- src/phpstan/src/Reflection/ProxiedPropertyReflection.php | 2 +- src/phpstan/src/Reflection/ReflectionHelper.php | 2 +- .../src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php | 2 +- src/phpstan/src/ReturnTypes/AppMakeHelper.php | 2 +- .../ArrGetDynamicStaticMethodReturnTypeExtension.php | 2 +- src/phpstan/src/ReturnTypes/AuthExtension.php | 2 +- src/phpstan/src/ReturnTypes/AuthManagerExtension.php | 2 +- .../ConditionableExpressionTypeResolverExtension.php | 2 +- .../src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php | 2 +- .../ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php | 2 +- src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php | 2 +- .../FilesystemManagerDynamicMethodReturnTypeExtension.php | 2 +- src/phpstan/src/ReturnTypes/GuardExtension.php | 2 +- src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php | 2 +- src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php | 2 +- src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php | 2 +- src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php | 2 +- src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php | 2 +- src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php | 2 +- src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php | 2 +- src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php | 2 +- src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php | 2 +- src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php | 2 +- .../ModelSerializationDynamicMethodReturnTypeExtension.php | 2 +- src/phpstan/src/ReturnTypes/RequestUserExtension.php | 2 +- src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php | 2 +- .../src/ReturnTypes/TranslatorGetReturnTypeExtension.php | 2 +- src/phpstan/src/Rules/ArrKeyExistenceRule.php | 2 +- src/phpstan/src/Rules/ConfigCollectionRule.php | 2 +- src/phpstan/src/Rules/ConfigTypeRule.php | 2 +- .../src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php | 2 +- src/phpstan/src/Rules/NoMissingTranslationsRule.php | 2 +- .../src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php | 2 +- src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php | 2 +- src/phpstan/src/Rules/UnusedViewsRule.php | 2 +- src/phpstan/src/Support/ArrKeyLookup.php | 2 +- src/phpstan/src/Support/BootstrapErrorHandler.php | 2 +- src/phpstan/src/Support/CollectionHelper.php | 2 +- src/phpstan/src/Support/ConfigArray.php | 2 +- src/phpstan/src/Support/ConfigParser.php | 2 +- src/phpstan/src/Support/ConfigSourceResolver.php | 2 +- src/phpstan/src/Support/HigherOrderWhenProxyHelper.php | 2 +- src/phpstan/src/Support/RecursiveDirectoryIterator.php | 2 +- src/phpstan/src/Support/ViewFileHelper.php | 2 +- src/phpstan/src/Support/ViewParser.php | 2 +- .../src/Types/AbortIfFunctionTypeSpecifyingExtension.php | 2 +- src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php | 2 +- .../ModelProperty/ModelPropertyTypeNodeResolverExtension.php | 2 +- tests/PHPStan/Unit/Support/BrokenRewindDirectoryStream.php | 2 +- types/Support/Traits.php | 2 +- 90 files changed, 90 insertions(+), 90 deletions(-) diff --git a/src/phpstan/src/ApplicationResolver.php b/src/phpstan/src/ApplicationResolver.php index 994d06525..ab8d5f036 100644 --- a/src/phpstan/src/ApplicationResolver.php +++ b/src/phpstan/src/ApplicationResolver.php @@ -19,7 +19,7 @@ * * @internal */ -final class ApplicationResolver +class ApplicationResolver { /** * Create a package application from its Testbench configuration. diff --git a/src/phpstan/src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php b/src/phpstan/src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php index f772a7000..70109a255 100644 --- a/src/phpstan/src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php +++ b/src/phpstan/src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php @@ -19,7 +19,7 @@ /** @implements Collector> */ #[AutowiredService] -final class UsedEmailAlternativeSyntaxViewCollector implements Collector +class UsedEmailAlternativeSyntaxViewCollector implements Collector { private const array VIEW_PARAM_NAMES = ['view', 'html', 'text', 'markdown']; diff --git a/src/phpstan/src/Collectors/UsedEmailSendViewCollector.php b/src/phpstan/src/Collectors/UsedEmailSendViewCollector.php index fc3bfdcc0..cc863c22e 100644 --- a/src/phpstan/src/Collectors/UsedEmailSendViewCollector.php +++ b/src/phpstan/src/Collectors/UsedEmailSendViewCollector.php @@ -23,7 +23,7 @@ /** @implements Collector */ #[AutowiredService] -final class UsedEmailSendViewCollector implements Collector +class UsedEmailSendViewCollector implements Collector { /** * Get the node type the collector inspects. diff --git a/src/phpstan/src/Collectors/UsedEmailViewCollector.php b/src/phpstan/src/Collectors/UsedEmailViewCollector.php index deea4749a..9089a875c 100644 --- a/src/phpstan/src/Collectors/UsedEmailViewCollector.php +++ b/src/phpstan/src/Collectors/UsedEmailViewCollector.php @@ -22,7 +22,7 @@ /** @implements Collector */ #[AutowiredService] -final class UsedEmailViewCollector implements Collector +class UsedEmailViewCollector implements Collector { private const array VIEW_METHOD_NAMES = ['view', 'html', 'text', 'markdown']; diff --git a/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php b/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php index f4662672f..01597c99a 100644 --- a/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php +++ b/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php @@ -18,7 +18,7 @@ /** @implements Collector */ #[AutowiredService] -final class UsedRouteFacadeViewCollector implements Collector +class UsedRouteFacadeViewCollector implements Collector { /** * Get the node type the collector inspects. diff --git a/src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php b/src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php index 6aeb1e679..9c402c232 100644 --- a/src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php +++ b/src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php @@ -23,7 +23,7 @@ * @implements Collector */ #[AutowiredService] -final class UsedTranslationFacadeCollector implements Collector +class UsedTranslationFacadeCollector implements Collector { use CollectsTranslationLookups; diff --git a/src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php b/src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php index 4a5c16d0b..d03371163 100644 --- a/src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php +++ b/src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php @@ -22,7 +22,7 @@ * @implements Collector */ #[AutowiredService] -final class UsedTranslationFunctionCollector implements Collector +class UsedTranslationFunctionCollector implements Collector { use CollectsTranslationLookups; diff --git a/src/phpstan/src/Collectors/UsedTranslationTranslatorCollector.php b/src/phpstan/src/Collectors/UsedTranslationTranslatorCollector.php index fb25cc93d..a506cb330 100644 --- a/src/phpstan/src/Collectors/UsedTranslationTranslatorCollector.php +++ b/src/phpstan/src/Collectors/UsedTranslationTranslatorCollector.php @@ -22,7 +22,7 @@ * @implements Collector */ #[AutowiredService] -final class UsedTranslationTranslatorCollector implements Collector +class UsedTranslationTranslatorCollector implements Collector { use CollectsTranslationLookups; diff --git a/src/phpstan/src/Collectors/UsedTranslationViewCollector.php b/src/phpstan/src/Collectors/UsedTranslationViewCollector.php index 69e2a445b..0a6edc54d 100644 --- a/src/phpstan/src/Collectors/UsedTranslationViewCollector.php +++ b/src/phpstan/src/Collectors/UsedTranslationViewCollector.php @@ -45,7 +45,7 @@ * @phpstan-import-type TranslationLookup from CollectsTranslationLookups */ #[AutowiredService] -final class UsedTranslationViewCollector +class UsedTranslationViewCollector { use CollectsTranslationLookups; use HasContainer; diff --git a/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php b/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php index 9e9894072..29b9668cb 100644 --- a/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php +++ b/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php @@ -18,7 +18,7 @@ /** @implements Collector */ #[AutowiredService] -final class UsedViewFacadeMakeCollector implements Collector +class UsedViewFacadeMakeCollector implements Collector { /** * Get the node type the collector inspects. diff --git a/src/phpstan/src/Collectors/UsedViewFunctionCollector.php b/src/phpstan/src/Collectors/UsedViewFunctionCollector.php index 56958c71c..ec48ccf82 100644 --- a/src/phpstan/src/Collectors/UsedViewFunctionCollector.php +++ b/src/phpstan/src/Collectors/UsedViewFunctionCollector.php @@ -20,7 +20,7 @@ /** @implements Collector */ #[AutowiredService] -final class UsedViewFunctionCollector implements Collector +class UsedViewFunctionCollector implements Collector { /** * Create a new collector instance. diff --git a/src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php b/src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php index be2a27e36..6910976e0 100644 --- a/src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php +++ b/src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php @@ -17,7 +17,7 @@ use const PREG_SET_ORDER; #[AutowiredService] -final class UsedViewInAnotherViewCollector +class UsedViewInAnotherViewCollector { /** @see https://regex101.com/r/OyHHCY/1 */ private const string VIEW_NAME_REGEX = '/@(extends|include(If|Unless|When|First)?)(\(.*?([\'"])(.*?)([\'"])([),]))/m'; diff --git a/src/phpstan/src/Collectors/UsedViewMakeCollector.php b/src/phpstan/src/Collectors/UsedViewMakeCollector.php index 206d83ae7..06a5f423b 100644 --- a/src/phpstan/src/Collectors/UsedViewMakeCollector.php +++ b/src/phpstan/src/Collectors/UsedViewMakeCollector.php @@ -17,7 +17,7 @@ /** @implements Collector */ #[AutowiredService] -final class UsedViewMakeCollector implements Collector +class UsedViewMakeCollector implements Collector { /** * Get the node type the collector inspects. diff --git a/src/phpstan/src/HypervelStubFilesExtension.php b/src/phpstan/src/HypervelStubFilesExtension.php index 530ebca98..b83319e80 100644 --- a/src/phpstan/src/HypervelStubFilesExtension.php +++ b/src/phpstan/src/HypervelStubFilesExtension.php @@ -9,7 +9,7 @@ use Symfony\Component\Finder\Finder; #[AutowiredService] -final class HypervelStubFilesExtension implements StubFilesExtension +class HypervelStubFilesExtension implements StubFilesExtension { /** * Get the stub files to load. diff --git a/src/phpstan/src/Internal/ConfigHelper.php b/src/phpstan/src/Internal/ConfigHelper.php index 959c3603d..1e8981775 100644 --- a/src/phpstan/src/Internal/ConfigHelper.php +++ b/src/phpstan/src/Internal/ConfigHelper.php @@ -45,7 +45,7 @@ /** @internal */ #[AutowiredService] -final class ConfigHelper +class ConfigHelper { /** * The repository and Config facade methods whose return types come from config values. diff --git a/src/phpstan/src/Internal/ConsoleApplicationHelper.php b/src/phpstan/src/Internal/ConsoleApplicationHelper.php index 3554e33e8..6cfa2c622 100644 --- a/src/phpstan/src/Internal/ConsoleApplicationHelper.php +++ b/src/phpstan/src/Internal/ConsoleApplicationHelper.php @@ -22,7 +22,7 @@ use function count; #[AutowiredService] -final class ConsoleApplicationHelper +class ConsoleApplicationHelper { /** * Get the arguments array type of the given commands. diff --git a/src/phpstan/src/Internal/ConsoleApplicationResolver.php b/src/phpstan/src/Internal/ConsoleApplicationResolver.php index a33f1a66b..71162408e 100644 --- a/src/phpstan/src/Internal/ConsoleApplicationResolver.php +++ b/src/phpstan/src/Internal/ConsoleApplicationResolver.php @@ -23,7 +23,7 @@ /** @internal */ #[AutowiredService] -final class ConsoleApplicationResolver +class ConsoleApplicationResolver { use HasContainer; diff --git a/src/phpstan/src/Internal/RecursionGuard.php b/src/phpstan/src/Internal/RecursionGuard.php index 1e4d26bca..f1dfe54c1 100644 --- a/src/phpstan/src/Internal/RecursionGuard.php +++ b/src/phpstan/src/Internal/RecursionGuard.php @@ -12,7 +12,7 @@ * * @see https://github.com/phpstan/phpstan-src/blob/2.2.x/src/Type/RecursionGuard.php */ -final class RecursionGuard +class RecursionGuard { /** @var array */ private static array $context = []; diff --git a/src/phpstan/src/Methods/FacadesMethodsExtension.php b/src/phpstan/src/Methods/FacadesMethodsExtension.php index 9c491853f..bf3386efd 100644 --- a/src/phpstan/src/Methods/FacadesMethodsExtension.php +++ b/src/phpstan/src/Methods/FacadesMethodsExtension.php @@ -20,7 +20,7 @@ /** @internal */ #[AutowiredService] -final class FacadesMethodsExtension implements MethodsClassReflectionExtension +class FacadesMethodsExtension implements MethodsClassReflectionExtension { /** @var array */ private array $cache = []; diff --git a/src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php b/src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php index 3fa271061..bb6f46462 100644 --- a/src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php +++ b/src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php @@ -11,7 +11,7 @@ use PHPStan\Reflection\MethodsClassReflectionExtension; #[AutowiredService] -final class HigherOrderCollectionProxyExtension implements MethodsClassReflectionExtension +class HigherOrderCollectionProxyExtension implements MethodsClassReflectionExtension { /** * Create a new extension instance. diff --git a/src/phpstan/src/Methods/HigherOrderTapProxyExtension.php b/src/phpstan/src/Methods/HigherOrderTapProxyExtension.php index fe3f10578..ed614ed1d 100644 --- a/src/phpstan/src/Methods/HigherOrderTapProxyExtension.php +++ b/src/phpstan/src/Methods/HigherOrderTapProxyExtension.php @@ -14,7 +14,7 @@ use PHPStan\Type\Type; #[AutowiredService] -final class HigherOrderTapProxyExtension implements MethodsClassReflectionExtension +class HigherOrderTapProxyExtension implements MethodsClassReflectionExtension { /** * Determine whether the tapped object has the public method. diff --git a/src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php b/src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php index 0821c89bc..8abf5d8e4 100644 --- a/src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php +++ b/src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php @@ -11,7 +11,7 @@ use PHPStan\Reflection\MethodsClassReflectionExtension; #[AutowiredService] -final class HigherOrderWhenProxyExtension implements MethodsClassReflectionExtension +class HigherOrderWhenProxyExtension implements MethodsClassReflectionExtension { /** * Create a new extension instance. diff --git a/src/phpstan/src/Methods/Macro.php b/src/phpstan/src/Methods/Macro.php index e220c7cdb..4a0c85dd5 100644 --- a/src/phpstan/src/Methods/Macro.php +++ b/src/phpstan/src/Methods/Macro.php @@ -19,7 +19,7 @@ use function array_key_exists; -final class Macro implements MethodReflection +class Macro implements MethodReflection { /** * Whether the macro is called statically. diff --git a/src/phpstan/src/Methods/ManagersMethodsExtension.php b/src/phpstan/src/Methods/ManagersMethodsExtension.php index bafbefbda..6470ab6de 100644 --- a/src/phpstan/src/Methods/ManagersMethodsExtension.php +++ b/src/phpstan/src/Methods/ManagersMethodsExtension.php @@ -33,7 +33,7 @@ * @internal */ #[AutowiredService] -final class ManagersMethodsExtension implements MethodsClassReflectionExtension +class ManagersMethodsExtension implements MethodsClassReflectionExtension { use HasContainer; diff --git a/src/phpstan/src/Methods/OptionalExtension.php b/src/phpstan/src/Methods/OptionalExtension.php index 64283941f..0cc6f3b9b 100644 --- a/src/phpstan/src/Methods/OptionalExtension.php +++ b/src/phpstan/src/Methods/OptionalExtension.php @@ -22,7 +22,7 @@ * Optional may hold something else, and only null when it never holds an object. */ #[AutowiredService] -final class OptionalExtension implements MethodsClassReflectionExtension +class OptionalExtension implements MethodsClassReflectionExtension { /** * Determine whether the object the Optional holds has the public method. diff --git a/src/phpstan/src/Methods/RedisCommandMethodsExtension.php b/src/phpstan/src/Methods/RedisCommandMethodsExtension.php index a5a7b38a8..b13982a9b 100644 --- a/src/phpstan/src/Methods/RedisCommandMethodsExtension.php +++ b/src/phpstan/src/Methods/RedisCommandMethodsExtension.php @@ -27,7 +27,7 @@ * as macros, keep their exact case. The proxy and manager reach this through their mixins. */ #[AutowiredService] -final class RedisCommandMethodsExtension implements MethodsClassReflectionExtension +class RedisCommandMethodsExtension implements MethodsClassReflectionExtension { /** * RedisConnection's command tag names, keyed by their lowercase form. diff --git a/src/phpstan/src/Properties/DirectoryResolver.php b/src/phpstan/src/Properties/DirectoryResolver.php index 4295b37e9..725294856 100644 --- a/src/phpstan/src/Properties/DirectoryResolver.php +++ b/src/phpstan/src/Properties/DirectoryResolver.php @@ -19,7 +19,7 @@ * * @internal */ -final class DirectoryResolver +class DirectoryResolver { /** * Resolve configured paths to directories. diff --git a/src/phpstan/src/Properties/HigherOrderCollectionProxyPropertyExtension.php b/src/phpstan/src/Properties/HigherOrderCollectionProxyPropertyExtension.php index 553e7db1c..477e12ea3 100644 --- a/src/phpstan/src/Properties/HigherOrderCollectionProxyPropertyExtension.php +++ b/src/phpstan/src/Properties/HigherOrderCollectionProxyPropertyExtension.php @@ -11,7 +11,7 @@ use PHPStan\Reflection\PropertyReflection; #[AutowiredService] -final class HigherOrderCollectionProxyPropertyExtension implements PropertiesClassReflectionExtension +class HigherOrderCollectionProxyPropertyExtension implements PropertiesClassReflectionExtension { /** * Create a new property extension instance. diff --git a/src/phpstan/src/Properties/HigherOrderWhenProxyPropertyExtension.php b/src/phpstan/src/Properties/HigherOrderWhenProxyPropertyExtension.php index 5ab1f3fc5..8c300c274 100644 --- a/src/phpstan/src/Properties/HigherOrderWhenProxyPropertyExtension.php +++ b/src/phpstan/src/Properties/HigherOrderWhenProxyPropertyExtension.php @@ -11,7 +11,7 @@ use PHPStan\Reflection\PropertyReflection; #[AutowiredService] -final class HigherOrderWhenProxyPropertyExtension implements PropertiesClassReflectionExtension +class HigherOrderWhenProxyPropertyExtension implements PropertiesClassReflectionExtension { /** * Create a new property extension instance. diff --git a/src/phpstan/src/Properties/MigrationCache.php b/src/phpstan/src/Properties/MigrationCache.php index 3ed687dc0..4eb4c9239 100644 --- a/src/phpstan/src/Properties/MigrationCache.php +++ b/src/phpstan/src/Properties/MigrationCache.php @@ -40,7 +40,7 @@ use const PHP_VERSION; #[AutowiredService] -final class MigrationCache +class MigrationCache { private const string CACHE_PREFIX = 'hypervel_migrations_'; diff --git a/src/phpstan/src/Properties/ModelAccessorExtension.php b/src/phpstan/src/Properties/ModelAccessorExtension.php index 70f095cf2..8bbe0b38f 100644 --- a/src/phpstan/src/Properties/ModelAccessorExtension.php +++ b/src/phpstan/src/Properties/ModelAccessorExtension.php @@ -11,7 +11,7 @@ /** @internal */ #[AutowiredService] -final class ModelAccessorExtension implements PropertiesClassReflectionExtension +class ModelAccessorExtension implements PropertiesClassReflectionExtension { /** * Create a new model accessor extension instance. diff --git a/src/phpstan/src/Properties/ModelPropertyExtension.php b/src/phpstan/src/Properties/ModelPropertyExtension.php index f4e5b0e6a..6a8bb0f13 100644 --- a/src/phpstan/src/Properties/ModelPropertyExtension.php +++ b/src/phpstan/src/Properties/ModelPropertyExtension.php @@ -11,7 +11,7 @@ /** @internal */ #[AutowiredService] -final class ModelPropertyExtension implements PropertiesClassReflectionExtension +class ModelPropertyExtension implements PropertiesClassReflectionExtension { /** * Create a new model property extension instance. diff --git a/src/phpstan/src/Properties/ModelRelationsExtension.php b/src/phpstan/src/Properties/ModelRelationsExtension.php index 946a9aa1c..d5595ba52 100644 --- a/src/phpstan/src/Properties/ModelRelationsExtension.php +++ b/src/phpstan/src/Properties/ModelRelationsExtension.php @@ -28,7 +28,7 @@ /** @internal */ #[AutowiredService] -final class ModelRelationsExtension implements PropertiesClassReflectionExtension +class ModelRelationsExtension implements PropertiesClassReflectionExtension { /** * Create a new model relations extension instance. diff --git a/src/phpstan/src/Properties/SchemaAggregator.php b/src/phpstan/src/Properties/SchemaAggregator.php index 3c30f20b2..a0c492530 100644 --- a/src/phpstan/src/Properties/SchemaAggregator.php +++ b/src/phpstan/src/Properties/SchemaAggregator.php @@ -60,7 +60,7 @@ * * @see https://github.com/psalm/laravel-psalm-plugin/blob/master/src/SchemaAggregator.php */ -final class SchemaAggregator +class SchemaAggregator { /** Schema builder methods that change no relations or column types. withoutForeignKeyConstraints() runs a callback that is applied on its own. */ private const array IGNORED_SCHEMA_METHODS = [ diff --git a/src/phpstan/src/Properties/SchemaColumn.php b/src/phpstan/src/Properties/SchemaColumn.php index ea0205616..cbbddfaaa 100644 --- a/src/phpstan/src/Properties/SchemaColumn.php +++ b/src/phpstan/src/Properties/SchemaColumn.php @@ -7,7 +7,7 @@ use Hypervel\SchemaParser\ColumnType; /** @see https://github.com/psalm/laravel-psalm-plugin/blob/master/src/SchemaColumn.php */ -final class SchemaColumn +class SchemaColumn { /** * Create a new schema column instance. diff --git a/src/phpstan/src/Properties/SchemaTable.php b/src/phpstan/src/Properties/SchemaTable.php index f0625a61f..0b0b4e42e 100644 --- a/src/phpstan/src/Properties/SchemaTable.php +++ b/src/phpstan/src/Properties/SchemaTable.php @@ -5,7 +5,7 @@ namespace Hypervel\PHPStan\Properties; /** @see https://github.com/psalm/laravel-psalm-plugin/blob/master/src/SchemaTable.php */ -final class SchemaTable +class SchemaTable { /** @var array */ public array $columns = []; diff --git a/src/phpstan/src/Properties/SquashedMigrationHelper.php b/src/phpstan/src/Properties/SquashedMigrationHelper.php index cd3fb2905..c57ffd123 100644 --- a/src/phpstan/src/Properties/SquashedMigrationHelper.php +++ b/src/phpstan/src/Properties/SquashedMigrationHelper.php @@ -20,7 +20,7 @@ * Reads the schema dumps that schema:dump writes for the booted connections. */ #[AutowiredService] -final class SquashedMigrationHelper +class SquashedMigrationHelper { /** * Create a new squashed migration helper instance. diff --git a/src/phpstan/src/Reflection/DynamicWhereMethodReflection.php b/src/phpstan/src/Reflection/DynamicWhereMethodReflection.php index a2a230fad..8e37c449d 100644 --- a/src/phpstan/src/Reflection/DynamicWhereMethodReflection.php +++ b/src/phpstan/src/Reflection/DynamicWhereMethodReflection.php @@ -16,7 +16,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; -final class DynamicWhereMethodReflection implements MethodReflection +class DynamicWhereMethodReflection implements MethodReflection { /** * Create a new method reflection instance. diff --git a/src/phpstan/src/Reflection/ProxiedMethodReflection.php b/src/phpstan/src/Reflection/ProxiedMethodReflection.php index 7083f0d88..f7964a38b 100644 --- a/src/phpstan/src/Reflection/ProxiedMethodReflection.php +++ b/src/phpstan/src/Reflection/ProxiedMethodReflection.php @@ -21,7 +21,7 @@ /** * Describe a method that a proxy passes to its target, returning what the proxy returns. */ -final class ProxiedMethodReflection implements MethodReflection +class ProxiedMethodReflection implements MethodReflection { /** * Create a proxied method reflection. diff --git a/src/phpstan/src/Reflection/ProxiedParameterReflection.php b/src/phpstan/src/Reflection/ProxiedParameterReflection.php index 0ebab4859..80419ef93 100644 --- a/src/phpstan/src/Reflection/ProxiedParameterReflection.php +++ b/src/phpstan/src/Reflection/ProxiedParameterReflection.php @@ -14,7 +14,7 @@ * Describe a target method's by-reference parameter as a proxy receives it. A proxy's __call() * gets the argument values, so the caller's variable is never passed by reference. */ -final class ProxiedParameterReflection implements ParameterReflection +class ProxiedParameterReflection implements ParameterReflection { /** * Create a proxied parameter reflection. diff --git a/src/phpstan/src/Reflection/ProxiedPropertyReflection.php b/src/phpstan/src/Reflection/ProxiedPropertyReflection.php index 04631921f..8945c86fe 100644 --- a/src/phpstan/src/Reflection/ProxiedPropertyReflection.php +++ b/src/phpstan/src/Reflection/ProxiedPropertyReflection.php @@ -13,7 +13,7 @@ * Describe a read-only property that a proxy reads from its target, typed as what the proxy * returns, with the target property's deprecation. */ -final class ProxiedPropertyReflection implements PropertyReflection +class ProxiedPropertyReflection implements PropertyReflection { /** * The type the proxy returns. diff --git a/src/phpstan/src/Reflection/ReflectionHelper.php b/src/phpstan/src/Reflection/ReflectionHelper.php index 2453432cb..88823f838 100644 --- a/src/phpstan/src/Reflection/ReflectionHelper.php +++ b/src/phpstan/src/Reflection/ReflectionHelper.php @@ -10,7 +10,7 @@ use function array_key_exists; -final class ReflectionHelper +class ReflectionHelper { /** @var array */ private static array $propertyTagCache = []; diff --git a/src/phpstan/src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php index 6d08b76f0..0cb72b973 100644 --- a/src/phpstan/src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php @@ -15,7 +15,7 @@ use function in_array; #[AutowiredService] -final class AppMakeDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension +class AppMakeDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { /** * Create a new extension instance. diff --git a/src/phpstan/src/ReturnTypes/AppMakeHelper.php b/src/phpstan/src/ReturnTypes/AppMakeHelper.php index 3a61a4c95..5b885c610 100644 --- a/src/phpstan/src/ReturnTypes/AppMakeHelper.php +++ b/src/phpstan/src/ReturnTypes/AppMakeHelper.php @@ -23,7 +23,7 @@ use function strcasecmp; #[AutowiredService] -final class AppMakeHelper +class AppMakeHelper { use HasContainer; diff --git a/src/phpstan/src/ReturnTypes/ArrGetDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ArrGetDynamicStaticMethodReturnTypeExtension.php index af64bdc25..f8ab40449 100644 --- a/src/phpstan/src/ReturnTypes/ArrGetDynamicStaticMethodReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/ArrGetDynamicStaticMethodReturnTypeExtension.php @@ -23,7 +23,7 @@ /** @internal */ #[AutowiredService] -final class ArrGetDynamicStaticMethodReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension +class ArrGetDynamicStaticMethodReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { /** * Get the class the extension applies to. diff --git a/src/phpstan/src/ReturnTypes/AuthExtension.php b/src/phpstan/src/ReturnTypes/AuthExtension.php index 3d106d850..aae395ffd 100644 --- a/src/phpstan/src/ReturnTypes/AuthExtension.php +++ b/src/phpstan/src/ReturnTypes/AuthExtension.php @@ -21,7 +21,7 @@ /** @internal */ #[AutowiredService] -final class AuthExtension implements DynamicStaticMethodReturnTypeExtension +class AuthExtension implements DynamicStaticMethodReturnTypeExtension { use Concerns\HasContainer; use Concerns\LoadsAuthModel; diff --git a/src/phpstan/src/ReturnTypes/AuthManagerExtension.php b/src/phpstan/src/ReturnTypes/AuthManagerExtension.php index 1a1467594..cbe3ed7ba 100644 --- a/src/phpstan/src/ReturnTypes/AuthManagerExtension.php +++ b/src/phpstan/src/ReturnTypes/AuthManagerExtension.php @@ -20,7 +20,7 @@ use function count; #[AutowiredService] -final class AuthManagerExtension implements DynamicMethodReturnTypeExtension +class AuthManagerExtension implements DynamicMethodReturnTypeExtension { use Concerns\HasContainer; use Concerns\LoadsAuthModel; diff --git a/src/phpstan/src/ReturnTypes/ConditionableExpressionTypeResolverExtension.php b/src/phpstan/src/ReturnTypes/ConditionableExpressionTypeResolverExtension.php index b44e80afd..9a361b18f 100644 --- a/src/phpstan/src/ReturnTypes/ConditionableExpressionTypeResolverExtension.php +++ b/src/phpstan/src/ReturnTypes/ConditionableExpressionTypeResolverExtension.php @@ -38,7 +38,7 @@ * @internal */ #[AutowiredService] -final class ConditionableExpressionTypeResolverExtension implements ExpressionTypeResolverExtension +class ConditionableExpressionTypeResolverExtension implements ExpressionTypeResolverExtension { /** * The parameters of when() and unless(), in order. diff --git a/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php index bb99ad766..c0f10730e 100644 --- a/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php @@ -20,7 +20,7 @@ * is omitted because the container does not support ArrayAccess. */ #[AutowiredService] -final class ContainerMakeDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension +class ContainerMakeDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { /** * Create a new extension instance. diff --git a/src/phpstan/src/ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php index 262b9d765..7dfd4f944 100644 --- a/src/phpstan/src/ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php @@ -17,7 +17,7 @@ /** @internal */ #[AutowiredService] -final class DoubleUnderscoreHelperReturnTypeExtension implements DynamicFunctionReturnTypeExtension +class DoubleUnderscoreHelperReturnTypeExtension implements DynamicFunctionReturnTypeExtension { /** * Determine whether the function is the __() helper. diff --git a/src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php b/src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php index 5b64c710a..bf4b96a6e 100644 --- a/src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php +++ b/src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php @@ -32,7 +32,7 @@ * types describe the disk, because building a disk can need credentials or I/O. */ #[AutowiredService] -final class FilesystemDiskHelper +class FilesystemDiskHelper { use HasContainer; diff --git a/src/phpstan/src/ReturnTypes/FilesystemManagerDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/FilesystemManagerDynamicMethodReturnTypeExtension.php index 5ca6c90f6..fd45d5aed 100644 --- a/src/phpstan/src/ReturnTypes/FilesystemManagerDynamicMethodReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/FilesystemManagerDynamicMethodReturnTypeExtension.php @@ -19,7 +19,7 @@ * can resolve disks in its own way. */ #[AutowiredService] -final class FilesystemManagerDynamicMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension +class FilesystemManagerDynamicMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension { /** * Create a new extension instance. diff --git a/src/phpstan/src/ReturnTypes/GuardExtension.php b/src/phpstan/src/ReturnTypes/GuardExtension.php index bd9ed2ae0..aa71681d8 100644 --- a/src/phpstan/src/ReturnTypes/GuardExtension.php +++ b/src/phpstan/src/ReturnTypes/GuardExtension.php @@ -31,7 +31,7 @@ use function strtolower; #[AutowiredService] -final class GuardExtension implements DynamicMethodReturnTypeExtension +class GuardExtension implements DynamicMethodReturnTypeExtension { use Concerns\HasContainer; use Concerns\LoadsAuthModel; diff --git a/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php b/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php index 87716993c..7656ab4fb 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php @@ -20,7 +20,7 @@ /** @internal */ #[AutowiredService] -final class AuthExtension implements DynamicFunctionReturnTypeExtension +class AuthExtension implements DynamicFunctionReturnTypeExtension { use Concerns\HasContainer; diff --git a/src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php b/src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php index 7485c52d6..da446bbdb 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php @@ -22,7 +22,7 @@ use function count; #[AutowiredService] -final class CollectExtension implements DynamicFunctionReturnTypeExtension +class CollectExtension implements DynamicFunctionReturnTypeExtension { private ?Type $emptyCollectionType = null; diff --git a/src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php b/src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php index e12c153b2..d4222c063 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php @@ -20,7 +20,7 @@ * @internal */ #[AutowiredService] -final class LoggerExtension implements DynamicFunctionReturnTypeExtension +class LoggerExtension implements DynamicFunctionReturnTypeExtension { use InfersBoundHelperCalls; diff --git a/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php index 436c553ab..8c9f41df3 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php @@ -18,7 +18,7 @@ /** @internal */ #[AutowiredService] -final class ResponseExtension implements DynamicFunctionReturnTypeExtension +class ResponseExtension implements DynamicFunctionReturnTypeExtension { use InfersFactoryHelperCalls; diff --git a/src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php b/src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php index 9107bea6e..f6f463a93 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php @@ -21,7 +21,7 @@ * @internal */ #[AutowiredService] -final class UrlExtension implements DynamicFunctionReturnTypeExtension +class UrlExtension implements DynamicFunctionReturnTypeExtension { use InfersBoundHelperCalls; diff --git a/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php index 182e00976..e14a21e70 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php @@ -18,7 +18,7 @@ /** @internal */ #[AutowiredService] -final class ValidatorExtension implements DynamicFunctionReturnTypeExtension +class ValidatorExtension implements DynamicFunctionReturnTypeExtension { use InfersFactoryHelperCalls; diff --git a/src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php index 1d5299097..f1b3d9329 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php @@ -21,7 +21,7 @@ /** @internal */ #[AutowiredService] -final class ValueExtension implements DynamicFunctionReturnTypeExtension +class ValueExtension implements DynamicFunctionReturnTypeExtension { /** * Determine whether the function is value(). diff --git a/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php index c0d085673..d4367010c 100644 --- a/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php +++ b/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php @@ -23,7 +23,7 @@ * @internal */ #[AutowiredService] -final class ViewExtension implements DynamicFunctionReturnTypeExtension +class ViewExtension implements DynamicFunctionReturnTypeExtension { use InfersFactoryHelperCalls; diff --git a/src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php b/src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php index f70727a07..cdc8c195a 100644 --- a/src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php +++ b/src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php @@ -15,7 +15,7 @@ /** @internal */ #[AutowiredService] -final class HigherOrderTapProxyExtension implements DynamicMethodReturnTypeExtension +class HigherOrderTapProxyExtension implements DynamicMethodReturnTypeExtension { /** * Get the class the extension applies to. diff --git a/src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php index 162282f21..7a6ae062a 100644 --- a/src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php @@ -20,7 +20,7 @@ /** @internal */ #[AutowiredService] -final class LangGetReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension +class LangGetReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { /** * Get the class whose static calls are typed. diff --git a/src/phpstan/src/ReturnTypes/ModelSerializationDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ModelSerializationDynamicMethodReturnTypeExtension.php index 4d0929d7b..beed31863 100644 --- a/src/phpstan/src/ReturnTypes/ModelSerializationDynamicMethodReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/ModelSerializationDynamicMethodReturnTypeExtension.php @@ -51,7 +51,7 @@ * Types the arrays a model serializes to from its database attributes, appends and visibility. */ #[AutowiredService] -final class ModelSerializationDynamicMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension +class ModelSerializationDynamicMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension { /** * Create a new model serialization return type extension instance. diff --git a/src/phpstan/src/ReturnTypes/RequestUserExtension.php b/src/phpstan/src/ReturnTypes/RequestUserExtension.php index 9dfbe27b8..3fac83a0d 100644 --- a/src/phpstan/src/ReturnTypes/RequestUserExtension.php +++ b/src/phpstan/src/ReturnTypes/RequestUserExtension.php @@ -21,7 +21,7 @@ /** @internal */ #[AutowiredService] -final class RequestUserExtension implements DynamicMethodReturnTypeExtension +class RequestUserExtension implements DynamicMethodReturnTypeExtension { use HasContainer; use LoadsAuthModel; diff --git a/src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php index d16815b06..c87f751c6 100644 --- a/src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php @@ -17,7 +17,7 @@ /** @internal */ #[AutowiredService] -final class TransHelperReturnTypeExtension implements DynamicFunctionReturnTypeExtension +class TransHelperReturnTypeExtension implements DynamicFunctionReturnTypeExtension { /** * Determine whether the function is the trans() helper. diff --git a/src/phpstan/src/ReturnTypes/TranslatorGetReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/TranslatorGetReturnTypeExtension.php index 4e7e18917..a236197f2 100644 --- a/src/phpstan/src/ReturnTypes/TranslatorGetReturnTypeExtension.php +++ b/src/phpstan/src/ReturnTypes/TranslatorGetReturnTypeExtension.php @@ -20,7 +20,7 @@ /** @internal */ #[AutowiredService] -final class TranslatorGetReturnTypeExtension implements DynamicMethodReturnTypeExtension +class TranslatorGetReturnTypeExtension implements DynamicMethodReturnTypeExtension { /** * Get the class whose method calls are typed. diff --git a/src/phpstan/src/Rules/ArrKeyExistenceRule.php b/src/phpstan/src/Rules/ArrKeyExistenceRule.php index 6f207569d..ee1457d59 100644 --- a/src/phpstan/src/Rules/ArrKeyExistenceRule.php +++ b/src/phpstan/src/Rules/ArrKeyExistenceRule.php @@ -26,7 +26,7 @@ * @implements Rule */ #[AutowiredService] -final class ArrKeyExistenceRule implements Rule +class ArrKeyExistenceRule implements Rule { /** * Get the node type the rule checks. diff --git a/src/phpstan/src/Rules/ConfigCollectionRule.php b/src/phpstan/src/Rules/ConfigCollectionRule.php index 33e7a6f1b..d9a5e8308 100644 --- a/src/phpstan/src/Rules/ConfigCollectionRule.php +++ b/src/phpstan/src/Rules/ConfigCollectionRule.php @@ -25,7 +25,7 @@ * @implements Rule */ #[Conditional(attribute: new AutowiredService, if: '%checkConfigTypes%')] -final class ConfigCollectionRule implements Rule +class ConfigCollectionRule implements Rule { /** * Create a new rule instance. diff --git a/src/phpstan/src/Rules/ConfigTypeRule.php b/src/phpstan/src/Rules/ConfigTypeRule.php index c590918f6..60891c8b0 100644 --- a/src/phpstan/src/Rules/ConfigTypeRule.php +++ b/src/phpstan/src/Rules/ConfigTypeRule.php @@ -25,7 +25,7 @@ * @implements Rule */ #[Conditional(attribute: new AutowiredService, if: '%checkConfigTypes%')] -final class ConfigTypeRule implements Rule +class ConfigTypeRule implements Rule { /** * The value types the typed getters require, as the error message names them. diff --git a/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php b/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php index 578772cf7..aa5ddfb1e 100644 --- a/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php +++ b/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php @@ -23,7 +23,7 @@ /** @implements Rule */ #[AutowiredService] -final class UndefinedArgumentOrOptionRule implements Rule +class UndefinedArgumentOrOptionRule implements Rule { /** * Create a new rule instance. diff --git a/src/phpstan/src/Rules/NoMissingTranslationsRule.php b/src/phpstan/src/Rules/NoMissingTranslationsRule.php index 3e88f8785..8b6b0e8a1 100644 --- a/src/phpstan/src/Rules/NoMissingTranslationsRule.php +++ b/src/phpstan/src/Rules/NoMissingTranslationsRule.php @@ -42,7 +42,7 @@ * @implements Rule */ #[Conditional(attribute: new AutowiredService, if: '%checkMissingTranslations%')] -final class NoMissingTranslationsRule implements Rule +class NoMissingTranslationsRule implements Rule { use HasContainer; diff --git a/src/phpstan/src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php b/src/phpstan/src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php index 56aea8512..1253d807e 100644 --- a/src/phpstan/src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php +++ b/src/phpstan/src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php @@ -32,7 +32,7 @@ * @implements Rule */ #[Conditional(attribute: new AutowiredService, if: '%noUnnecessaryEnumerableToArrayCalls%')] -final class NoUnnecessaryEnumerableToArrayCallsRule implements Rule +class NoUnnecessaryEnumerableToArrayCallsRule implements Rule { /** * Get the node type the rule checks. diff --git a/src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php b/src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php index b487a8e12..8e1a89395 100644 --- a/src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php +++ b/src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php @@ -33,7 +33,7 @@ * @implements Rule */ #[Conditional(attribute: new AutowiredService, if: '%checkRedisConnectionBoundMethods%')] -final class RedisConnectionBoundMethodRule implements Rule +class RedisConnectionBoundMethodRule implements Rule { /** * The lowercase method names the proxy rejects, as keys. diff --git a/src/phpstan/src/Rules/UnusedViewsRule.php b/src/phpstan/src/Rules/UnusedViewsRule.php index 114ad2bac..6693d9afb 100644 --- a/src/phpstan/src/Rules/UnusedViewsRule.php +++ b/src/phpstan/src/Rules/UnusedViewsRule.php @@ -33,7 +33,7 @@ /** @implements Rule */ #[Conditional(attribute: new AutowiredService, if: '%checkUnusedViews%')] -final class UnusedViewsRule implements Rule +class UnusedViewsRule implements Rule { /** @var null|list */ private ?array $viewsUsedInOtherViews = null; diff --git a/src/phpstan/src/Support/ArrKeyLookup.php b/src/phpstan/src/Support/ArrKeyLookup.php index b8bf2f618..b214bf4e8 100644 --- a/src/phpstan/src/Support/ArrKeyLookup.php +++ b/src/phpstan/src/Support/ArrKeyLookup.php @@ -25,7 +25,7 @@ * * @internal */ -final class ArrKeyLookup +class ArrKeyLookup { /** * The methods Arr::get() runs, which pull() also reads keys through. diff --git a/src/phpstan/src/Support/BootstrapErrorHandler.php b/src/phpstan/src/Support/BootstrapErrorHandler.php index 2059022f8..a0e9f15c6 100644 --- a/src/phpstan/src/Support/BootstrapErrorHandler.php +++ b/src/phpstan/src/Support/BootstrapErrorHandler.php @@ -34,7 +34,7 @@ use const ENT_QUOTES; use const PHP_EOL; -final class BootstrapErrorHandler +class BootstrapErrorHandler { private const string TITLE = 'Application bootstrap failed'; diff --git a/src/phpstan/src/Support/CollectionHelper.php b/src/phpstan/src/Support/CollectionHelper.php index b6f1f3c47..0394495e7 100644 --- a/src/phpstan/src/Support/CollectionHelper.php +++ b/src/phpstan/src/Support/CollectionHelper.php @@ -37,7 +37,7 @@ use function ltrim; #[AutowiredService] -final class CollectionHelper +class CollectionHelper { /** * The framework collections, whose own method implementations the extensions and rules rely on. diff --git a/src/phpstan/src/Support/ConfigArray.php b/src/phpstan/src/Support/ConfigArray.php index aeda38a0d..8bd3fc726 100644 --- a/src/phpstan/src/Support/ConfigArray.php +++ b/src/phpstan/src/Support/ConfigArray.php @@ -14,7 +14,7 @@ * * @internal */ -final readonly class ConfigArray +readonly class ConfigArray { /** * Create a new config array instance. diff --git a/src/phpstan/src/Support/ConfigParser.php b/src/phpstan/src/Support/ConfigParser.php index 84697106b..14f9245b0 100644 --- a/src/phpstan/src/Support/ConfigParser.php +++ b/src/phpstan/src/Support/ConfigParser.php @@ -81,7 +81,7 @@ * holding its default. */ #[AutowiredService] -final class ConfigParser +class ConfigParser { /** * Config file paths keyed like Hypervel's config keys: `nested/stripe.php` diff --git a/src/phpstan/src/Support/ConfigSourceResolver.php b/src/phpstan/src/Support/ConfigSourceResolver.php index 348d1c76f..a13027e15 100644 --- a/src/phpstan/src/Support/ConfigSourceResolver.php +++ b/src/phpstan/src/Support/ConfigSourceResolver.php @@ -100,7 +100,7 @@ * @internal */ #[AutowiredService] -final class ConfigSourceResolver +class ConfigSourceResolver { use HasContainer; diff --git a/src/phpstan/src/Support/HigherOrderWhenProxyHelper.php b/src/phpstan/src/Support/HigherOrderWhenProxyHelper.php index 7580c3af9..318c86e9c 100644 --- a/src/phpstan/src/Support/HigherOrderWhenProxyHelper.php +++ b/src/phpstan/src/Support/HigherOrderWhenProxyHelper.php @@ -24,7 +24,7 @@ * returns the member's value when the condition holds, and the target otherwise. */ #[AutowiredService] -final class HigherOrderWhenProxyHelper +class HigherOrderWhenProxyHelper { /** * Determine whether the proxy's target has the method or property. diff --git a/src/phpstan/src/Support/RecursiveDirectoryIterator.php b/src/phpstan/src/Support/RecursiveDirectoryIterator.php index f0c74e12a..cb6c9b0e2 100644 --- a/src/phpstan/src/Support/RecursiveDirectoryIterator.php +++ b/src/phpstan/src/Support/RecursiveDirectoryIterator.php @@ -27,7 +27,7 @@ * This is the approach symfony/finder takes for non-rewindable streams — * see Symfony\Component\Finder\Iterator\RecursiveDirectoryIterator. */ -final class RecursiveDirectoryIterator extends SplRecursiveDirectoryIterator +class RecursiveDirectoryIterator extends SplRecursiveDirectoryIterator { private bool $ignoreFirstRewind = true; diff --git a/src/phpstan/src/Support/ViewFileHelper.php b/src/phpstan/src/Support/ViewFileHelper.php index 764850c36..53be97565 100644 --- a/src/phpstan/src/Support/ViewFileHelper.php +++ b/src/phpstan/src/Support/ViewFileHelper.php @@ -31,7 +31,7 @@ use const DIRECTORY_SEPARATOR; #[AutowiredService] -final class ViewFileHelper +class ViewFileHelper { use HasContainer; diff --git a/src/phpstan/src/Support/ViewParser.php b/src/phpstan/src/Support/ViewParser.php index cc3dfcd49..dafb88dd9 100644 --- a/src/phpstan/src/Support/ViewParser.php +++ b/src/phpstan/src/Support/ViewParser.php @@ -13,7 +13,7 @@ use function array_key_exists; #[AutowiredService] -final class ViewParser +class ViewParser { /** @var array */ private array $nodes = []; diff --git a/src/phpstan/src/Types/AbortIfFunctionTypeSpecifyingExtension.php b/src/phpstan/src/Types/AbortIfFunctionTypeSpecifyingExtension.php index b310e8d29..bb3a0d938 100644 --- a/src/phpstan/src/Types/AbortIfFunctionTypeSpecifyingExtension.php +++ b/src/phpstan/src/Types/AbortIfFunctionTypeSpecifyingExtension.php @@ -16,7 +16,7 @@ use function count; use function str_starts_with; -final class AbortIfFunctionTypeSpecifyingExtension implements FunctionTypeSpecifyingExtension, TypeSpecifierAwareExtension +class AbortIfFunctionTypeSpecifyingExtension implements FunctionTypeSpecifyingExtension, TypeSpecifierAwareExtension { private TypeSpecifier $typeSpecifier; diff --git a/src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php b/src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php index e9ac88e9e..e713f44c5 100644 --- a/src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php +++ b/src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php @@ -24,7 +24,7 @@ * @internal */ #[AutowiredService] -final class ArrPullParameterOutTypeExtension implements StaticMethodParameterOutTypeExtension +class ArrPullParameterOutTypeExtension implements StaticMethodParameterOutTypeExtension { /** * Determine whether the parameter is the array of Arr::pull(). diff --git a/src/phpstan/src/Types/ModelProperty/ModelPropertyTypeNodeResolverExtension.php b/src/phpstan/src/Types/ModelProperty/ModelPropertyTypeNodeResolverExtension.php index fd6caa6f9..8924d48a8 100644 --- a/src/phpstan/src/Types/ModelProperty/ModelPropertyTypeNodeResolverExtension.php +++ b/src/phpstan/src/Types/ModelProperty/ModelPropertyTypeNodeResolverExtension.php @@ -26,7 +26,7 @@ * Ensures a 'model-property' type in PHPDoc is recognised to be of type ModelPropertyType. */ #[AutowiredService] -final class ModelPropertyTypeNodeResolverExtension implements TypeNodeResolverExtension +class ModelPropertyTypeNodeResolverExtension implements TypeNodeResolverExtension { /** * Create a new model property type node resolver extension instance. diff --git a/tests/PHPStan/Unit/Support/BrokenRewindDirectoryStream.php b/tests/PHPStan/Unit/Support/BrokenRewindDirectoryStream.php index 3954049f3..ebde98776 100644 --- a/tests/PHPStan/Unit/Support/BrokenRewindDirectoryStream.php +++ b/tests/PHPStan/Unit/Support/BrokenRewindDirectoryStream.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\PHPStan\Unit\Support; -final class BrokenRewindDirectoryStream +class BrokenRewindDirectoryStream { public const string SCHEME = 'hypervel-broken-directory'; diff --git a/types/Support/Traits.php b/types/Support/Traits.php index f61f8c01f..d186ee6b8 100644 --- a/types/Support/Traits.php +++ b/types/Support/Traits.php @@ -27,7 +27,7 @@ public function useWithLocale(): void assertType('1|ConditionableTarget', $target->unless($condition, fn () => 1)); }; -final class ConditionableTarget +class ConditionableTarget { use Conditionable; } From dd2a748cc35d85689882839851c65ec352b4d60a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:31:29 +0000 Subject: [PATCH 110/303] Keep new schema impact value classes readonly without forbidding inheritance Remove final from Impact and TypeReference while preserving readonly properties and their constructor contracts. Neither value carrier nor its parser consumers requires a closed inheritance hierarchy. The pre-existing parser classes are unchanged. Validation: exact one-keyword changes, formatter clean and full PHPStan analysis clean; no runtime implementation changed. --- src/schema-parser/src/Impact.php | 2 +- src/schema-parser/src/TypeReference.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/schema-parser/src/Impact.php b/src/schema-parser/src/Impact.php index bcaca732d..b6b957dfd 100644 --- a/src/schema-parser/src/Impact.php +++ b/src/schema-parser/src/Impact.php @@ -13,7 +13,7 @@ * longer exist or keep their columns, and which user types may no longer exist * or keep their definitions. It does not list index or comment changes. */ -final readonly class Impact +readonly class Impact { /** * Create the impact of one input. diff --git a/src/schema-parser/src/TypeReference.php b/src/schema-parser/src/TypeReference.php index e54190c44..463da7bf9 100644 --- a/src/schema-parser/src/TypeReference.php +++ b/src/schema-parser/src/TypeReference.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class TypeReference +readonly class TypeReference { /** * Create a PostgreSQL user type or domain reference without assuming an unspecified schema. From a34ccb69ea77d7900dd0cf40b9b2d5d09a3038c4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:31:29 +0000 Subject: [PATCH 111/303] Record completion of the separate final-keyword cleanup checkpoint Mark the reviewed cleanup complete after the preceding model schema checkpoint, as requested. Preserve the remaining port checklist and the required shared cache provenance, warm reuse and efficiency work as the next implementation scope. --- docs/plans/2026-11-04-hypervel-phpstan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 4ac9cea7f..65f741d6b 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -23,8 +23,8 @@ Remaining work: - Also completed: the macro and forwarding flow (Macros; facade, storage and manager forwarding; factory-helper bindings), with the `Support\Manager::getCreatorMethod()` runtime fix and its regression tests. - Also completed: the request, environment and storage flow, Redis command casing and the connection-bound Redis rule, with their source fixes and tests (see Requests, environment and storage). - Also completed and reviewed: the model-properties/schema flow, including schema-parser impact, casts/accessors, fetched types, schema and forwarding dependencies, the schema-dump diagnostic, the routing and SQLite JSON fixes, Attribute variance, inherited method-template declarations, tests and documentation. -- Immediate next checkpoint, explicitly approved by the owner: after the model-properties/schema checkpoint is committed, remove unnecessary `final` declarations from the PHPStan package and the branch-added `BrokenRewindDirectoryStream` and `ConditionableTarget` fixtures. Include `ConfigArray`, retaining `readonly`; retain the pre-existing `Comment`/`Comments` type fixtures. Bulk editing is approved for this cleanup. Complete its checks, self-review and peer review loop, then have the orchestrator commit it before any other implementation resumes. -- Then port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. +- Also completed and reviewed as a separate checkpoint: the approved `final` cleanup in the PHPStan package and branch-added fixtures, plus the branch-added schema-parser `Impact` and `TypeReference` classes. `readonly` is preserved; pre-existing schema-parser classes and the `Comment`/`Comments` type fixtures are unchanged. +- Next: port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions From cae0bc73adb0be81829e98534644aea23aa94b02 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:40:17 +0000 Subject: [PATCH 112/303] Remove unnecessary final restrictions from schema parser classes Allow inheritance for the remaining fourteen readonly result classes and four internal parsing classes. Preserve readonly declarations, private state, signatures and parsing behavior; no concrete invariant requires these classes to forbid inheritance. Make the package consistent with Impact and TypeReference and update the active plan cleanup note. Self-reviewed every changed declaration and verified that source changes only remove final. Validation: PHP-CS-Fixer made no changes; composer analyse passed, including type fixtures and the application skeleton; all 570 schema-parser tests passed (1648 assertions). --- docs/plans/2026-11-04-hypervel-phpstan.md | 2 +- src/schema-parser/src/CheckConstraint.php | 2 +- src/schema-parser/src/Column.php | 2 +- src/schema-parser/src/ColumnType.php | 2 +- src/schema-parser/src/Domain.php | 2 +- src/schema-parser/src/EnumType.php | 2 +- src/schema-parser/src/ForeignKey.php | 2 +- src/schema-parser/src/Generation.php | 2 +- src/schema-parser/src/Index.php | 2 +- src/schema-parser/src/IndexColumn.php | 2 +- src/schema-parser/src/Issue.php | 2 +- src/schema-parser/src/Parsing/ColumnDefinition.php | 2 +- src/schema-parser/src/Parsing/ParseState.php | 2 +- src/schema-parser/src/Parsing/TableDefinition.php | 2 +- src/schema-parser/src/Parsing/TokenStream.php | 2 +- src/schema-parser/src/Schema.php | 2 +- src/schema-parser/src/Table.php | 2 +- src/schema-parser/src/TableReference.php | 2 +- src/schema-parser/src/View.php | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 65f741d6b..4fea72173 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -23,7 +23,7 @@ Remaining work: - Also completed: the macro and forwarding flow (Macros; facade, storage and manager forwarding; factory-helper bindings), with the `Support\Manager::getCreatorMethod()` runtime fix and its regression tests. - Also completed: the request, environment and storage flow, Redis command casing and the connection-bound Redis rule, with their source fixes and tests (see Requests, environment and storage). - Also completed and reviewed: the model-properties/schema flow, including schema-parser impact, casts/accessors, fetched types, schema and forwarding dependencies, the schema-dump diagnostic, the routing and SQLite JSON fixes, Attribute variance, inherited method-template declarations, tests and documentation. -- Also completed and reviewed as a separate checkpoint: the approved `final` cleanup in the PHPStan package and branch-added fixtures, plus the branch-added schema-parser `Impact` and `TypeReference` classes. `readonly` is preserved; pre-existing schema-parser classes and the `Comment`/`Comments` type fixtures are unchanged. +- Also completed: the approved `final` cleanup in the PHPStan and schema-parser packages and branch-added fixtures. `readonly` is preserved; the `Comment`/`Comments` type fixtures are unchanged. - Next: port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. diff --git a/src/schema-parser/src/CheckConstraint.php b/src/schema-parser/src/CheckConstraint.php index 7438b1cde..9fb70c25e 100644 --- a/src/schema-parser/src/CheckConstraint.php +++ b/src/schema-parser/src/CheckConstraint.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class CheckConstraint +readonly class CheckConstraint { /** * Create a check constraint with its unevaluated SQL expression. diff --git a/src/schema-parser/src/Column.php b/src/schema-parser/src/Column.php index 40e6ced71..2afb9d6a1 100644 --- a/src/schema-parser/src/Column.php +++ b/src/schema-parser/src/Column.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Column +readonly class Column { /** * Create a column definition. diff --git a/src/schema-parser/src/ColumnType.php b/src/schema-parser/src/ColumnType.php index b30b47670..b78406c9f 100644 --- a/src/schema-parser/src/ColumnType.php +++ b/src/schema-parser/src/ColumnType.php @@ -9,7 +9,7 @@ * PostgreSQL standard aliases use canonical names, while quoted user types * retain their case and qualification. */ -final readonly class ColumnType +readonly class ColumnType { /** * Create a column type definition. diff --git a/src/schema-parser/src/Domain.php b/src/schema-parser/src/Domain.php index 82e248b60..9f8074109 100644 --- a/src/schema-parser/src/Domain.php +++ b/src/schema-parser/src/Domain.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Domain +readonly class Domain { /** * Create a domain definition. diff --git a/src/schema-parser/src/EnumType.php b/src/schema-parser/src/EnumType.php index b7cd023c1..c8b22d7cf 100644 --- a/src/schema-parser/src/EnumType.php +++ b/src/schema-parser/src/EnumType.php @@ -9,7 +9,7 @@ * * PostgreSQL enums have an identity independent of the columns using them. */ -final readonly class EnumType +readonly class EnumType { /** * Create an enum type definition. diff --git a/src/schema-parser/src/ForeignKey.php b/src/schema-parser/src/ForeignKey.php index c30f2f629..cbe58fab7 100644 --- a/src/schema-parser/src/ForeignKey.php +++ b/src/schema-parser/src/ForeignKey.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class ForeignKey +readonly class ForeignKey { /** * Create a foreign key definition. diff --git a/src/schema-parser/src/Generation.php b/src/schema-parser/src/Generation.php index 9e2206299..2e5538607 100644 --- a/src/schema-parser/src/Generation.php +++ b/src/schema-parser/src/Generation.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Generation +readonly class Generation { /** * Create a generated column definition. diff --git a/src/schema-parser/src/Index.php b/src/schema-parser/src/Index.php index 27eed0f1c..d9674bc3d 100644 --- a/src/schema-parser/src/Index.php +++ b/src/schema-parser/src/Index.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Index +readonly class Index { /** * Create an index definition. diff --git a/src/schema-parser/src/IndexColumn.php b/src/schema-parser/src/IndexColumn.php index 1cd1a212d..db336b301 100644 --- a/src/schema-parser/src/IndexColumn.php +++ b/src/schema-parser/src/IndexColumn.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class IndexColumn +readonly class IndexColumn { /** * Create an index key from a column name or SQL expression. diff --git a/src/schema-parser/src/Issue.php b/src/schema-parser/src/Issue.php index f6911d49c..5d1fbdb42 100644 --- a/src/schema-parser/src/Issue.php +++ b/src/schema-parser/src/Issue.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Issue +readonly class Issue { /** * Describe uncertainty in the owning schema or table. diff --git a/src/schema-parser/src/Parsing/ColumnDefinition.php b/src/schema-parser/src/Parsing/ColumnDefinition.php index 4a4d84ed8..57d15380e 100644 --- a/src/schema-parser/src/Parsing/ColumnDefinition.php +++ b/src/schema-parser/src/Parsing/ColumnDefinition.php @@ -13,7 +13,7 @@ * * @internal */ -final class ColumnDefinition +class ColumnDefinition { public bool $nullable = true; diff --git a/src/schema-parser/src/Parsing/ParseState.php b/src/schema-parser/src/Parsing/ParseState.php index 8662c683f..32386aa66 100644 --- a/src/schema-parser/src/Parsing/ParseState.php +++ b/src/schema-parser/src/Parsing/ParseState.php @@ -21,7 +21,7 @@ * * @internal */ -final class ParseState +class ParseState { /** @var array */ public array $tables = []; diff --git a/src/schema-parser/src/Parsing/TableDefinition.php b/src/schema-parser/src/Parsing/TableDefinition.php index 489c7d476..6a7209e86 100644 --- a/src/schema-parser/src/Parsing/TableDefinition.php +++ b/src/schema-parser/src/Parsing/TableDefinition.php @@ -15,7 +15,7 @@ * * @internal */ -final class TableDefinition +class TableDefinition { /** @var array columns by name, in declaration order */ public array $columns = []; diff --git a/src/schema-parser/src/Parsing/TokenStream.php b/src/schema-parser/src/Parsing/TokenStream.php index 92fafbc12..b3e19db78 100644 --- a/src/schema-parser/src/Parsing/TokenStream.php +++ b/src/schema-parser/src/Parsing/TokenStream.php @@ -18,7 +18,7 @@ * * @internal */ -final class TokenStream +class TokenStream { /** Keyword operators that take an operand after them, in the expressions that clause keywords can end. */ private const array OPERATORS = [ diff --git a/src/schema-parser/src/Schema.php b/src/schema-parser/src/Schema.php index 82fee35ad..9bfff2c03 100644 --- a/src/schema-parser/src/Schema.php +++ b/src/schema-parser/src/Schema.php @@ -6,7 +6,7 @@ use InvalidArgumentException; -final readonly class Schema +readonly class Schema { /** @var array> */ private array $tablesByName; diff --git a/src/schema-parser/src/Table.php b/src/schema-parser/src/Table.php index 1019946a7..8c1766ff6 100644 --- a/src/schema-parser/src/Table.php +++ b/src/schema-parser/src/Table.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Table +readonly class Table { /** @var array */ private array $columnsByName; diff --git a/src/schema-parser/src/TableReference.php b/src/schema-parser/src/TableReference.php index 1bb0501d2..7ba497bb1 100644 --- a/src/schema-parser/src/TableReference.php +++ b/src/schema-parser/src/TableReference.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class TableReference +readonly class TableReference { /** * Create a table reference without assuming an unspecified schema. diff --git a/src/schema-parser/src/View.php b/src/schema-parser/src/View.php index 0b3451c62..1a93113b1 100644 --- a/src/schema-parser/src/View.php +++ b/src/schema-parser/src/View.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class View +readonly class View { /** * Create a view definition without inferring its query result types. From 0885b1fea7e81d3dc91a45ecd2f20d6146e6a2ce Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:53:44 +0000 Subject: [PATCH 113/303] Remove duplicated bulk-edit approval rule Remove the local owner-approval requirement now that the monorepo rule requires peer agreement on bulk-edit scope and patterns, followed by complete diff review. Keep the policy in one authoritative location. --- AGENTS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5d773c00e..073338002 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,7 +140,6 @@ Apply approved adaptations when porting or modifying code. Do not expand a chang ### Working rules - **Never use subagents without explicit user consent** — Do not spawn or delegate work to subagents unless the user explicitly requests or approves their use. -- **Avoid bulk modification tools** — tools like `sed` and `replace_all` often have unwanted side effects. Never use bulk modification tools without explicit user approval; prefer manual edits. When approved, run them in multiple passes that each target long, exact, case-sensitive strings to avoid accidental changes. - **One change at a time** — Work on one coherent change at a time. Sequence dependent edits, and review the complete affected behavior across files before considering the change finished. - **Never use Write to overwrite files** — always use Edit for targeted updates. - **Always use `cp` to copy files and `mv` to move/rename** — never read → write new version → delete old version. From 50e948edaf4a512e6d16a24efe3ddb41f365a490 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:31:57 +0000 Subject: [PATCH 114/303] Refine Eloquent query callbacks and relation result contracts Describe nested where callbacks and subquery callbacks according to the operator passed to the runtime. Preserve the query type through route binding and use the far parent model in through-relation existence queries. Type relation chunk callbacks with their Eloquent collections and pivot values. Narrow quiet increments to their actual integer result, retain raw aggregate string results, and remove the circular builder mixin that bypassed model-property checks. Update the affected database type assertions and test wrappers. Validated as part of the reviewed checkpoint with formatting, full PHPStan/types, Database tests and NestedSet tests. --- src/database/src/Eloquent/Builder.php | 21 ++++++---- .../Eloquent/Concerns/HasUniqueStringIds.php | 6 ++- .../Concerns/QueriesRelationships.php | 5 ++- src/database/src/Eloquent/Model.php | 18 ++++++--- .../src/Eloquent/Relations/BelongsToMany.php | 15 ++++++- .../Relations/HasOneOrManyThrough.php | 25 ++++++++++-- src/database/src/Query/Builder.php | 8 ++++ tests/Database/DatabaseEloquentModelTest.php | 4 +- types/Database/Eloquent/Builder.php | 40 +++++++++++-------- .../Eloquent/CustomBuilderForwarding.php | 21 +++++++++- types/Database/Eloquent/NamedScopes.php | 2 +- types/Database/Eloquent/Relations.php | 29 ++++++++++++-- types/Database/Query/Builder.php | 8 ++++ 13 files changed, 155 insertions(+), 47 deletions(-) diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 6912a02bf..3e9abd44c 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -341,7 +341,7 @@ public function except(mixed $models): static /** * Add a basic where clause to the query. * - * @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @param array|(Closure(($operator is null ? static : QueryBuilder)): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column * @return $this */ public function where(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static @@ -366,7 +366,7 @@ public function where(array|Closure|self|QueryBuilder|Relation|Expression|string /** * Add a basic where clause to the query, and return the first result. * - * @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @param array|(Closure(($operator is null ? static : QueryBuilder)): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column * @return null|TModel */ public function firstWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model @@ -377,7 +377,10 @@ public function firstWhere(array|Closure|self|QueryBuilder|Relation|Expression|s /** * Add an "or where" clause to the query. * - * @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * A null operator passed as the second of two arguments becomes a comparison with null, which + * makes a callback a subquery, so the callback type cannot follow the operator here. + * + * @param array|(Closure(__benevolent): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column */ public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static { @@ -393,7 +396,7 @@ public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|stri /** * Add a basic "where not" clause to the query. * - * @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @param array|(Closure(($operator is null ? static : QueryBuilder)): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column */ public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -409,7 +412,7 @@ public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|str /** * Add an "or where not" clause to the query. * - * @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @param array|(Closure(($operator is null ? static : QueryBuilder)): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column */ public function orWhereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static { @@ -1120,7 +1123,9 @@ public function modelKeys(): array /** * Paginate the given query. * - * @return LengthAwarePaginator + * @param null|(Closure(int): ?int)|int $perPage + * @param null|(Closure(): ?int)|int $total + * @return LengthAwarePaginator> * * @throws InvalidArgumentException */ @@ -1145,7 +1150,7 @@ public function paginate(Closure|int|null $perPage = null, array|string $columns /** * Paginate the given query into a simple paginator. * - * @return Paginator + * @return Paginator> */ public function simplePaginate(?int $perPage = null, array|string $columns = ['*'], string $pageName = 'page', ?int $page = null): Paginator { @@ -1167,7 +1172,7 @@ public function simplePaginate(?int $perPage = null, array|string $columns = ['* /** * Paginate the given query into a cursor paginator. * - * @return CursorPaginator + * @return CursorPaginator> */ public function cursorPaginate(?int $perPage = null, array|string $columns = ['*'], string $cursorName = 'cursor', Cursor|string|null $cursor = null): CursorPaginator { diff --git a/src/database/src/Eloquent/Concerns/HasUniqueStringIds.php b/src/database/src/Eloquent/Concerns/HasUniqueStringIds.php index 75bb654b8..2a6e93bb1 100644 --- a/src/database/src/Eloquent/Concerns/HasUniqueStringIds.php +++ b/src/database/src/Eloquent/Concerns/HasUniqueStringIds.php @@ -43,8 +43,10 @@ public function uniqueIds(): array /** * Retrieve the model for a bound value. * - * @param Model|Builder|Relation<*, *, *> $query - * @return Builder|Relation<*, *, *> + * @template TQuery of Builder<*>|Relation<*, *, *> + * + * @param Model|TQuery $query + * @return ($query is Model ? Builder : TQuery) * * @throws ModelNotFoundException */ diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index c58742ab5..125f27793 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -26,7 +26,10 @@ use function Hypervel\Support\enum_value; -/** @mixin Builder */ +/** + * Unlike Laravel, this declares no `@mixin Builder`: Builder is its only user, and the bare mixin made + * PHPStan accept on every builder whatever the builder of an unknown model accepts. + */ trait QueriesRelationships { /** diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 8c7eaeb14..b206599a7 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1306,8 +1306,9 @@ public function updateQuietly(array $attributes = [], array $options = []): bool * * @param array $extra */ - protected function incrementQuietly(string $column, float|int $amount = 1, array $extra = []): int|false + protected function incrementQuietly(string $column, float|int $amount = 1, array $extra = []): int { + // fireModelEvent() returns true while events are disabled, so the update never halts with false. return static::withoutEvents( fn () => $this->incrementOrDecrement($column, $amount, $extra, 'increment') ); @@ -1318,8 +1319,9 @@ protected function incrementQuietly(string $column, float|int $amount = 1, array * * @param array $extra */ - protected function decrementQuietly(string $column, float|int $amount = 1, array $extra = []): int|false + protected function decrementQuietly(string $column, float|int $amount = 1, array $extra = []): int { + // fireModelEvent() returns true while events are disabled, so the update never halts with false. return static::withoutEvents( fn () => $this->incrementOrDecrement($column, $amount, $extra, 'decrement') ); @@ -1353,8 +1355,9 @@ protected function decrementEach(array $columns, array $extra = []): int|false * @param array $columns * @param array $extra */ - protected function incrementEachQuietly(array $columns, array $extra = []): int|false + protected function incrementEachQuietly(array $columns, array $extra = []): int { + // fireModelEvent() returns true while events are disabled, so the update never halts with false. return static::withoutEvents( fn () => $this->incrementOrDecrementEach($columns, $extra, 'incrementEach') ); @@ -1366,8 +1369,9 @@ protected function incrementEachQuietly(array $columns, array $extra = []): int| * @param array $columns * @param array $extra */ - protected function decrementEachQuietly(array $columns, array $extra = []): int|false + protected function decrementEachQuietly(array $columns, array $extra = []): int { + // fireModelEvent() returns true while events are disabled, so the update never halts with false. return static::withoutEvents( fn () => $this->incrementOrDecrementEach($columns, $extra, 'decrementEach') ); @@ -2871,8 +2875,10 @@ protected function childRouteBindingRelationshipName(string $childType): string /** * Retrieve the model for a bound value. * - * @param self|Builder|Relation<*, *, *> $query - * @return Builder|Relation<*, *, *> + * @template TQuery of Builder<*>|Relation<*, *, *> + * + * @param self|TQuery $query + * @return ($query is self ? Builder : TQuery) * * @throws ModelNotFoundException */ diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 08594319d..ebce248c9 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -1074,7 +1074,8 @@ protected function aliasedPivotColumns(): array /** * Get a paginator for the "select" statement. * - * @return LengthAwarePaginator + * @param null|(Closure(): ?int)|int $total + * @return LengthAwarePaginator> */ public function paginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null, Closure|int|null $total = null): mixed { @@ -1128,6 +1129,8 @@ public function cursorPaginate( /** * Chunk the results of the query. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunk(int $count, callable $callback): bool { @@ -1140,6 +1143,8 @@ public function chunk(int $count, callable $callback): bool /** * Chunk the results of a query by comparing numeric IDs. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool { @@ -1148,6 +1153,8 @@ public function chunkById(int $count, callable $callback, ?string $column = null /** * Chunk the results of a query by comparing IDs in descending order. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool { @@ -1156,6 +1163,8 @@ public function chunkByIdDesc(int $count, callable $callback, ?string $column = /** * Execute a callback over each item while chunking by ID. + * + * @param callable(object{pivot: TPivotModel}&TRelatedModel, int): mixed $callback */ public function eachById(callable $callback, int $count = 1000, ?string $column = null, ?string $alias = null): bool { @@ -1170,6 +1179,8 @@ public function eachById(callable $callback, int $count = 1000, ?string $column /** * Chunk the results of a query by comparing IDs in a given order. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function orderedChunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null, SortDirection|bool $descending = false): bool { @@ -1188,6 +1199,8 @@ public function orderedChunkById(int $count, callable $callback, ?string $column /** * Execute a callback over each item while chunking. + * + * @param callable(object{pivot: TPivotModel}&TRelatedModel, int): mixed $callback */ public function each(callable $callback, int $count = 1000): bool { diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 5ed2b2a0b..d4d819fdd 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -564,7 +564,8 @@ public function get(array $columns = ['*']): BaseCollection /** * Get a paginator for the "select" statement. * - * @return LengthAwarePaginator + * @param null|(Closure(): ?int)|int $total + * @return LengthAwarePaginator> */ public function paginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null, Closure|int|null $total = null): mixed { @@ -656,6 +657,8 @@ public function allRelatedIds(): BaseCollection /** * Chunk the results of the query. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunk(int $count, callable $callback): bool { @@ -664,6 +667,8 @@ public function chunk(int $count, callable $callback): bool /** * Chunk the results of a query by comparing numeric IDs. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool { @@ -676,6 +681,8 @@ public function chunkById(int $count, callable $callback, ?string $column = null /** * Chunk the results of a query by comparing IDs in descending order. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool { @@ -688,6 +695,8 @@ public function chunkByIdDesc(int $count, callable $callback, ?string $column = /** * Execute a callback over each item while chunking by ID. + * + * @param callable(TRelatedModel, int): mixed $callback */ public function eachById(callable $callback, int $count = 1000, ?string $column = null, ?string $alias = null): bool { @@ -710,6 +719,8 @@ public function cursor(): mixed /** * Execute a callback over each item while chunking. + * + * @param callable(TRelatedModel, int): mixed $callback */ public function each(callable $callback, int $count = 1000): bool { @@ -795,15 +806,23 @@ protected function prepareQueryBuilder(array $columns = ['*']): Builder ); } + /** + * Add the constraints for an internal relationship existence query. + * + * Unlike Laravel, which inherits Relation's types, the parent query is the declaring model's: Relation's + * parent here is the intermediate model. + * + * @param Builder $query + * @param Builder $parentQuery + * @return Builder + */ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder { if ($parentQuery->getQuery()->from === $query->getQuery()->from) { - // @phpstan-ignore argument.type (template types don't narrow through self-relation detection) return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); } if ($parentQuery->getQuery()->from === $this->throughParent->getTable()) { - // @phpstan-ignore argument.type (template types don't narrow through self-relation detection) return $this->getRelationExistenceQueryForThroughSelfRelation($query, $parentQuery, $columns); } diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 0976ad72f..41f05fb50 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -3274,7 +3274,9 @@ protected function withoutGroupLimitKeys(Collection $items): Collection /** * Paginate the given query into a simple paginator. * + * @param (Closure(int): int)|int $perPage * @param array|ExpressionContract|string $columns + * @param null|(Closure(): ?int)|int $total */ public function paginate( int|Closure $perPage = 15, @@ -4359,6 +4361,8 @@ public function max(ExpressionContract|string $column): mixed /** * Retrieve the sum of the values of a given column. + * + * @return float|int|string */ public function sum(ExpressionContract|string $column): mixed { @@ -4369,6 +4373,8 @@ public function sum(ExpressionContract|string $column): mixed /** * Retrieve the average of the values of a given column. + * + * @return null|float|int|string */ public function avg(ExpressionContract|string $column): mixed { @@ -4377,6 +4383,8 @@ public function avg(ExpressionContract|string $column): mixed /** * Alias for the "avg" method. + * + * @return null|float|int|string */ public function average(ExpressionContract|string $column): mixed { diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index a6b3d5e67..a61229e68 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -5568,7 +5568,7 @@ public function publicDecrementEach(array $columns, array $extra = []) /** * Expose quiet multi-column increments for testing. */ - public function publicIncrementEachQuietly(array $columns, array $extra = []): int|false + public function publicIncrementEachQuietly(array $columns, array $extra = []): int { return $this->incrementEachQuietly($columns, $extra); } @@ -5576,7 +5576,7 @@ public function publicIncrementEachQuietly(array $columns, array $extra = []): i /** * Expose quiet multi-column decrements for testing. */ - public function publicDecrementEachQuietly(array $columns, array $extra = []): int|false + public function publicDecrementEachQuietly(array $columns, array $extra = []): int { return $this->decrementEachQuietly($columns, $extra); } diff --git a/types/Database/Eloquent/Builder.php b/types/Database/Eloquent/Builder.php index 37a489126..3e4d78f70 100644 --- a/types/Database/Eloquent/Builder.php +++ b/types/Database/Eloquent/Builder.php @@ -35,8 +35,9 @@ function test( assertType('Hypervel\Database\Eloquent\Builder', $query->whereIn('id', [1])->with('relation')); assertType('Hypervel\Database\Eloquent\Builder', $query->orderBy('id')->with('relation')); assertType('Hypervel\Database\Eloquent\Builder', $query->limit(1)->with('relation')); - assertType("Hypervel\\Database\\Query\\Builder", $query->dump()); - assertType("Hypervel\\Database\\Query\\Builder", $query->dumpRawSql()); + // Passthru methods return the raw query toBase() gives them, whose rows are not models. + assertType('Hypervel\Database\Query\Builder', $query->dump()); + assertType('stdClass|null', $query->dumpRawSql()->first()); assertType('stdClass|null', $query->toBase()->first()); assertType('stdClass|null', $query->getQuery()->first()); assertType('Hypervel\Database\Eloquent\Builder', $query->newQuery()); @@ -93,7 +94,7 @@ function test( assertType('Hypervel\Support\LazyCollection', $query->lazy()); assertType('Hypervel\Support\LazyCollection', $query->lazyById()); assertType('Hypervel\Support\LazyCollection', $query->lazyByIdDesc()); - assertType('Hypervel\Pagination\CursorPaginator', $query->cursorPaginate()); + assertType('Hypervel\Pagination\CursorPaginator>', $query->cursorPaginate()); assertType('string', $query->cursorPaginate()->toJson()); assertType('Hypervel\Support\Collection<(int|string), mixed>', $query->pluck('foo')); assertType('Hypervel\Database\Eloquent\Relations\Relation', $query->getRelation('foo')); @@ -103,27 +104,27 @@ function test( assertType('Hypervel\Database\Eloquent\Builder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->has($user->posts(), count: $expression, callback: function ($query) { - assertType('Hypervel\Database\Eloquent\Builder', $query); + assertType('Hypervel\Types\Builder\CommonBuilder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->orHas($user->posts(), count: $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->doesntHave($user->posts(), callback: function ($query) { - assertType('Hypervel\Database\Eloquent\Builder', $query); + assertType('Hypervel\Types\Builder\CommonBuilder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->orDoesntHave($user->posts())); assertType('Hypervel\Database\Eloquent\Builder', $query->whereHas($user->posts(), function ($query) { - assertType('Hypervel\Database\Eloquent\Builder', $query); + assertType('Hypervel\Types\Builder\CommonBuilder', $query); }, count: $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->withWhereHas('posts', function ($query) { - assertType('Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $query); + assertType('Hypervel\Database\Eloquent\Relations\HasMany|Hypervel\Types\Builder\CommonBuilder', $query); }, count: $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereHas($user->posts(), function ($query) { - assertType('Hypervel\Database\Eloquent\Builder', $query); + assertType('Hypervel\Types\Builder\CommonBuilder', $query); }, count: $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->whereDoesntHave($user->posts(), function ($query) { - assertType('Hypervel\Database\Eloquent\Builder', $query); + assertType('Hypervel\Types\Builder\CommonBuilder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereDoesntHave($user->posts(), function ($query) { - assertType('Hypervel\Database\Eloquent\Builder', $query); + assertType('Hypervel\Types\Builder\CommonBuilder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->hasMorph($post->taggable(), 'taggable', count: $expression, callback: function ($query, $type) { assertType('Hypervel\Database\Eloquent\Builder', $query); @@ -152,16 +153,16 @@ function test( assertType('string', $type); })); assertType('Hypervel\Database\Eloquent\Builder', $query->whereRelation($user->posts(), function ($query) { - assertType('Hypervel\Database\Eloquent\Builder', $query); + assertType('Hypervel\Types\Builder\CommonBuilder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereRelation($user->posts(), function ($query) { - assertType('Hypervel\Database\Eloquent\Builder', $query); + assertType('Hypervel\Types\Builder\CommonBuilder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->whereDoesntHaveRelation($user->posts(), function ($query) { - assertType('Hypervel\Database\Eloquent\Builder', $query); + assertType('Hypervel\Types\Builder\CommonBuilder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereDoesntHaveRelation($user->posts(), function ($query) { - assertType('Hypervel\Database\Eloquent\Builder', $query); + assertType('Hypervel\Types\Builder\CommonBuilder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->whereMorphRelation($post->taggable(), 'taggable', function ($query) { assertType('Hypervel\Database\Eloquent\Builder', $query); @@ -202,18 +203,18 @@ function test( assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereNotMorphedTo($post->taggable(), new Post)); $query->chunk(1, function ($users, $page) { - assertType('Hypervel\Support\Collection', $users); + assertType('Hypervel\Database\Eloquent\Collection', $users); assertType('int', $page); }); $query->chunkById(1, function ($users, $page) { - assertType('Hypervel\Support\Collection', $users); + assertType('Hypervel\Database\Eloquent\Collection', $users); assertType('int', $page); }); $query->chunkMap(function ($users) { assertType('Hypervel\Types\Builder\User', $users); }); $query->chunkByIdDesc(1, function ($users, $page) { - assertType('Hypervel\Support\Collection', $users); + assertType('Hypervel\Database\Eloquent\Collection', $users); assertType('int', $page); }); $query->each(function ($users, $page) { @@ -224,6 +225,11 @@ function test( assertType('Hypervel\Types\Builder\User', $users); assertType('int', $page); }); + $query->paginate(function ($total) { + assertType('int', $total); + + return null; + }, total: fn () => 10); assertType('Hypervel\Types\Builder\CommonBuilder', Post::query()); assertType('Hypervel\Types\Builder\CommonBuilder', Post::query()->whereIn('id', [1])->with('comments')); diff --git a/types/Database/Eloquent/CustomBuilderForwarding.php b/types/Database/Eloquent/CustomBuilderForwarding.php index a392fd2fe..969b55d31 100644 --- a/types/Database/Eloquent/CustomBuilderForwarding.php +++ b/types/Database/Eloquent/CustomBuilderForwarding.php @@ -25,7 +25,7 @@ function test(User $user, ScopedUser $scoped, FixedUser $fixed): void assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', User::query()->selectRaw('id', record: true)); assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', User::query()->ignoredAnswer()); assertType('int', User::query()->rawAnswer()); - assertType('Hypervel\Types\CustomBuilderForwarding\UserQuery', User::query()->dump()); + assertType('Hypervel\Types\CustomBuilderForwarding\UserQuery', User::query()->dump()); assertType('Hypervel\Types\CustomBuilderForwarding\UserQuery', User::query()->getQuery()); assertType('Hypervel\Support\Collection', User::tenant('one')->models()); assertType('Hypervel\Support\Collection', Admin::tenant('one')->models()); @@ -45,7 +45,7 @@ function test(User $user, ScopedUser $scoped, FixedUser $fixed): void assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->children()->clone()); assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', $user->children()->applyScopes()); assertType('int', $user->children()->rawAnswer()); - assertType('Hypervel\Types\CustomBuilderForwarding\UserQuery', $user->children()->dump()); + assertType('Hypervel\Types\CustomBuilderForwarding\UserQuery', $user->children()->dump()); assertType('int', ScopedUser::query()->limit('scope')); assertType('int', ScopedUser::limit('scope')); @@ -62,6 +62,23 @@ function test(User $user, ScopedUser $scoped, FixedUser $fixed): void FixedUser::query()->inspectRow(function ($row): void { assertType('stdClass', $row); }); + + // A model's own newQuery() replaces HasBuilder's, so forwarding falls back to the model's builder factory. + assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', ReplacedQueryUser::tenant('one')); + assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', (new ReplacedQueryUser)->newQuery()); +} + +class ReplacedQueryUser extends Model +{ + /** @use HasBuilder> */ + use HasBuilder; + + protected static string $builder = UserBuilder::class; + + public function newQuery(): Builder + { + return parent::newQuery(); + } } class User extends Model diff --git a/types/Database/Eloquent/NamedScopes.php b/types/Database/Eloquent/NamedScopes.php index 9fd2a64fa..bc7a26481 100644 --- a/types/Database/Eloquent/NamedScopes.php +++ b/types/Database/Eloquent/NamedScopes.php @@ -79,7 +79,7 @@ function test(User $user, Post $post, DatabaseNotification $notification): void assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->where('active', true)); assertType('Hypervel\Database\Eloquent\Builder', DatabaseNotification::query()->read()); - assertType('Hypervel\Database\Eloquent\Collection', DatabaseNotification::query()->unread()->get()); + assertType('Hypervel\Notifications\DatabaseNotificationCollection', DatabaseNotification::query()->unread()->get()); assertType('bool', $notification->read()); assertType('never', $user->posts()->unavailable()); diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index d72dfd617..2e1aa9607 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -39,8 +39,8 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->getResults()); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->fetchUsing(PDO::FETCH_ASSOC)->get()); assertType('Hypervel\Types\Relations\Post|null', $user->posts()->useWritePdo()->first()); - assertType("Hypervel\\Database\\Query\\Builder", $user->posts()->dump()); - assertType("Hypervel\\Database\\Query\\Builder", $user->posts()->dumpRawSql()); + assertType('Hypervel\Database\Query\Builder', $user->posts()->dump()); + assertType('stdClass|null', $user->posts()->dumpRawSql()->first()); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->clone()); assertType('Hypervel\Database\Eloquent\Builder', $user->posts()->applyScopes()); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->whereIn('id', [1])); @@ -107,9 +107,17 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Support\LazyCollection', $user->roles()->lazy()); assertType('Hypervel\Support\LazyCollection', $user->roles()->lazyById()); assertType('Hypervel\Support\LazyCollection', $user->roles()->cursor()); - assertType('Hypervel\Pagination\LengthAwarePaginator', $user->roles()->paginate()); + assertType('Hypervel\Pagination\LengthAwarePaginator>', $user->roles()->paginate()); assertType('Hypervel\Contracts\Pagination\Paginator', $user->roles()->simplePaginate()); assertType('Hypervel\Contracts\Pagination\CursorPaginator', $user->roles()->cursorPaginate(cursor: new Cursor(['id' => 1]))); + $user->roles()->chunk(1, function ($roles, $page) { + assertType('Hypervel\Database\Eloquent\Collection', $roles); + assertType('int', $page); + }); + $user->roles()->each(function ($role, $key) { + assertType('Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $role); + assertType('int', $key); + }); assertType("Hypervel\\Database\\Eloquent\\Relations\\BelongsToMany", $user->tenantRoles()); assertType('Hypervel\Database\Eloquent\Collection', $user->tenantRoles()->getResults()); @@ -135,9 +143,22 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Database\Eloquent\Relations\HasManyThrough', $user->parts()); assertType('Hypervel\Database\Eloquent\Collection', $user->parts()->getResults()); - assertType('Hypervel\Pagination\LengthAwarePaginator', $user->parts()->paginate()); + assertType('Hypervel\Pagination\LengthAwarePaginator>', $user->parts()->paginate()); assertType('Hypervel\Contracts\Pagination\Paginator', $user->parts()->simplePaginate()); assertType('Hypervel\Contracts\Pagination\CursorPaginator', $user->parts()->cursorPaginate(cursor: new Cursor(['id' => 1]))); + // The parent query of a through relation's existence query is the declaring model's, not the intermediate's. + assertType('Hypervel\Database\Eloquent\Builder', $user->parts()->getRelationExistenceQuery(Part::query(), User::query())); + + assertType('Hypervel\Database\Eloquent\Builder', $user->resolveRouteBindingQuery($user, 1)); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->resolveRouteBindingQuery($user->posts(), 1)); + $user->parts()->chunk(1, function ($parts, $page) { + assertType('Hypervel\Database\Eloquent\Collection', $parts); + assertType('int', $page); + }); + $user->parts()->each(function ($part, $key) { + assertType('Hypervel\Types\Relations\Part', $part); + assertType('int', $key); + }); assertType('Hypervel\Database\Eloquent\Relations\HasOneThrough', $user->firstPart()); assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $post->user()); diff --git a/types/Database/Query/Builder.php b/types/Database/Query/Builder.php index b05b685bd..d420785d4 100644 --- a/types/Database/Query/Builder.php +++ b/types/Database/Query/Builder.php @@ -81,6 +81,14 @@ function test(Builder $query, EloquentBuilder $userQuery): void assertType('stdClass', $users); assertType('int', $page); }); + $query->paginate(function ($total) { + assertType('int', $total); + + return 15; + }, total: fn () => 10); + assertType('float|int|string', $query->sum('id')); + assertType('float|int|string|null', $query->avg('id')); + assertType('float|int|string|null', $query->average('id')); assertType('Hypervel\Database\Query\Builder', $query->pipe(function () { })); assertType('Hypervel\Database\Query\Builder', $query->pipe(fn () => null)); From bc8c6dfaccc6e491c2563c92acf511be493fd586 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:32:04 +0000 Subject: [PATCH 115/303] Preserve paginator collection types through replacement and mapping Carry the stored collection as a defaulted third paginator template while keeping existing two-argument annotations valid. Return that collection from getCollection(), update all templates through setCollection(), and widen the collection after through() maps its values. Keep cursor paginator input keys independent of its renumbered result keys. Update paginator and data type assertions without changing runtime pagination behavior. Validated with the combined checkpoint's formatting, PHPStan/types and Pagination tests. --- .../src/AbstractCursorPaginator.php | 10 +++--- src/pagination/src/AbstractPaginator.php | 10 +++--- src/pagination/src/CursorPaginator.php | 9 +++-- src/pagination/src/LengthAwarePaginator.php | 3 +- src/pagination/src/Paginator.php | 3 +- types/Data/Data.php | 28 +++++++-------- types/Pagination/Paginator.php | 34 +++++++++---------- 7 files changed, 53 insertions(+), 44 deletions(-) diff --git a/src/pagination/src/AbstractCursorPaginator.php b/src/pagination/src/AbstractCursorPaginator.php index d2192cee6..5220139d1 100644 --- a/src/pagination/src/AbstractCursorPaginator.php +++ b/src/pagination/src/AbstractCursorPaginator.php @@ -28,6 +28,7 @@ * @template TKey of array-key * * @template-covariant TValue + * @template-covariant TCollection of Collection = Collection the stored collection, kept as given * * @mixin Collection */ @@ -405,7 +406,7 @@ public function items(): array * @param callable(TValue, TKey): TThroughValue $callback * @return $this * - * @phpstan-this-out static + * @phpstan-this-out static> */ public function through(callable $callback): static { @@ -556,7 +557,7 @@ public function count(): int /** * Get the paginator's underlying collection. * - * @return Collection + * @return Collection&TCollection */ public function getCollection(): Collection { @@ -568,11 +569,12 @@ public function getCollection(): Collection * * @template TSetKey of array-key * @template TSetValue + * @template TSetCollection of Collection * - * @param Collection $collection + * @param Collection&TSetCollection $collection * @return $this * - * @phpstan-this-out static + * @phpstan-this-out static */ public function setCollection(Collection $collection): static { diff --git a/src/pagination/src/AbstractPaginator.php b/src/pagination/src/AbstractPaginator.php index 72f003aad..ce6d2dd74 100644 --- a/src/pagination/src/AbstractPaginator.php +++ b/src/pagination/src/AbstractPaginator.php @@ -23,6 +23,7 @@ * @template TKey of array-key * * @template-covariant TValue + * @template-covariant TCollection of Collection = Collection the stored collection, kept as given * * @mixin Collection */ @@ -345,7 +346,7 @@ public function lastItem(): ?int * @param callable(TValue, TKey): TMapValue $callback * @return $this * - * @phpstan-this-out static + * @phpstan-this-out static> */ public function through(callable $callback): static { @@ -630,7 +631,7 @@ public function count(): int /** * Get the paginator's underlying collection. * - * @return Collection + * @return Collection&TCollection */ public function getCollection(): Collection { @@ -642,11 +643,12 @@ public function getCollection(): Collection * * @template TSetKey of array-key * @template TSetValue + * @template TSetCollection of Collection * - * @param Collection $collection + * @param Collection&TSetCollection $collection * @return $this * - * @phpstan-this-out static + * @phpstan-this-out static */ public function setCollection(Collection $collection): static { diff --git a/src/pagination/src/CursorPaginator.php b/src/pagination/src/CursorPaginator.php index f88155c97..a1dab5695 100644 --- a/src/pagination/src/CursorPaginator.php +++ b/src/pagination/src/CursorPaginator.php @@ -20,8 +20,9 @@ * @template TKey of array-key * * @template-covariant TValue + * @template-covariant TCollection of Collection = Collection * - * @extends AbstractCursorPaginator + * @extends AbstractCursorPaginator * * @implements Arrayable> * @implements ArrayAccess @@ -33,7 +34,9 @@ class CursorPaginator extends AbstractCursorPaginator implements Arrayable, Arra /** * Create a new paginator instance. * - * @param null|Arrayable>|Collection|iterable $items + * The items are renumbered, so their keys do not decide TKey. + * + * @param null|Arrayable>|Collection|iterable $items * @param array $options (path, query, fragment, cursorName, parameters) */ public function __construct(mixed $items, int $perPage, ?Cursor $cursor = null, array $options = []) @@ -54,7 +57,7 @@ public function __construct(mixed $items, int $perPage, ?Cursor $cursor = null, /** * Set the items for the paginator. * - * @param null|Arrayable>|Collection|iterable $items + * @param null|Arrayable>|Collection|iterable $items */ protected function setItems(mixed $items): void { diff --git a/src/pagination/src/LengthAwarePaginator.php b/src/pagination/src/LengthAwarePaginator.php index 9be9dc405..b3cf2fc90 100644 --- a/src/pagination/src/LengthAwarePaginator.php +++ b/src/pagination/src/LengthAwarePaginator.php @@ -20,8 +20,9 @@ * @template TKey of array-key * * @template-covariant TValue + * @template-covariant TCollection of Collection = Collection * - * @extends AbstractPaginator + * @extends AbstractPaginator * * @implements Arrayable> * @implements ArrayAccess diff --git a/src/pagination/src/Paginator.php b/src/pagination/src/Paginator.php index 363709488..a41a76019 100644 --- a/src/pagination/src/Paginator.php +++ b/src/pagination/src/Paginator.php @@ -20,8 +20,9 @@ * @template TKey of array-key * * @template-covariant TValue + * @template-covariant TCollection of Collection = Collection * - * @extends AbstractPaginator + * @extends AbstractPaginator * * @implements Arrayable> * @implements ArrayAccess diff --git a/types/Data/Data.php b/types/Data/Data.php index c7f167eef..88ab88930 100644 --- a/types/Data/Data.php +++ b/types/Data/Data.php @@ -141,7 +141,7 @@ function dataTypeLengthAwarePaginatorContract(): LengthAwarePaginatorContract assertType('Hypervel\Support\LazyCollection', $explicitLazyCollection); assertType('Hypervel\Data\DataCollection', $explicitDataCollection); assertType('Hypervel\Support\Collection', $iteratorCollection); -assertType('array|Hypervel\Contracts\Pagination\CursorPaginator|Hypervel\Contracts\Pagination\Paginator|Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator|Hypervel\Pagination\AbstractPaginator|Hypervel\Support\Enumerable', $dynamicTarget); +assertType('array|Hypervel\Contracts\Pagination\CursorPaginator|Hypervel\Contracts\Pagination\Paginator|Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator>|Hypervel\Pagination\AbstractPaginator>|Hypervel\Support\Enumerable', $dynamicTarget); /** @var Paginator $paginator */ $paginator = new Paginator([['id' => 1]], 10, 1); @@ -184,21 +184,21 @@ function dataTypeLengthAwarePaginatorContract(): LengthAwarePaginatorContract $directPaginatedData = new PaginatedDataCollection(DataTypeUserData::class, $stringKeyedPaginator); $directCursorPaginatedData = new CursorPaginatedDataCollection(DataTypeUserData::class, $stringKeyedCursorPaginator); -assertType('Hypervel\Pagination\Paginator', $paginated); +assertType('Hypervel\Pagination\Paginator>', $paginated); assertType('Hypervel\Data\PaginatedDataCollection', $paginatedData); -assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginated); +assertType('Hypervel\Pagination\CursorPaginator>', $cursorPaginated); assertType('Hypervel\Data\CursorPaginatedDataCollection', $cursorPaginatedData); -assertType('Hypervel\Pagination\LengthAwarePaginator', $lengthAwarePaginated); -assertType('Hypervel\Pagination\AbstractPaginator', $abstractPaginated); -assertType('Hypervel\Pagination\AbstractCursorPaginator', $abstractCursorPaginated); -assertType('Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator|Hypervel\Pagination\AbstractPaginator|Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $contractPaginated); -assertType('Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator|Hypervel\Pagination\AbstractPaginator|Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $contractCursorPaginated); -assertType('Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator|Hypervel\Pagination\AbstractPaginator|Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $contractLengthAwarePaginated); -assertType('Hypervel\Pagination\Paginator', $explicitPaginator); -assertType('Hypervel\Pagination\CursorPaginator', $explicitCursorPaginator); -assertType('Hypervel\Pagination\LengthAwarePaginator', $explicitLengthAwarePaginator); -assertType('Hypervel\Pagination\AbstractPaginator', $explicitAbstractPaginator); -assertType('Hypervel\Pagination\AbstractCursorPaginator', $explicitAbstractCursorPaginator); +assertType('Hypervel\Pagination\LengthAwarePaginator>', $lengthAwarePaginated); +assertType('Hypervel\Pagination\AbstractPaginator>', $abstractPaginated); +assertType('Hypervel\Pagination\AbstractCursorPaginator>', $abstractCursorPaginated); +assertType('Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator>|Hypervel\Pagination\AbstractPaginator>|Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $contractPaginated); +assertType('Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator>|Hypervel\Pagination\AbstractPaginator>|Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $contractCursorPaginated); +assertType('Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator>|Hypervel\Pagination\AbstractPaginator>|Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $contractLengthAwarePaginated); +assertType('Hypervel\Pagination\Paginator>', $explicitPaginator); +assertType('Hypervel\Pagination\CursorPaginator>', $explicitCursorPaginator); +assertType('Hypervel\Pagination\LengthAwarePaginator>', $explicitLengthAwarePaginator); +assertType('Hypervel\Pagination\AbstractPaginator>', $explicitAbstractPaginator); +assertType('Hypervel\Pagination\AbstractCursorPaginator>', $explicitAbstractCursorPaginator); assertType('Hypervel\Contracts\Pagination\Paginator', $explicitPaginatorContract); assertType('Hypervel\Contracts\Pagination\CursorPaginator', $explicitCursorPaginatorContract); assertType('Hypervel\Contracts\Pagination\LengthAwarePaginator', $explicitLengthAwareContract); diff --git a/types/Pagination/Paginator.php b/types/Pagination/Paginator.php index ba8a4e155..c8dbb6d0d 100644 --- a/types/Pagination/Paginator.php +++ b/types/Pagination/Paginator.php @@ -21,20 +21,20 @@ assertType('ArrayIterator', $paginator->getIterator()); assertType('string|null', $paginator->fragment()); -assertType('Hypervel\Pagination\Paginator', $paginator->fragment('foo')); +assertType('Hypervel\Pagination\Paginator>', $paginator->fragment('foo')); assertType('Hypervel\Contracts\Support\Htmlable', $paginator->links()); -assertType('Hypervel\Pagination\Paginator', $paginator->loadMorph('parentable', [Post::class => ['comments' => function ($relation) { +assertType('Hypervel\Pagination\Paginator>', $paginator->loadMorph('parentable', [Post::class => ['comments' => function ($relation) { assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); }]])); -assertType('Hypervel\Pagination\Paginator', $paginator->loadMorphCount('parentable', [Post::class => ['comments' => function ($query) { +assertType('Hypervel\Pagination\Paginator>', $paginator->loadMorphCount('parentable', [Post::class => ['comments' => function ($query) { assertType('Hypervel\Database\Eloquent\Builder<*>', $query); }]])); -assertType('Hypervel\Pagination\Paginator', $paginator->loadMorph('parentable', [Post::class => 'comments'])); -assertType('Hypervel\Pagination\Paginator', $paginator->loadMorphCount('parentable', [Post::class => 'comments'])); +assertType('Hypervel\Pagination\Paginator>', $paginator->loadMorph('parentable', [Post::class => 'comments'])); +assertType('Hypervel\Pagination\Paginator>', $paginator->loadMorphCount('parentable', [Post::class => 'comments'])); foreach (['post', 2] as $alias) { - assertType('Hypervel\Pagination\Paginator', $paginator->loadMorph('parentable', [$alias => ['comments']])); - assertType('Hypervel\Pagination\Paginator', $paginator->loadMorphCount('parentable', [$alias => ['comments' => []]])); + assertType('Hypervel\Pagination\Paginator>', $paginator->loadMorph('parentable', [$alias => ['comments']])); + assertType('Hypervel\Pagination\Paginator>', $paginator->loadMorphCount('parentable', [$alias => ['comments' => []]])); } $paginator->each(function ($post) { @@ -50,7 +50,7 @@ 'first' => ['id' => 1], ])); -assertType('Hypervel\Pagination\Paginator', $paginatorWithReplacedCollection); +assertType('Hypervel\Pagination\Paginator>', $paginatorWithReplacedCollection); /** @var LengthAwarePaginator $lengthAwarePaginator */ $lengthAwarePaginator = new LengthAwarePaginator($items, 1, 1); @@ -73,19 +73,19 @@ assertType('ArrayIterator', $cursorPaginator->getIterator()); assertType('string|null', $cursorPaginator->fragment()); -assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->fragment('foo')); -assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorph('parentable', [Post::class => ['comments' => function ($relation) { +assertType('Hypervel\Pagination\CursorPaginator>', $cursorPaginator->fragment('foo')); +assertType('Hypervel\Pagination\CursorPaginator>', $cursorPaginator->loadMorph('parentable', [Post::class => ['comments' => function ($relation) { assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); }]])); -assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorphCount('parentable', [Post::class => ['comments' => function ($query) { +assertType('Hypervel\Pagination\CursorPaginator>', $cursorPaginator->loadMorphCount('parentable', [Post::class => ['comments' => function ($query) { assertType('Hypervel\Database\Eloquent\Builder<*>', $query); }]])); -assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorph('parentable', [Post::class => 'comments'])); -assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorphCount('parentable', [Post::class => 'comments'])); +assertType('Hypervel\Pagination\CursorPaginator>', $cursorPaginator->loadMorph('parentable', [Post::class => 'comments'])); +assertType('Hypervel\Pagination\CursorPaginator>', $cursorPaginator->loadMorphCount('parentable', [Post::class => 'comments'])); foreach (['post', 2] as $alias) { - assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorph('parentable', [$alias => ['comments']])); - assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->loadMorphCount('parentable', [$alias => ['comments' => []]])); + assertType('Hypervel\Pagination\CursorPaginator>', $cursorPaginator->loadMorph('parentable', [$alias => ['comments']])); + assertType('Hypervel\Pagination\CursorPaginator>', $cursorPaginator->loadMorphCount('parentable', [$alias => ['comments' => []]])); } $cursorPaginator->each(function ($post) { @@ -101,7 +101,7 @@ 'first' => ['id' => 1], ])); -assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginatorWithReplacedCollection); +assertType('Hypervel\Pagination\CursorPaginator>', $cursorPaginatorWithReplacedCollection); $throughPaginator = clone $cursorPaginator; $throughPaginator->through(function ($post, $key): array { @@ -114,7 +114,7 @@ ]; }); -assertType('Hypervel\Pagination\CursorPaginator', $throughPaginator); +assertType('Hypervel\Pagination\CursorPaginator>', $throughPaginator); /** @var PaginatorContract $paginatorContract */ assertType('Hypervel\Contracts\Support\Htmlable', $paginatorContract->render()); From eed3f2c0f6ac27b7e3086ea3ef389acee1b59d44 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:32:04 +0000 Subject: [PATCH 116/303] Resolve model-specific builders collections and relationship paths Add the builder-of, collection-of and relation-of PHPDoc types and their resolvers. Select builders using declared overrides, HasBuilder, attributes and model defaults, preserving model generics and unrelated builder type arguments. Resolve attribute class names through PHPStan's constant-expression support for both builder and collection attributes. Track external constant declarations, preserve custom collection types, and retain uncertainty when model or relationship alternatives cannot be resolved. Cover generic models, attributes, custom builders, bare relations and unknown model boundaries in analyzer fixtures. Validated with the combined checkpoint's full analyzer suite and PHPStan/type checks. --- src/phpstan/src/Methods/BuilderHelper.php | 246 ++++++++++++ .../Reflection/AttributeClassNameResolver.php | 60 +++ src/phpstan/src/Support/CollectionHelper.php | 60 ++- .../src/Types/BuilderOf/BuilderOfType.php | 205 ++++++++++ .../BuilderOfTypeNodeResolverExtension.php | 71 ++++ .../Types/CollectionOf/CollectionOfType.php | 190 ++++++++++ .../CollectionOfTypeNodeResolverExtension.php | 75 ++++ ...oquentBuilderTypeNodeResolverExtension.php | 109 ++++++ .../src/Types/RelationOf/RelationOfType.php | 245 ++++++++++++ .../RelationOfTypeNodeResolverExtension.php | 56 +++ tests/PHPStan/Type/data/builder-of-type.php | 351 ++++++++++++++++++ .../PHPStan/Type/data/collection-of-type.php | 69 ++++ tests/PHPStan/Type/data/relation-of-type.php | 93 +++++ .../app/BareRelations/Category.php | 15 + .../application/app/BareRelations/Item.php | 15 + .../application/app/BareRelations/Label.php | 9 + .../application/app/BareRelations/Owner.php | 15 + 17 files changed, 1866 insertions(+), 18 deletions(-) create mode 100644 src/phpstan/src/Methods/BuilderHelper.php create mode 100644 src/phpstan/src/Reflection/AttributeClassNameResolver.php create mode 100644 src/phpstan/src/Types/BuilderOf/BuilderOfType.php create mode 100644 src/phpstan/src/Types/BuilderOf/BuilderOfTypeNodeResolverExtension.php create mode 100644 src/phpstan/src/Types/CollectionOf/CollectionOfType.php create mode 100644 src/phpstan/src/Types/CollectionOf/CollectionOfTypeNodeResolverExtension.php create mode 100644 src/phpstan/src/Types/GenericEloquentBuilderTypeNodeResolverExtension.php create mode 100644 src/phpstan/src/Types/RelationOf/RelationOfType.php create mode 100644 src/phpstan/src/Types/RelationOf/RelationOfTypeNodeResolverExtension.php create mode 100644 tests/PHPStan/Type/data/builder-of-type.php create mode 100644 tests/PHPStan/Type/data/collection-of-type.php create mode 100644 tests/PHPStan/Type/data/relation-of-type.php create mode 100644 tests/PHPStan/application/app/BareRelations/Category.php create mode 100644 tests/PHPStan/application/app/BareRelations/Item.php create mode 100644 tests/PHPStan/application/app/BareRelations/Label.php create mode 100644 tests/PHPStan/application/app/BareRelations/Owner.php diff --git a/src/phpstan/src/Methods/BuilderHelper.php b/src/phpstan/src/Methods/BuilderHelper.php new file mode 100644 index 000000000..cb55ff25b --- /dev/null +++ b/src/phpstan/src/Methods/BuilderHelper.php @@ -0,0 +1,246 @@ +> */ + private array $passthru = []; + + /** @var array */ + private array $builders = []; + + /** @var array */ + private array $builderTypes = []; + + private readonly OutOfClassScope $scope; + + /** + * Create a builder helper. + */ + public function __construct( + private readonly ReflectionProvider $reflectionProvider, + private readonly DeclarationDependencyTracker $declarationDependencyTracker, + private readonly AttributeClassNameResolver $attributeClassNames, + ) { + $this->scope = new OutOfClassScope; + } + + /** + * Determine whether the builder passes the method through to its base query. + */ + public function isPassthru(ClassReflection $builderClass, string $methodName): bool + { + $className = $builderClass->getName(); + + if (! isset($this->passthru[$className])) { + /** @var list $passthru */ + $passthru = $builderClass->getNativeReflection()->getDefaultProperties()['passthru']; + + $this->passthru[$className] = $passthru; + } + + return in_array(strtolower($methodName), $this->passthru[$className], strict: true); + } + + /** + * Return the declared types of the queries a builder forwards to: its stored query, which ordinary + * forwarded calls run on, and the query passthru calls run on. + * + * Builder::__call() passes passthru calls to toBase() and forwards others to the stored query. Read + * through getMethod(), so a `@method` tag counts, as packages replacing the query layer (such as + * mongodb/laravel-mongodb) declare it on toBase(). + * + * @return array{Type, Type} + */ + public function queryTypes(ClassReflection $builderClass): array + { + $storedType = $builderClass->getMethod('getQuery', $this->scope)->getVariants()[0]->getReturnType(); + $baseType = $builderClass->getMethod('toBase', $this->scope)->getVariants()[0]->getReturnType(); + + // The inherited toBase() returns the scoped stored query, so its declaration describes that query too. + if ($builderClass->getNativeMethod('toBase')->getDeclaringClass()->getName() === EloquentBuilder::class) { + $storedType = TypeCombinator::intersect($storedType, $baseType); + + return [$storedType, $storedType]; + } + + return [$storedType, $baseType]; + } + + /** + * Bind a forwarded query's row types to the model, without changing raw-query types. + */ + public function bindQueryRows(Type $queryType, Type $modelType): Type + { + $queryClasses = $queryType->getObjectClassReflections(); + + if (count($queryClasses) !== 1) { + return $queryType; + } + + $queryClass = $queryClasses[0]; + $templates = $queryClass->getTemplateTypeMap(); + + if ($templates->getType('TKey') === null || $templates->getType('TValue') === null) { + return $queryType; + } + + $types = $queryClass->getActiveTemplateTypeMap()->map( + static fn (string $name, Type $type): Type => match ($name) { + 'TKey' => new IntegerType, + 'TValue' => $modelType, + default => $type, + }, + ); + + return new GenericObjectType($queryClass->getName(), $queryClass->typeMapToList($types)); + } + + /** + * Return the builder type holding the model, as Model::newModelQuery() binds it through setModel(). + * + * setModel() rebinds only the template mapped to the Eloquent builder's model, so other type arguments + * and a builder that fixes its model keep their declarations. + */ + public function getBuilderType(Type $builderType, Type $modelType): Type + { + // The base builder holds the model as given. Stub validation relies on this too: it reflects only the + // stubs, whose Builder declares no setModel(). + if ($builderType->getObjectClassNames() === [EloquentBuilder::class]) { + return new GenericObjectType(EloquentBuilder::class, [$modelType]); + } + + // Every builder-of resolution binds a model, so the inference is memoized. + $cacheKey = $builderType->describe(VerbosityLevel::cache()) . "\0" . $modelType->describe(VerbosityLevel::cache()); + + return $this->builderTypes[$cacheKey] ??= ParametersAcceptorSelector::selectFromTypes( + [$modelType], + $builderType->getMethod('setModel', $this->scope)->getVariants(), + false, + )->getReturnType(); + } + + /** + * Determine the model's builder as Model::newEloquentBuilder() does: an override's declared return, else a + * builder named by the UseEloquentBuilder attribute, else the $builder default (which upstream ignores). + */ + private function determineBuilder(ClassReflection $modelReflection): string|Type + { + // The base model, which stands for an unknown model, creates the base builder. Stub validation + // relies on this too: it reflects only the stubs, whose Model declares no newEloquentBuilder(). + if ($modelReflection->getName() === Model::class) { + return EloquentBuilder::class; + } + + // Forwarded calls run through newQuery(). HasBuilder's declares the builder its @use names, even when another + // trait's newEloquentBuilder() wins the conflict, as Nested Set's HasNode does. Its origin is checked by file + // first: reflecting an undocumented override resolves its inherited builder-of, which calls back here. + if ($modelReflection->getNativeReflection()->getMethod('newQuery')->getFileName() + === $this->reflectionProvider->getClass(HasBuilder::class)->getFileName()) { + return $modelReflection->getNativeMethod('newQuery')->getVariants()[0]->getReturnType(); + } + + $method = $modelReflection->getNativeMethod('newEloquentBuilder'); + + // The override decides on its own, so its declared return is kept even when it is broader than the attribute. + if ($method->getDeclaringClass()->getName() !== Model::class) { + return $method->getVariants()[0]->getReturnType(); + } + + $builderClassName = $this->findUseEloquentBuilderClassName($modelReflection); + + // Like Model::newEloquentBuilder(), ignore a class that is not an Eloquent builder subclass. + if ($builderClassName !== null + && $this->reflectionProvider->hasClass($builderClassName) + && $this->reflectionProvider->getClass($builderClassName)->isSubclassOfClass($this->reflectionProvider->getClass(EloquentBuilder::class))) { + return $builderClassName; + } + + // A property default is not part of the model's declarations. + foreach (SourceFiles::forInstance($this->reflectionProvider, $modelReflection, Model::class) as $file) { + $this->declarationDependencyTracker->trackFileDependency($modelReflection, $file); + } + + $builderClassName = $modelReflection->getNativeReflection()->getDefaultProperties()['builder']; + + return is_string($builderClassName) && $this->reflectionProvider->hasClass($builderClassName) + ? $builderClassName + : EloquentBuilder::class; + } + + /** + * Mirror Model::resolveCustomBuilderClass(): the model, its traits, then each parent and its traits, nearest first. + * + * It instantiates the attribute, so the argument may also be named. + */ + private function findUseEloquentBuilderClassName(ClassReflection $modelReflection): ?string + { + foreach ([$modelReflection, ...$modelReflection->getParents()] as $classReflection) { + foreach ([$classReflection, ...$classReflection->getTraits()] as $sourceReflection) { + $attributes = $sourceReflection->getNativeReflection()->getAttributes(UseEloquentBuilder::class); + + if ($attributes === []) { + continue; + } + + $arguments = $attributes[0]->getArgumentsExpressions(); + + return $this->attributeClassNames->resolve($arguments[0] ?? $arguments['builderClass'] ?? null, $sourceReflection, $modelReflection); + } + } + + return null; + } + + /** + * Return the builder type the model creates, with the given model type bound to it. + */ + public function determineBuilderClass(string $modelClassName, Type $modelType): ?Type + { + // Unlike upstream, which crashes, a missing class is left to PHPStan's class.notFound report. + if (! $this->reflectionProvider->hasClass($modelClassName)) { + return null; + } + + $modelReflection = $this->reflectionProvider->getClass($modelClassName); + + if (! $modelReflection->is(Model::class)) { + return null; + } + + $builder = $this->builders[$modelClassName] ??= $this->determineBuilder($modelReflection); + + return $this->getBuilderType(is_string($builder) ? new ObjectType($builder) : $builder, $modelType); + } +} diff --git a/src/phpstan/src/Reflection/AttributeClassNameResolver.php b/src/phpstan/src/Reflection/AttributeClassNameResolver.php new file mode 100644 index 000000000..796dc9724 --- /dev/null +++ b/src/phpstan/src/Reflection/AttributeClassNameResolver.php @@ -0,0 +1,60 @@ +class instanceof Name + && ! $argument->class->isSpecialClassName() + && $argument->name instanceof Identifier + && $argument->name->toLowerString() !== 'class') { + $this->declarationDependencyTracker->trackClassDependency($dependentReflection, $argument->class->toString()); + } + + $classNames = $this->initializerExprTypeResolver + ->getType($argument, InitializerExprContext::fromClassReflection($sourceReflection)) + ->getConstantStrings(); + + return count($classNames) === 1 ? ltrim($classNames[0]->getValue(), '\\') : null; + } +} diff --git a/src/phpstan/src/Support/CollectionHelper.php b/src/phpstan/src/Support/CollectionHelper.php index 0394495e7..c90d8e62f 100644 --- a/src/phpstan/src/Support/CollectionHelper.php +++ b/src/phpstan/src/Support/CollectionHelper.php @@ -5,16 +5,19 @@ namespace Hypervel\PHPStan\Support; use Hypervel\Database\Eloquent\Attributes\CollectedBy; +use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\HasCollection; +use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\PHPStan\Reflection\AttributeClassNameResolver; +use Hypervel\PHPStan\Reflection\ForwardedMethodReflection; use Hypervel\Support\Collection; use Hypervel\Support\Enumerable; use Hypervel\Support\LazyCollection; -use PhpParser\Node\Expr\ClassConstFetch; -use PhpParser\Node\Name; -use PhpParser\Node\Scalar\String_; +use Hypervel\Support\Str; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MissingMethodFromReflectionException; use PHPStan\Reflection\ReflectionProvider; use PHPStan\ShouldNotHappenException; @@ -34,7 +37,7 @@ use function array_map; use function count; use function in_array; -use function ltrim; +use function str_starts_with; #[AutowiredService] class CollectionHelper @@ -47,8 +50,10 @@ class CollectionHelper /** * Create a new collection helper instance. */ - public function __construct(private ReflectionProvider $reflectionProvider) - { + public function __construct( + private ReflectionProvider $reflectionProvider, + private AttributeClassNameResolver $attributeClassNames, + ) { } /** @@ -69,6 +74,26 @@ public static function usesFrameworkMethods(ClassReflection $classReflection, ar return true; } + /** + * Determine whether a framework builder or relation implements the method, so the model's newCollection() + * creates the collections it returns. A custom builder's or relation's override keeps its declaration. + */ + public static function isFrameworkQueryMethod(MethodReflection $methodReflection): bool + { + $prototype = $methodReflection->getPrototype(); + + // A relation runs the builder method it forwards. + if ($prototype instanceof ForwardedMethodReflection) { + $methodReflection = $prototype->getForwardedMethod(); + } + + $declaringClass = $methodReflection->getDeclaringClass(); + + return $declaringClass->hasNativeMethod($methodReflection->getName()) + && ($declaringClass->getName() === EloquentBuilder::class + || str_starts_with($declaringClass->getName(), Str::beforeLast(Relation::class, '\\') . '\\')); + } + /** * Get the collection type that collect() makes from a scalar, null or an empty array. * @@ -138,17 +163,8 @@ private function findCollectedByClassName(ClassReflection $modelReflection): ?st // The attribute is instantiated, so its argument may be named. $arguments = $attributes[0]->getArgumentsExpressions(); - $expr = $arguments['collectionClass'] ?? $arguments[0] ?? null; - - if ($expr instanceof ClassConstFetch && $expr->class instanceof Name) { - return $expr->class->toString(); - } - if ($expr instanceof String_) { - return ltrim($expr->value, '\\'); - } - - return null; + return $this->attributeClassNames->resolve($arguments['collectionClass'] ?? $arguments[0] ?? null, $classReflection, $modelReflection); } return null; @@ -217,13 +233,21 @@ public function replaceCollectionsInType(Type $type): Type return $traverse($type); } - $models = $type->getTemplateType(EloquentCollection::class, 'TModel')->getObjectClassNames(); + $modelType = $type->getTemplateType(EloquentCollection::class, 'TModel'); + $models = $modelType->getObjectClassNames(); if (count($models) === 0) { return $type; } - return TypeCombinator::union(...array_map([$this, 'determineCollectionClass'], $models)); + $keyType = $type->getTemplateType(EloquentCollection::class, 'TKey'); + + // Unlike upstream, each model keeps the rest of its value type, such as a pivot shape, and + // the collection keeps its key type. + return TypeCombinator::union(...array_map( + fn (string $model): Type => $this->determineCollectionClass($model, TypeCombinator::intersect($modelType, new ObjectType($model)), $keyType), + $models, + )); }); } } diff --git a/src/phpstan/src/Types/BuilderOf/BuilderOfType.php b/src/phpstan/src/Types/BuilderOf/BuilderOfType.php new file mode 100644 index 000000000..c5fc8c76c --- /dev/null +++ b/src/phpstan/src/Types/BuilderOf/BuilderOfType.php @@ -0,0 +1,205 @@ +, or of a relation path's related model, + * written builder-of. + */ +// @phpstan-ignore phpstanApi.interface (PHPStan's own late-resolvable types are compound, so comparisons resolve them first.) +class BuilderOfType implements CompoundType, LateResolvableType +{ + use LateResolvableTypeTrait; + + /** + * Create a builder-of type. + */ + public function __construct(private Type $type, private BuilderHelper $builderHelper, private ?Type $relationType = null) + { + } + + /** + * Resolve the builder type of each model the type stands for. + */ + protected function getResult(): Type + { + $relatedType = $this->type; + + if ($this->relationType !== null) { + [$relationType, $unknown] = (new RelationOfType($this->type, $this->relationType))->resolveRelations(); + + // Unlike upstream, a path that does not resolve queries an unknown model, never the declaring one. + $relatedTypes = $relationType === null ? [] : [$relationType->getTemplateType(Relation::class, 'TRelatedModel')]; + + if ($unknown) { + $relatedTypes[] = new ObjectType(Model::class); + } + + $relatedType = TypeCombinator::union(...$relatedTypes); + } + + $results = []; + + foreach (TypeUtils::flattenTypes($relatedType) as $modelType) { + foreach ($modelType->getObjectClassNames() as $className) { + $builderType = $this->builderHelper->determineBuilderClass($className, $modelType); + + if ($builderType === null) { + continue; + } + + $results[] = $builderType; + } + } + + // Unlike upstream, which resolves to never, an unknown model such as a relation's `*` has an unknown + // builder, as relation-of gives an unknown relation. + if ($results === [] && (new ObjectType(Model::class))->isSuperTypeOf($relatedType)->maybe()) { + return new GenericObjectType(EloquentBuilder::class, [new MixedType], null, null, [TemplateTypeVariance::createBivariant()]); + } + + return TypeCombinator::union(...$results); + } + + /** + * Determine whether the type can be resolved, which waits for its template types. + */ + public function isResolvable(): bool + { + return ! TypeUtils::containsTemplateType($this->type) + && ($this->relationType === null || ! TypeUtils::containsTemplateType($this->relationType)); + } + + /** + * Get the classes the type refers to. + * + * @return list + */ + public function getReferencedClasses(): array + { + return array_merge($this->type->getReferencedClasses(), $this->relationType?->getReferencedClasses() ?? []); + } + + /** + * Get the template types the type refers to. + * + * @return list + */ + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + // Unlike upstream, the inner types are invariant, as Builder's model is, so a `*` argument resolves + // as it does there instead of to never in a parameter. + $variance = $positionVariance->compose(TemplateTypeVariance::createInvariant()); + + return array_merge( + $this->type->getReferencedTemplateTypes($variance), + $this->relationType?->getReferencedTemplateTypes($variance) ?? [], + ); + } + + /** + * Determine whether the type equals another. + */ + public function equals(Type $type): bool + { + return $type instanceof self && $this->type->equals($type->type) + && ($this->relationType === null + ? $type->relationType === null + : $type->relationType !== null && $this->relationType->equals($type->relationType)); + } + + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + return 'builder-of<' . $this->type->describe($level) + . ($this->relationType === null ? '' : ', ' . $this->relationType->describe($level)) . '>'; + } + + /** + * Map the inner types. + * + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $type = $cb($this->type); + $relationType = $this->relationType === null ? null : $cb($this->relationType); + + if ($this->type === $type && $this->relationType === $relationType) { + return $this; + } + + return new self($type, $this->builderHelper, $relationType); + } + + /** + * Map the inner types together with another builder-of type's. + */ + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (! $right instanceof self || ($this->relationType === null) !== ($right->relationType === null)) { + return $this; + } + + $type = $cb($this->type, $right->type); + $relationType = $this->relationType !== null && $right->relationType !== null + ? $cb($this->relationType, $right->relationType) + : null; + + if ($this->type === $type && $this->relationType === $relationType) { + return $this; + } + + return new self($type, $this->builderHelper, $relationType); + } + + /** + * Convert the type to a PHPDoc node. + */ + public function toPhpDocNode(): TypeNode + { + $types = [$this->type->toPhpDocNode()]; + + if ($this->relationType !== null) { + $types[] = $this->relationType->toPhpDocNode(); + } + + return new GenericTypeNode(new IdentifierTypeNode('builder-of'), $types); + } + + /** + * Generalize the inner types. + */ + public function generalize(GeneralizePrecision $precision): Type + { + return $this->traverse(static fn (Type $type) => $type->generalize($precision)); + } +} diff --git a/src/phpstan/src/Types/BuilderOf/BuilderOfTypeNodeResolverExtension.php b/src/phpstan/src/Types/BuilderOf/BuilderOfTypeNodeResolverExtension.php new file mode 100644 index 000000000..e9fffb381 --- /dev/null +++ b/src/phpstan/src/Types/BuilderOf/BuilderOfTypeNodeResolverExtension.php @@ -0,0 +1,71 @@ + or builder-of type node. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof GenericTypeNode || (string) $typeNode->type !== 'builder-of') { + return null; + } + + if (count($typeNode->genericTypes) < 1 || count($typeNode->genericTypes) > 2) { + return null; + } + + $genericType = $this->typeNodeResolver->resolve($typeNode->genericTypes[0], $nameScope); + + if ($genericType instanceof NeverType || (new ObjectType(Model::class))->isSuperTypeOf($genericType)->no()) { + return null; + } + + $relationType = isset($typeNode->genericTypes[1]) + ? $this->typeNodeResolver->resolve($typeNode->genericTypes[1], $nameScope) + : null; + + if ($relationType !== null && (! $relationType->isString()->yes() || $relationType instanceof NeverType)) { + return null; + } + + return new BuilderOfType($genericType, $this->builderHelper, $relationType); + } + + /** + * Set the resolver used for the inner type nodes. + */ + public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void + { + $this->typeNodeResolver = $typeNodeResolver; + } +} diff --git a/src/phpstan/src/Types/CollectionOf/CollectionOfType.php b/src/phpstan/src/Types/CollectionOf/CollectionOfType.php new file mode 100644 index 000000000..dc0718440 --- /dev/null +++ b/src/phpstan/src/Types/CollectionOf/CollectionOfType.php @@ -0,0 +1,190 @@ + or collection-of. + */ +// @phpstan-ignore phpstanApi.interface (PHPStan's own late-resolvable types are compound, so comparisons resolve them first.) +class CollectionOfType implements CompoundType, LateResolvableType +{ + use LateResolvableTypeTrait; + + /** + * Create a collection-of type. + */ + public function __construct(private Type $type, private CollectionHelper $collectionHelper, private ?Type $keyType = null) + { + } + + /** + * Resolve the collection class of each model the type stands for. + */ + protected function getResult(): Type + { + $keyType = $this->keyType ?? new BenevolentUnionType([new IntegerType, new StringType]); + $results = []; + + foreach (TypeUtils::flattenTypes($this->type) as $modelType) { + foreach ($modelType->getObjectClassNames() as $className) { + if (! (new ObjectType(Model::class))->isSuperTypeOf(new ObjectType($className))->yes()) { + continue; + } + + $results[] = $this->collectionHelper->determineCollectionClass($className, $modelType, $keyType); + } + } + + // Unlike upstream, which resolves to never, an unknown model, such as a relation's `*`, has the base + // model's collection, as builder-of gives an unknown builder. + if ($results === [] && (new ObjectType(Model::class))->isSuperTypeOf($this->type)->maybe()) { + return $this->collectionHelper->determineCollectionClass(Model::class, keyType: $keyType); + } + + return TypeCombinator::union(...$results); + } + + /** + * Determine whether the type can be resolved, which waits for its template types. + */ + public function isResolvable(): bool + { + return ! TypeUtils::containsTemplateType($this->type) + && ($this->keyType === null || ! TypeUtils::containsTemplateType($this->keyType)); + } + + /** + * Get the classes the type refers to. + * + * @return list + */ + public function getReferencedClasses(): array + { + return array_merge($this->type->getReferencedClasses(), $this->keyType?->getReferencedClasses() ?? []); + } + + /** + * Get the template types the type refers to. + * + * @return list + */ + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + // Unlike upstream, the inner types are invariant, as the Eloquent collection's are, so a `*` argument + // resolves as it does there instead of to never in a parameter. + $variance = $positionVariance->compose(TemplateTypeVariance::createInvariant()); + + return array_merge( + $this->type->getReferencedTemplateTypes($variance), + $this->keyType?->getReferencedTemplateTypes($variance) ?? [], + ); + } + + /** + * Determine whether the type equals another. + */ + public function equals(Type $type): bool + { + if (! $type instanceof self || ! $this->type->equals($type->type)) { + return false; + } + + if ($this->keyType === null || $type->keyType === null) { + return $this->keyType === $type->keyType; + } + + return $this->keyType->equals($type->keyType); + } + + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + if ($this->keyType !== null) { + return 'collection-of<' . $this->keyType->describe($level) . ', ' . $this->type->describe($level) . '>'; + } + + return 'collection-of<' . $this->type->describe($level) . '>'; + } + + /** + * Map the inner types. + * + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $type = $cb($this->type); + $keyType = $this->keyType !== null ? $cb($this->keyType) : null; + + if ($this->type === $type && $this->keyType === $keyType) { + return $this; + } + + return new self($type, $this->collectionHelper, $keyType); + } + + /** + * Map the inner types together with another collection-of type's. + */ + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (! $right instanceof self) { + return $this; + } + + $type = $cb($this->type, $right->type); + $keyType = $this->keyType !== null && $right->keyType !== null ? $cb($this->keyType, $right->keyType) : $this->keyType; + + if ($this->type === $type && $this->keyType === $keyType) { + return $this; + } + + return new self($type, $this->collectionHelper, $keyType); + } + + /** + * Convert the type to a PHPDoc node. + */ + public function toPhpDocNode(): TypeNode + { + $genericTypes = $this->keyType !== null + ? [$this->keyType->toPhpDocNode(), $this->type->toPhpDocNode()] + : [$this->type->toPhpDocNode()]; + + return new GenericTypeNode(new IdentifierTypeNode('collection-of'), $genericTypes); + } + + /** + * Generalize the inner types. + */ + public function generalize(GeneralizePrecision $precision): Type + { + return $this->traverse(static fn (Type $type) => $type->generalize($precision)); + } +} diff --git a/src/phpstan/src/Types/CollectionOf/CollectionOfTypeNodeResolverExtension.php b/src/phpstan/src/Types/CollectionOf/CollectionOfTypeNodeResolverExtension.php new file mode 100644 index 000000000..fcdfa6ad5 --- /dev/null +++ b/src/phpstan/src/Types/CollectionOf/CollectionOfTypeNodeResolverExtension.php @@ -0,0 +1,75 @@ + or collection-of type node. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof GenericTypeNode || (string) $typeNode->type !== 'collection-of') { + return null; + } + + $genericTypes = $typeNode->genericTypes; + + if (count($genericTypes) !== 1 && count($genericTypes) !== 2) { + return null; + } + + // Like array, the optional key type comes first. + $keyType = count($genericTypes) === 2 ? $this->typeNodeResolver->resolve($genericTypes[0], $nameScope) : null; + $genericType = $this->typeNodeResolver->resolve($genericTypes[count($genericTypes) - 1], $nameScope); + + if ($genericType instanceof NeverType || (new ObjectType(Model::class))->isSuperTypeOf($genericType)->no()) { + return null; + } + + if ($keyType !== null && (new UnionType([new IntegerType, new StringType]))->isSuperTypeOf($keyType)->no()) { + return null; + } + + return new CollectionOfType($genericType, $this->collectionHelper, $keyType); + } + + /** + * Set the resolver used for the inner type nodes. + */ + public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void + { + $this->typeNodeResolver = $typeNodeResolver; + } +} diff --git a/src/phpstan/src/Types/GenericEloquentBuilderTypeNodeResolverExtension.php b/src/phpstan/src/Types/GenericEloquentBuilderTypeNodeResolverExtension.php new file mode 100644 index 000000000..78a42a6b1 --- /dev/null +++ b/src/phpstan/src/Types/GenericEloquentBuilderTypeNodeResolverExtension.php @@ -0,0 +1,109 @@ +`. + */ +#[AutowiredService] +class GenericEloquentBuilderTypeNodeResolverExtension implements TypeNodeResolverExtension +{ + private const int KIND_MODEL = 1; + + private const int KIND_BUILDER = 2; + + /** + * This extension is consulted for every PHPDoc type node, so the verdict + * for a resolved identifier (model class, builder class, or neither) is + * memoized to avoid repeated reflection lookups for common union members + * like `string` or `null`. + * + * @var array + */ + private array $classKind = []; + + /** + * Create the generic Eloquent builder type node resolver. + */ + public function __construct(private ReflectionProvider $provider, private BuilderHelper $builderHelper) + { + } + + /** + * Resolve a two-member union of a model and a builder class. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof UnionTypeNode || count($typeNode->types) !== 2) { + return null; + } + + $modelTypeName = null; + $builderTypeName = null; + + foreach ($typeNode->types as $innerTypeNode) { + // A matching union needs a model member and a builder member, so + // any member that is not a plain identifier means we can bail out. + if (! $innerTypeNode instanceof IdentifierTypeNode) { + return null; + } + + $resolvedName = $nameScope->resolveStringName($innerTypeNode->name); + $kind = $this->resolveClassKind($resolvedName); + + if ($kind === self::KIND_MODEL && $modelTypeName === null) { + $modelTypeName = $resolvedName; + } elseif ($kind === self::KIND_BUILDER && $builderTypeName === null) { + $builderTypeName = $resolvedName; + } + } + + if ($modelTypeName === null || $builderTypeName === null) { + return null; + } + + return $this->builderHelper->getBuilderType(new ObjectType($builderTypeName), new ObjectType($modelTypeName)); + } + + /** + * Determine whether the name is a model class, a builder class, or neither. + */ + private function resolveClassKind(string $resolvedName): ?int + { + if (array_key_exists($resolvedName, $this->classKind)) { + return $this->classKind[$resolvedName]; + } + + $kind = null; + + if ($this->provider->hasClass($resolvedName)) { + $classReflection = $this->provider->getClass($resolvedName); + + if ($classReflection->is(Model::class)) { + $kind = self::KIND_MODEL; + } elseif ($classReflection->is(Builder::class)) { + $kind = self::KIND_BUILDER; + } + } + + return $this->classKind[$resolvedName] = $kind; + } +} diff --git a/src/phpstan/src/Types/RelationOf/RelationOfType.php b/src/phpstan/src/Types/RelationOf/RelationOfType.php new file mode 100644 index 000000000..536cb2242 --- /dev/null +++ b/src/phpstan/src/Types/RelationOf/RelationOfType.php @@ -0,0 +1,245 @@ +. + */ +// @phpstan-ignore phpstanApi.interface (PHPStan's own late-resolvable types are compound, so comparisons resolve them first.) +class RelationOfType implements CompoundType, LateResolvableType +{ + use LateResolvableTypeTrait; + + /** + * Create a relation-of type. + */ + public function __construct(private Type $model, private Type $key) + { + } + + /** + * Resolve the relationship type, or a relation of unknown models. + */ + protected function getResult(): Type + { + [$relationType, $unknown] = $this->resolveRelations(); + + $relations = $relationType === null ? [] : [$relationType]; + + if ($unknown) { + $relations[] = new GenericObjectType( + Relation::class, + [new MixedType, new MixedType, new MixedType], + null, + null, + array_fill(0, 3, TemplateTypeVariance::createBivariant()), + ); + } + + $relation = TypeCombinator::union(...$relations); + + // Eager loading creates the relation on a fresh model instance. + $relation = TypeTraverser::map($relation, static fn (Type $type, callable $traverse): Type => $type instanceof StaticType ? $type->getStaticObjectType() : $traverse($type)); + + return TypeUtils::toBenevolentUnion($relation); + } + + /** + * Resolve the relationships the paths name. + * + * Unlike upstream, a path that does not resolve is unknown rather than missing, kept beside the + * paths that do: the name can be a non-constant string, belong to an abstract model, or name a + * relation that resolveRelationUsing() registers at boot. + * + * @internal + * + * @return array{null|Type, bool} the relationship type of the paths that resolve, and whether any path does not + */ + public function resolveRelations(): array + { + $relations = $this->key->getConstantStrings(); + + if ($relations === []) { + return [null, true]; + } + + $results = []; + $unknown = false; + + foreach ($relations as $relation) { + [$relationType, $pathUnknown] = $this->followRelationPath($relation->getValue()); + $unknown = $unknown || $pathUnknown; + + if ($relationType !== null) { + $results[] = $relationType; + } + } + + return [$results === [] ? null : TypeCombinator::union(...$results), $unknown]; + } + + /** + * Follow a dotted relation path, ignoring a column list after a colon. + * + * Unlike upstream, a model alternative without the relation is unknown, as a path that does not resolve is. + * + * @return array{null|Type, bool} the relationship type of the alternatives the path resolves for, and whether + * any does not resolve + */ + private function followRelationPath(string $path): array + { + $relatedType = $this->model; + $relationType = null; + $unknown = false; + + foreach (explode('.', explode(':', $path, 2)[0]) as $relationName) { + $relations = []; + + foreach (TypeUtils::flattenTypes($relatedType) as $modelType) { + $returnType = $modelType->hasMethod($relationName)->yes() + ? ParametersAcceptorSelector::selectFromTypes([], $modelType->getMethod($relationName, new OutOfClassScope)->getVariants(), false)->getReturnType() + : null; + + if ($returnType === null || ! (new ObjectType(Relation::class))->isSuperTypeOf($returnType)->yes()) { + $unknown = true; + + continue; + } + + $relations[] = $returnType; + } + + if ($relations === []) { + return [null, true]; + } + + $relationType = TypeCombinator::union(...$relations); + $relatedType = $relationType->getTemplateType(Relation::class, 'TRelatedModel'); + } + + return [$relationType, $unknown]; + } + + /** + * Determine whether the type can be resolved, which waits for its template types. + */ + public function isResolvable(): bool + { + return ! TypeUtils::containsTemplateType($this->model) && ! TypeUtils::containsTemplateType($this->key); + } + + /** + * Get the classes the type refers to. + * + * @return list + */ + public function getReferencedClasses(): array + { + return array_merge($this->model->getReferencedClasses(), $this->key->getReferencedClasses()); + } + + /** + * Get the template types the type refers to. + * + * @return list + */ + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + // Unlike upstream, the inner types are invariant, as a relation's declaring model is, so a `*` argument + // resolves as it does there instead of to never in a parameter. + $variance = $positionVariance->compose(TemplateTypeVariance::createInvariant()); + + return array_merge( + $this->model->getReferencedTemplateTypes($variance), + $this->key->getReferencedTemplateTypes($variance), + ); + } + + /** + * Determine whether the type equals another. + */ + public function equals(Type $type): bool + { + return $type instanceof self && $this->model->equals($type->model) && $this->key->equals($type->key); + } + + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + return 'relation-of<' . $this->model->describe($level) . ', ' . $this->key->describe($level) . '>'; + } + + /** + * Map the inner types. + * + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $model = $cb($this->model); + $key = $cb($this->key); + + return $model === $this->model && $key === $this->key ? $this : new self($model, $key); + } + + /** + * Map the inner types together with another relation-of type's. + */ + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (! $right instanceof self) { + return $this; + } + + $model = $cb($this->model, $right->model); + $key = $cb($this->key, $right->key); + + return $model === $this->model && $key === $this->key ? $this : new self($model, $key); + } + + /** + * Convert the type to a PHPDoc node. + */ + public function toPhpDocNode(): TypeNode + { + return new GenericTypeNode(new IdentifierTypeNode('relation-of'), [$this->model->toPhpDocNode(), $this->key->toPhpDocNode()]); + } + + /** + * Generalize the inner types. + */ + public function generalize(GeneralizePrecision $precision): Type + { + return $this->traverse(static fn (Type $type) => $type->generalize($precision)); + } +} diff --git a/src/phpstan/src/Types/RelationOf/RelationOfTypeNodeResolverExtension.php b/src/phpstan/src/Types/RelationOf/RelationOfTypeNodeResolverExtension.php new file mode 100644 index 000000000..2fbb786d0 --- /dev/null +++ b/src/phpstan/src/Types/RelationOf/RelationOfTypeNodeResolverExtension.php @@ -0,0 +1,56 @@ + type node. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof GenericTypeNode || (string) $typeNode->type !== 'relation-of' || count($typeNode->genericTypes) !== 2) { + return null; + } + + $model = $this->typeNodeResolver->resolve($typeNode->genericTypes[0], $nameScope); + $key = $this->typeNodeResolver->resolve($typeNode->genericTypes[1], $nameScope); + + if ($model instanceof NeverType || (new ObjectType(Model::class))->isSuperTypeOf($model)->no()) { + return null; + } + + if ($key instanceof NeverType || ! $key->isString()->yes()) { + return null; + } + + return new RelationOfType($model, $key); + } + + /** + * Set the resolver used for the inner type nodes. + */ + public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void + { + $this->typeNodeResolver = $typeNodeResolver; + } +} diff --git a/tests/PHPStan/Type/data/builder-of-type.php b/tests/PHPStan/Type/data/builder-of-type.php new file mode 100644 index 000000000..fd113f9d9 --- /dev/null +++ b/tests/PHPStan/Type/data/builder-of-type.php @@ -0,0 +1,351 @@ + $userBuilder + * @param builder-of<\App\Account> $accountBuilder + * @param builder-of<\App\Team> $teamBuilder + * @param builder-of<\App\User|\App\Team> $union + */ +function test($userBuilder, Builder $accountBuilder, Builder $teamBuilder, Builder $union): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $userBuilder); + assertType('Hypervel\Database\Eloquent\Builder', $accountBuilder); + assertType('App\ChildTeamBuilder', $teamBuilder); + assertType('App\ChildTeamBuilder|Hypervel\Database\Eloquent\Builder', $union); + + assertType('Hypervel\Database\Eloquent\Builder', genericMethod(\App\User::class)); + assertType('Hypervel\Database\Eloquent\Builder', genericMethod(\App\Account::class)); + assertType('App\ChildTeamBuilder', genericMethod(\App\Team::class)); +} + +/** + * @template T of Model + * + * @param class-string $class + * + * @return builder-of + */ +function genericMethod(string $class): Builder +{ + return $class::query(); +} + +class ModelWithStaticBuilder extends Model +{ + /** @return builder-of */ + public function builder(): Builder + { + return $this->newQuery(); + } + + /** @return builder-of<$this> */ + public function instanceBuilder(): Builder + { + return $this->newEloquentBuilder($this->newBaseQueryBuilder())->setModel($this); + } + + public function testStaticBuilder(): void + { + assertType('Hypervel\Database\Eloquent\Builder', $this->builder()); + assertType('Hypervel\Database\Eloquent\Builder<$this(BuilderOfType\ModelWithStaticBuilder)>', $this->instanceBuilder()); + } +} + +class ChildModelWithStaticBuilder extends ModelWithStaticBuilder {} + +function testStaticBuilderOutsideClass(ModelWithStaticBuilder $model, ChildModelWithStaticBuilder $child): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $model->builder()); + assertType('Hypervel\Database\Eloquent\Builder', $child->builder()); +} + +/** @template TValue */ +class GenericModel extends Model {} + +/** + * @param builder-of> $generic + * @param builder-of|\App\Team> $union + * @param builder-of<\App\User&object{extra: string}> $intersection + * @param builder-of<\App\User>|null $nullable + */ +function testModelTypes($generic, $union, $intersection, $nullable): void +{ + assertType('Hypervel\Database\Eloquent\Builder>', $generic); + assertType('App\ChildTeamBuilder|Hypervel\Database\Eloquent\Builder>', $union); + assertType('Hypervel\Database\Eloquent\Builder', $intersection); + assertType('Hypervel\Database\Eloquent\Builder|null', $nullable); +} + +class PostWithBuilderMethod extends \App\Post +{ + /** @return builder-of */ + public function builder(): Builder + { + return $this->newQuery(); + } + + public function testCustomBuilder(): void + { + assertType('App\PostBuilder', $this->builder()); + } +} + +final class FinalPostWithBuilderMethod extends PostWithBuilderMethod {} + +/** @param GenericModel $generic */ +function testInheritedAndGenericBuilders(FinalPostWithBuilderMethod $post, GenericModel $generic): void +{ + assertType('App\PostBuilder', $post->builder()); + assertType('Hypervel\Database\Eloquent\Builder>', $generic->newQuery()); + assertType('Hypervel\Database\Eloquent\Builder>', $generic::query()); +} + +class ModelWithNeverQuery extends Model +{ + public static function query(): never + { + throw new \LogicException(); + } +} + +function testNeverQuery(): void +{ + assertType('never', ModelWithNeverQuery::query()); +} + +/** + * @param builder-of<\App\User, 'accounts'> $accounts + * @param builder-of<\App\User, 'posts'> $posts + * @param builder-of<\App\User, 'posts.comments'> $comments + * @param builder-of<\App\User, 'posts:id'> $postColumns + * @param builder-of<\App\User, 'posts.comments:id,post_id'> $commentColumns + */ +function testRelationshipBuilders($accounts, $posts, $comments, $postColumns, $commentColumns): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $accounts); + assertType('App\PostBuilder', $posts); + assertType('Hypervel\Database\Eloquent\Builder', $comments); + assertType('App\PostBuilder', $postColumns); + assertType('Hypervel\Database\Eloquent\Builder', $commentColumns); +} + +/** + * @param builder-of<\App\BareRelations\Owner> $owner + * @param builder-of<\App\BareRelations\Owner, 'items'> $items + * @param builder-of<\App\BareRelations\Owner, 'items.category'> $category + * @param builder-of<\App\BareRelations\Owner, 'items.category.labels'> $labels + * @param builder-of<\App\BareRelations\Owner, 'missing'> $missing + * @param builder-of<\App\BareRelations\Owner, string> $unknown + */ +function testBareRelationshipBuilders($owner, $items, $category, $labels, $missing, $unknown): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $owner); + assertType('Hypervel\Database\Eloquent\Builder', $items); + assertType('Hypervel\Database\Eloquent\Builder', $category); + assertType('Hypervel\Database\Eloquent\Builder', $labels); + assertType('Hypervel\Database\Eloquent\Builder', $missing); + assertType('Hypervel\Database\Eloquent\Builder', $unknown); +} + +abstract class AbstractSubject extends Model +{ + /** @return HasMany<\App\Account, $this> */ + public function accounts(): HasMany + { + throw new \LogicException(); + } +} + +class ModelWithAbstractRelation extends Model +{ + /** @return HasMany */ + public function subjects(): HasMany + { + throw new \LogicException(); + } + + /** @return MorphTo<\App\Account|AbstractSubject, $this> */ + public function subject(): MorphTo + { + throw new \LogicException(); + } +} + +/** + * @param builder-of $subjects + * @param builder-of $throughAbstract + * @param builder-of $missingOnAbstract + * @param builder-of $missingOnConcrete + * @param builder-of $partOfUnion + * @param builder-of $missingOnUnion + */ +function testAbstractRelatedModels($subjects, $throughAbstract, $missingOnAbstract, $missingOnConcrete, $partOfUnion, $missingOnUnion): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $subjects); + assertType('Hypervel\Database\Eloquent\Builder', $throughAbstract); + assertType('Hypervel\Database\Eloquent\Builder', $missingOnAbstract); + assertType('Hypervel\Database\Eloquent\Builder', $missingOnConcrete); + // Unlike upstream, a model alternative without the relation, here the abstract subject, is unknown. + assertType('Hypervel\Database\Eloquent\Builder', $partOfUnion); + assertType('Hypervel\Database\Eloquent\Builder', $missingOnUnion); +} + +/** + * @param builder-of<\App\User, 'posts'|'accounts'> $relations + * @param builder-of<\App\User, 'posts'|'missing'> $partlyMissing + * @param builder-of<\App\User, 'posts'|'address.addressable.missing'> $partlyUnknown + * @param builder-of<\App\User, 'posts.comments'|'posts.missing'> $nested + * @param builder-of<\App\User|\App\Team, 'posts'> $models + * @param builder-of<\App\User|\App\Team, 'missing'> $missingModels + * @param builder-of<\App\User&object{extra: string}, 'accounts'> $intersection + * @param builder-of<\App\User, 'accounts'>|null $nullable + */ +function testRelationshipUnions($relations, $partlyMissing, $partlyUnknown, $nested, $models, $missingModels, $intersection, $nullable): void +{ + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Builder', $relations); + assertType('Hypervel\Database\Eloquent\Builder', $partlyMissing); + assertType('Hypervel\Database\Eloquent\Builder', $partlyUnknown); + assertType('Hypervel\Database\Eloquent\Builder', $nested); + // Unlike upstream, Team's missing posts relation is unknown. + assertType('Hypervel\Database\Eloquent\Builder', $models); + assertType('Hypervel\Database\Eloquent\Builder', $missingModels); + assertType('Hypervel\Database\Eloquent\Builder', $intersection); + assertType('Hypervel\Database\Eloquent\Builder|null', $nullable); +} + +/** + * @param builder-of<\App\User, 'missing'> $missing + * @param builder-of<\App\User, 'posts.missing'> $nested + * @param builder-of<\App\User, 'missing'|'posts.missing'> $union + * @param builder-of<\App\User, 'getAllCapsName'> $nonRelation + * @param builder-of<\App\User, string> $unknown + * @param builder-of<\App\User, non-empty-string> $nonEmpty + * @param builder-of<\App\User, ''|'posts.'|'.posts'|'posts..comments'> $malformed + * @param builder-of<\App\Team, 'missing'> $custom + */ +function testRelationshipFallbacks($missing, $nested, $union, $nonRelation, $unknown, $nonEmpty, $malformed, $custom): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $missing); + assertType('Hypervel\Database\Eloquent\Builder', $nested); + assertType('Hypervel\Database\Eloquent\Builder', $union); + assertType('Hypervel\Database\Eloquent\Builder', $nonRelation); + assertType('Hypervel\Database\Eloquent\Builder', $unknown); + assertType('Hypervel\Database\Eloquent\Builder', $nonEmpty); + assertType('Hypervel\Database\Eloquent\Builder', $malformed); + assertType('Hypervel\Database\Eloquent\Builder', $custom); +} + +/** + * @template TValue + * @method HasMany<\App\Team, $this> teams() + */ +class ModelWithRelationships extends \App\User +{ + /** @return HasMany, $this> */ + public function genericModels(): HasMany + { + throw new \LogicException(); + } + + /** @return MorphTo<\App\Post|\App\Team, $this> */ + public function subject(): MorphTo + { + return $this->morphTo(); + } + + /** @return HasMany<\App\Account, $this>|null */ + public function nullableRelation(): ?HasMany + { + return null; + } + + /** @return builder-of */ + public function accountBuilder(): Builder + { + return $this->accounts()->getQuery(); + } + + public function testInsideClass(): void + { + assertType('Hypervel\Database\Eloquent\Builder', $this->accountBuilder()); + } +} + +/** + * @param builder-of, 'genericModels'> $generic + * @param builder-of, 'subject'> $polymorphic + * @param builder-of, 'subject.comments'> $nestedUnion + * @param builder-of, 'teams'> $annotation + * @param builder-of, 'nullableRelation'> $nullableRelation + * @param builder-of<\App\Comment, 'commentable'> $broad + * @param builder-of<\App\Comment, 'commentable.comments'> $broadNested + * @param builder-of<\App\User, 'syncableRelation'> $customRelation + * @param ModelWithRelationships $model + */ +function testRelatedModelTypes($generic, $polymorphic, $nestedUnion, $annotation, $nullableRelation, $broad, $broadNested, $customRelation, $model): void +{ + assertType('Hypervel\Database\Eloquent\Builder>', $generic); + assertType('App\ChildTeamBuilder|App\PostBuilder', $polymorphic); + // Unlike upstream, a subject alternative without a comments relation is unknown. + assertType('Hypervel\Database\Eloquent\Builder', $nestedUnion); + assertType('App\ChildTeamBuilder', $annotation); + assertType('Hypervel\Database\Eloquent\Builder', $nullableRelation); + assertType('Hypervel\Database\Eloquent\Builder', $broad); + assertType('Hypervel\Database\Eloquent\Builder', $broadNested); + assertType('Hypervel\Database\Eloquent\Builder', $customRelation); + assertType('Hypervel\Database\Eloquent\Builder', $model->accountBuilder()); +} + +/** + * @template TModel of Model + * @template TRelation of string + * @param class-string $class + * @param TRelation $relation + * @return builder-of + */ +function genericRelationshipBuilder(string $class, string $relation): Builder +{ + throw new \LogicException(); +} + +/** + * @template TRelation of string + * @param builder-of<\App\User, TRelation> $builder + */ +function testUnresolvedRelationshipTemplate($builder): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $builder); +} + +/** @param 'posts'|'missing' $relation */ +function testRelationshipTemplates(string $relation, string $unknown): void +{ + assertType('App\PostBuilder', genericRelationshipBuilder(\App\User::class, 'posts')); + assertType('Hypervel\Database\Eloquent\Builder', genericRelationshipBuilder(\App\User::class, 'posts.comments')); + assertType('Hypervel\Database\Eloquent\Builder', genericRelationshipBuilder(\App\User::class, $relation)); + assertType('Hypervel\Database\Eloquent\Builder', genericRelationshipBuilder(\App\User::class, 'missing')); + assertType('Hypervel\Database\Eloquent\Builder', genericRelationshipBuilder(\App\User::class, $unknown)); +} + +/** + * @param builder-of $unknown + * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *> $relation + * @param builder-of<\App\MissingModel> $missing + */ +function unknownModel($unknown, \Hypervel\Database\Eloquent\Relations\Relation $relation, Builder $query, $missing): void +{ + // Unlike upstream, which resolves to never, an unknown model's builder is unknown too. + assertType('Hypervel\Database\Eloquent\Builder<*>', $unknown); + assertType('Hypervel\Database\Eloquent\Builder<*>', $relation->getRelationExistenceCountQuery($query, $query)); + + // Unlike upstream, which crashes analysis, a missing class is an unknown model. + assertType('Hypervel\Database\Eloquent\Builder<*>', $missing); +} diff --git a/tests/PHPStan/Type/data/collection-of-type.php b/tests/PHPStan/Type/data/collection-of-type.php new file mode 100644 index 000000000..37068d12f --- /dev/null +++ b/tests/PHPStan/Type/data/collection-of-type.php @@ -0,0 +1,69 @@ + $param + * @param collection-of<\App\Role> $param2 + * @param collection-of<\App\User|\App\Role> $union + */ +function test($param, Collection $param2, Collection $union): void +{ + assertType('Hypervel\Database\Eloquent\Collection<(int|string), App\User>', $param); + assertType('App\RoleCollection<(int|string), App\Role>', $param2); + assertType('App\RoleCollection<(int|string), App\Role>|Hypervel\Database\Eloquent\Collection<(int|string), App\User>', $union); + + assertType('Hypervel\Database\Eloquent\Collection<(int|string), App\User>', genericMethod(\App\User::class)); + assertType('App\RoleCollection<(int|string), App\Role>', genericMethod(\App\Role::class)); +} + +/** + * @template T of Model + * + * @param class-string $class + * + * @return collection-of + */ +function genericMethod(string $class): Collection +{ + return (new $class)->newCollection(); +} + +/** + * @param collection-of $unknown + * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *> $relation + * @param collection-of $constantCollected + */ +function unknownModels($unknown, \Hypervel\Database\Eloquent\Relations\Relation $relation, $constantCollected): void +{ + // Unlike upstream, which resolves to never, an unknown model has the base model's collection. + assertType('Hypervel\Database\Eloquent\Collection<(int|string), Hypervel\Database\Eloquent\Model>', $unknown); + assertType('Hypervel\Database\Eloquent\Collection', $relation->get()); + + // Unlike upstream, a class constant's value names the collection, as instantiating the attribute reads it. + assertType('CollectionOfType\ConstantCollection<(int|string), CollectionOfType\ConstantCollectedModel>', $constantCollected); +} + +class CollectionNames +{ + public const string COLLECTION = ConstantCollection::class; +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + */ +class ConstantCollection extends Collection +{ +} + +#[\Hypervel\Database\Eloquent\Attributes\CollectedBy(CollectionNames::COLLECTION)] +class ConstantCollectedModel extends Model +{ +} diff --git a/tests/PHPStan/Type/data/relation-of-type.php b/tests/PHPStan/Type/data/relation-of-type.php new file mode 100644 index 000000000..8ff3c1824 --- /dev/null +++ b/tests/PHPStan/Type/data/relation-of-type.php @@ -0,0 +1,93 @@ + $accounts + * @param relation-of $group + * @param relation-of $nested + * @param relation-of $columns + * @param relation-of $union + * @param relation-of|null $nullable + * @param relation-of $intersection + */ +function relations($accounts, $group, $nested, $columns, $union, $nullable, $intersection): void +{ + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $accounts); + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $group); + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $nested); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $columns); + assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $union); + assertType('Hypervel\Database\Eloquent\Relations\HasMany|null', $nullable); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $intersection); +} + +/** + * @param relation-of $dynamic + * @param relation-of $nonEmpty + * @param relation-of $missing + * @param relation-of $missingNested + * @param relation-of $nonRelation + * @param relation-of $partlyMissing + * @param relation-of $modelUnion + */ +function fallbacks($dynamic, $nonEmpty, $missing, $missingNested, $nonRelation, $partlyMissing, $modelUnion): void +{ + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $dynamic); + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $nonEmpty); + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $missing); + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $missingNested); + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $nonRelation); + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $partlyMissing); + // Unlike upstream, Team's missing accounts relation is unknown. + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $modelUnion); +} + +/** + * @template TModel of Model + * @template TRelation of string + * @param TModel $model + * @param TRelation $relation + * @return relation-of + */ +function genericRelation(Model $model, string $relation) +{ + throw new \LogicException(); +} + +/** @template TValue */ +class GenericRelated extends Model +{ +} + +/** @template TValue */ +class GenericParent extends Model +{ + /** @return HasMany, $this> */ + public function children(): HasMany + { + throw new \LogicException(); + } + + /** @return MorphTo */ + public function subject(): MorphTo + { + return $this->morphTo(); + } +} + +/** @param GenericParent $parent */ +function generics(User $user, GenericParent $parent): void +{ + assertType('Hypervel\Database\Eloquent\Relations\HasMany', genericRelation($user, 'accounts')); + assertType('Hypervel\Database\Eloquent\Relations\HasMany, RelationOfType\GenericParent>', genericRelation($parent, 'children')); + assertType('Hypervel\Database\Eloquent\Relations\MorphTo>', genericRelation($parent, 'subject')); +} diff --git a/tests/PHPStan/application/app/BareRelations/Category.php b/tests/PHPStan/application/app/BareRelations/Category.php new file mode 100644 index 000000000..aa0a97878 --- /dev/null +++ b/tests/PHPStan/application/app/BareRelations/Category.php @@ -0,0 +1,15 @@ +hasMany(Label::class); + } +} diff --git a/tests/PHPStan/application/app/BareRelations/Item.php b/tests/PHPStan/application/app/BareRelations/Item.php new file mode 100644 index 000000000..6310d2e89 --- /dev/null +++ b/tests/PHPStan/application/app/BareRelations/Item.php @@ -0,0 +1,15 @@ +belongsTo(Category::class, 'category_id'); + } +} diff --git a/tests/PHPStan/application/app/BareRelations/Label.php b/tests/PHPStan/application/app/BareRelations/Label.php new file mode 100644 index 000000000..c155ac850 --- /dev/null +++ b/tests/PHPStan/application/app/BareRelations/Label.php @@ -0,0 +1,9 @@ +hasMany(Item::class); + } +} From 19ee1a53ed6268e424204a93f0c7ca215e8424c8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:32:05 +0000 Subject: [PATCH 117/303] Preserve Eloquent forwarding and macro registration semantics Describe forwarded methods with the receiving class while preserving late-static model templates. Follow native methods, local and global macros, scopes, passthru methods and query forwarding in runtime order; keep passthru query rows unchanged. Distinguish local macro callback binding from global registration and preserve instance mixin results through builders and models. Keep forwarded instance methods separate from builder static macro dispatch. Track custom builder, query and attribute dependencies for cached analysis. Add focused forwarding, registration, scope and warm dependency regressions. Validated with formatting, PHPStan/types and the complete analyzer suite at checkpoint signoff. --- .../src/Methods/BuilderDependencies.php | 12 +- .../src/Methods/BuilderMacroResolver.php | 13 +- .../ForwardedBuilderMethodExtension.php | 174 +++++----- .../Methods/ForwardedModelMethodExtension.php | 37 +- .../Methods/MacroRegistrationExtension.php | 72 ++++ .../src/Methods/ModelScopeMethodResolver.php | 38 ++- .../src/Methods/NamedScopeMethodExtension.php | 66 ++-- .../DynamicWhereParameterReflection.php | 61 ++++ .../EloquentBuilderMethodReflection.php | 164 +++++++++ .../ForwardedFluentMethodReflection.php | 120 +------ .../Reflection/ForwardedMethodReflection.php | 153 +++++++++ .../ForwardedModelMethodReflection.php | 120 ++----- .../MacroRegistrationMethodReflection.php | 52 +++ .../Reflection/ProxiedParameterReflection.php | 5 +- .../application/app/Models/BuilderNames.php | 10 + .../application/app/Models/BuilderUsage.php | 2 +- .../application/app/Models/Member.php | 12 + .../application/app/Models/MemberUsage.php | 19 ++ .../application/app/Models/UserQuery.php | 2 +- .../ForwardedBuilderMethodExtensionTest.php | 13 +- tests/PHPStan/ModelPropertyDependencyTest.php | 24 +- .../Type/data/custom-eloquent-builder.php | 318 ++++++++++++++++++ tests/PHPStan/Type/data/macros.php | 58 +++- .../Type/data/model-scope-attribute-l12.php | 49 +++ tests/PHPStan/Type/data/model-scopes.php | 75 +++++ tests/PHPStan/bootstrap.php | 8 + 26 files changed, 1311 insertions(+), 366 deletions(-) create mode 100644 src/phpstan/src/Methods/MacroRegistrationExtension.php create mode 100644 src/phpstan/src/Reflection/DynamicWhereParameterReflection.php create mode 100644 src/phpstan/src/Reflection/EloquentBuilderMethodReflection.php create mode 100644 src/phpstan/src/Reflection/ForwardedMethodReflection.php create mode 100644 src/phpstan/src/Reflection/MacroRegistrationMethodReflection.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/BuilderNames.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Member.php create mode 100644 tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/MemberUsage.php create mode 100644 tests/PHPStan/Type/data/custom-eloquent-builder.php create mode 100644 tests/PHPStan/Type/data/model-scope-attribute-l12.php create mode 100644 tests/PHPStan/Type/data/model-scopes.php diff --git a/src/phpstan/src/Methods/BuilderDependencies.php b/src/phpstan/src/Methods/BuilderDependencies.php index e3524fbc0..40d14b610 100644 --- a/src/phpstan/src/Methods/BuilderDependencies.php +++ b/src/phpstan/src/Methods/BuilderDependencies.php @@ -31,6 +31,7 @@ class BuilderDependencies */ public function __construct( private readonly DeclarationDependencyTracker $declarationDependencyTracker, + private readonly BuilderHelper $builderHelper, ) { } @@ -66,11 +67,12 @@ private function resolveInputs(ClassReflection $builder): array $classNames[] = $builder->getName(); } - $queryType = $builder->getNativeMethod('getQuery')->getVariants()[0]->getReturnType(); - - foreach ($queryType->getObjectClassNames() as $queryClassName) { - if ($queryClassName !== QueryBuilder::class) { - $classNames[] = $queryClassName; + // Ordinary calls run on the stored query and passthru calls on toBase()'s. + foreach ($this->builderHelper->queryTypes($builder) as $queryType) { + foreach ($queryType->getObjectClassNames() as $queryClassName) { + if ($queryClassName !== QueryBuilder::class && ! in_array($queryClassName, $classNames, true)) { + $classNames[] = $queryClassName; + } } } diff --git a/src/phpstan/src/Methods/BuilderMacroResolver.php b/src/phpstan/src/Methods/BuilderMacroResolver.php index 5e086d723..e30a1f280 100644 --- a/src/phpstan/src/Methods/BuilderMacroResolver.php +++ b/src/phpstan/src/Methods/BuilderMacroResolver.php @@ -5,6 +5,7 @@ namespace Hypervel\PHPStan\Methods; use Hypervel\PHPStan\Reflection\ForwardedFluentMethodReflection; +use Hypervel\PHPStan\Reflection\ForwardedMethodReflection; use PHPStan\Analyser\OutOfClassScope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -50,8 +51,11 @@ public function hasMacro(ClassReflection $modelClass, string $methodName): bool /** * Resolve a trait macro called on a builder for the given model type. + * + * The signature names the model by the signature model type, the receiver's own template type for it, so + * PHPStan binds the model only after replacing the receiver's static types. */ - public function resolve(ClassReflection $builderClass, Type $modelType, string $methodName): ?MethodReflection + public function resolve(ClassReflection $builderClass, Type $modelType, Type $signatureModelType, string $methodName): ?MethodReflection { $modelClasses = $modelType->getObjectClassReflections(); @@ -66,14 +70,15 @@ public function resolve(ClassReflection $builderClass, Type $modelType, string $ } $interfaceType = $macroInterface->isGeneric() - ? new GenericObjectType($macroInterface->getName(), [$modelType]) + ? new GenericObjectType($macroInterface->getName(), [$signatureModelType]) : new ObjectType($macroInterface->getName()); - $method = $interfaceType->getMethod($methodName, $this->scope); + // @phpstan-ignore phpstanApi.method (ObjectType::getMethod() resolves template types to their bounds.), phpstanApi.method (Same lookup.) + $method = $interfaceType->getUnresolvedMethodPrototype($methodName, $this->scope)->doNotResolveTemplateTypeMapToBounds()->getTransformedMethod(); // Fluent macros return the builder they run on. return $macroInterface->getNativeMethod($methodName)->getVariants()[0]->getReturnType() instanceof StaticType ? new ForwardedFluentMethodReflection($builderClass, $method) - : $method; + : new ForwardedMethodReflection($builderClass, $method); } /** diff --git a/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php b/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php index e36de4c2d..212257e5b 100644 --- a/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php +++ b/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php @@ -6,16 +6,22 @@ use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\Database\Query\Builder as QueryBuilder; +use Hypervel\PHPStan\Reflection\DynamicWhereParameterReflection; +use Hypervel\PHPStan\Reflection\EloquentBuilderMethodReflection; use Hypervel\PHPStan\Reflection\ForwardedFluentMethodReflection; +use Hypervel\PHPStan\Reflection\ForwardedMethodReflection; +use Hypervel\PHPStan\Reflection\MacroRegistrationMethodReflection; use LogicException; use PHPStan\Analyser\OutOfClassScope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MethodsClassReflectionExtension; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\Generic\GenericObjectType; -use PHPStan\Type\IntegerType; +use PHPStan\Type\NeverType; use PHPStan\Type\StaticType; use PHPStan\Type\Type; @@ -29,6 +35,8 @@ class ForwardedBuilderMethodExtension implements MethodsClassReflectionExtension private const array RELATION_NON_DECORATED_METHODS = [ 'applyscopes', 'clone', + 'clonewithout', + 'clonewithoutbindings', ]; /** @var list */ @@ -37,9 +45,6 @@ class ForwardedBuilderMethodExtension implements MethodsClassReflectionExtension 'withcan', ]; - /** @var array> */ - private array $passthru = []; - /** @var array */ private array $methods = []; @@ -53,6 +58,8 @@ public function __construct( private readonly ModelScopeMethodResolver $scopeMethods, private readonly BuilderMacroResolver $builderMacros, private readonly BuilderDependencies $builderDependencies, + private readonly BuilderHelper $builderHelper, + private readonly MacroMethodsClassReflectionExtension $macroMethods, ) { $this->scope = new OutOfClassScope; } @@ -105,7 +112,11 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN if (! $this->hasNativeOrDocumentedMethod($classReflection, $methodName)) { if ($isEloquentHost) { $this->builderDependencies->track($classReflection, $classReflection); - $method = $this->resolveEloquentBuilderMethod($classReflection, $methodName); + $method = $this->resolveEloquentBuilderMethod( + $classReflection, + $methodName, + $this->signatureModelType($classReflection, EloquentBuilder::class, 'TModel'), + ); } else { $method = $this->resolveRelationMethod($classReflection, $methodName); } @@ -117,40 +128,75 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN } /** - * Resolve a query method forwarded by an Eloquent builder. + * Resolve a query method forwarded by an Eloquent builder, naming the model in its signature by the signature model type. */ private function resolveEloquentBuilderMethod( ClassReflection $classReflection, string $methodName, + Type $signatureModelType, ): ?MethodReflection { + // Builder::__call() and __callStatic() register a local or global macro before any other dispatch. + if ($methodName === 'macro') { + return new MacroRegistrationMethodReflection( + $classReflection, + $this->reflectionProvider->getClass(QueryBuilder::class)->getNativeMethod($methodName), + ); + } + $modelType = $this->templateType($classReflection, EloquentBuilder::class, 'TModel'); // Local macros run before named scopes in Builder::__call(). - $macro = $this->builderMacros->resolve($classReflection, $modelType, $methodName); + $macro = $this->builderMacros->resolve($classReflection, $modelType, $signatureModelType, $methodName); if ($macro !== null) { return $macro; } - if ($this->hasNamedScope($modelType, $methodName)) { + // Global macros, then named scopes, run next. Their own extensions describe them, and relations reach + // global macros through their builder mixin. + if ($this->macroMethods->hasMethod($classReflection, $methodName) + || $this->hasNamedScope($modelType, $methodName)) { return null; } - $queryType = $this->queryBuilderType($classReflection, $modelType); - $queryClasses = $queryType->getObjectClassReflections(); + [$storedType, $baseType] = $this->builderHelper->queryTypes($classReflection); + $isPassthru = $this->builderHelper->isPassthru($classReflection, $methodName); + // Passthru runs on the raw query toBase() returns, whose rows stay as declared. + $queryType = $isPassthru ? $baseType : $this->builderHelper->bindQueryRows($storedType, $signatureModelType); - if (count($queryClasses) !== 1 || ! $queryClasses[0]->hasNativeMethod($methodName)) { + // A query that never exists runs no method. + if ($queryType instanceof NeverType) { return null; } - $method = $queryType->getMethod($methodName, $this->scope); + $queryClasses = $queryType->getObjectClassReflections(); + + // Query\Builder::__call() runs a macro, else handles a where clause the query does not declare. + // ModelPropertyColumnRule checks a dynamic where's columns: a model-dependent answer here would make + // PHPStan report a generic subclass's call as private. + if (count($queryClasses) !== 1 + || ! ($queryClasses[0]->hasNativeMethod($methodName) || $this->macroMethods->hasMethod($queryClasses[0], $methodName))) { + return str_starts_with($methodName, 'where') + ? new ForwardedFluentMethodReflection( + $classReflection, + new EloquentBuilderMethodReflection($methodName, $classReflection, [new DynamicWhereParameterReflection], isVariadic: true), + ) + : null; + } + + $method = $this->unresolvedMethod($queryType, $methodName); if (! $method->isPublic()) { return null; } - if (in_array(strtolower($methodName), $this->passthruMethods($classReflection), strict: true)) { - return $method; + if ($isPassthru) { + return new ForwardedMethodReflection($classReflection, $method); + } + + // __callStatic() registers a mixin's methods as global macros, so the query's mixin() is static here too. + if ($methodName === 'mixin') { + return new MacroRegistrationMethodReflection($classReflection, $method); } // Eloquent discards ordinary forwarded results, regardless of their declared type. @@ -164,8 +210,7 @@ private function resolveRelationMethod(ClassReflection $classReflection, string { $normalizedMethodName = strtolower($methodName); $relatedType = $this->templateType($classReflection, Relation::class, 'TRelatedModel'); - $builderType = $this->eloquentBuilderType($relatedType); - $builderClasses = $builderType->getObjectClassReflections(); + $builderClasses = $this->eloquentBuilderType($relatedType, $relatedType)->getObjectClassReflections(); if (count($builderClasses) !== 1) { return null; @@ -178,8 +223,11 @@ private function resolveRelationMethod(ClassReflection $classReflection, string $this->builderDependencies->track($relatedClass, $eloquentBuilder); } + $signatureModelType = $this->signatureModelType($classReflection, Relation::class, 'TRelatedModel'); + $builderType = $this->eloquentBuilderType($relatedType, $signatureModelType); + if ($eloquentBuilder->hasNativeMethod($methodName)) { - $method = $builderType->getMethod($methodName, $this->scope); + $method = $this->unresolvedMethod($builderType, $methodName); if (! $method->isPublic()) { return null; @@ -187,18 +235,18 @@ private function resolveRelationMethod(ClassReflection $classReflection, string if (in_array($normalizedMethodName, self::RELATION_NON_DECORATED_METHODS, strict: true) || ! $this->returnsStatic($eloquentBuilder->getNativeMethod($methodName))) { - return $method; + return new ForwardedMethodReflection($classReflection, $method); } return new ForwardedFluentMethodReflection($classReflection, $method); } - $method = $this->resolveEloquentBuilderMethod($eloquentBuilder, $methodName); + $method = $this->resolveEloquentBuilderMethod($eloquentBuilder, $methodName, $signatureModelType); if ($method !== null) { return $method instanceof ForwardedFluentMethodReflection ? new ForwardedFluentMethodReflection($classReflection, $method) - : $method; + : new ForwardedMethodReflection($classReflection, $method); } if ($this->hasNamedScope($relatedType, $methodName) @@ -206,29 +254,7 @@ private function resolveRelationMethod(ClassReflection $classReflection, string return null; } - $method = $builderType->getMethod($methodName, $this->scope); - - return new ForwardedFluentMethodReflection($classReflection, $method); - } - - /** - * Return the Eloquent builder passthru methods. - * - * @return list - */ - private function passthruMethods(ClassReflection $builderClass): array - { - $className = $builderClass->getName(); - - if (! isset($this->passthru[$className])) { - /** @var list $passthru */ - $passthru = $builderClass->getNativeReflection() - ->getDefaultProperties()['passthru']; - - $this->passthru[$className] = $passthru; - } - - return $this->passthru[$className]; + return new ForwardedFluentMethodReflection($classReflection, $this->unresolvedMethod($builderType, $methodName)); } /** @@ -283,51 +309,36 @@ private function templateType( } /** - * Resolve the related model's declared builder, retaining late-static model types. + * Return a class's ancestor template type as the class's own template types declare it. + * + * Forwarded signatures name the receiver's model this way. PHPStan binds the receiver's template types + * after replacing its static types, so a late-static model is not mistaken for the receiver. + * + * @param class-string $ancestorClass */ - private function eloquentBuilderType(Type $modelType): Type + private function signatureModelType(ClassReflection $classReflection, string $ancestorClass, string $templateName): Type { - $modelClasses = $modelType->getObjectClassReflections(); - - if (count($modelClasses) === 1) { - $modelClass = $modelClasses[0]; - $variants = $modelClass->getMethod('query', $this->scope)->getVariants(); - - return ModelScopeTypeResolver::bindToModel($variants[0]->getReturnType(), $modelClass); - } - - return new GenericObjectType(EloquentBuilder::class, [$modelType]); + return $this->templateType($this->reflectionProvider->getClass($classReflection->getName()), $ancestorClass, $templateName); } /** - * Bind forwarded query signatures to model rows without changing raw-query types. + * Return a type's method, keeping the template types it is instantiated with instead of their bounds. */ - private function queryBuilderType(ClassReflection $builderClass, Type $modelType): Type + private function unresolvedMethod(Type $type, string $methodName): ExtendedMethodReflection { - $variants = $builderClass->getNativeMethod('getQuery')->getVariants(); - $queryType = $variants[0]->getReturnType(); - $queryClasses = $queryType->getObjectClassReflections(); - - if (count($queryClasses) !== 1) { - return $queryType; - } - - $queryClass = $queryClasses[0]; - $templates = $queryClass->getTemplateTypeMap(); - - if ($templates->getType('TKey') === null || $templates->getType('TValue') === null) { - return $queryType; - } + // @phpstan-ignore phpstanApi.method (ObjectType::getMethod() resolves template types to their bounds.), phpstanApi.method (Same lookup.) + return $type->getUnresolvedMethodPrototype($methodName, $this->scope)->doNotResolveTemplateTypeMapToBounds()->getTransformedMethod(); + } - $types = $queryClass->getActiveTemplateTypeMap()->map( - static fn (string $name, Type $type): Type => match ($name) { - 'TKey' => new IntegerType, - 'TValue' => $modelType, - default => $type, - }, - ); + /** + * Resolve the related model's builder holding the given model type, or the base builder for several or unknown models. + */ + private function eloquentBuilderType(Type $relatedType, Type $modelType): Type + { + $modelClassNames = $relatedType->getObjectClassNames(); - return new GenericObjectType($queryClass->getName(), $queryClass->typeMapToList($types)); + return (count($modelClassNames) === 1 ? $this->builderHelper->determineBuilderClass($modelClassNames[0], $modelType) : null) + ?? new GenericObjectType(EloquentBuilder::class, [$modelType]); } /** @@ -335,10 +346,7 @@ private function queryBuilderType(ClassReflection $builderClass, Type $modelType */ private function hasNamedScope(Type $modelType, string $methodName): bool { - $modelClasses = $modelType->getObjectClassReflections(); - - return count($modelClasses) === 1 - && $this->scopeMethods->resolve($modelClasses[0], $methodName) !== null; + return $this->scopeMethods->resolveForType($modelType, $methodName) !== null; } /** diff --git a/src/phpstan/src/Methods/ForwardedModelMethodExtension.php b/src/phpstan/src/Methods/ForwardedModelMethodExtension.php index cb8b7f5f5..22bedc4c3 100644 --- a/src/phpstan/src/Methods/ForwardedModelMethodExtension.php +++ b/src/phpstan/src/Methods/ForwardedModelMethodExtension.php @@ -6,6 +6,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\PHPStan\Reflection\ForwardedModelMethodReflection; +use Hypervel\PHPStan\Reflection\MacroRegistrationMethodReflection; use LogicException; use PHPStan\Analyser\OutOfClassScope; use PHPStan\DependencyInjection\AutowiredService; @@ -13,6 +14,7 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MethodsClassReflectionExtension; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\StaticType; use PHPStan\Type\Type; /** @@ -34,6 +36,8 @@ public function __construct( private readonly ModelScopeMethodResolver $scopeMethods, private readonly BuilderMacroResolver $builderMacros, private readonly BuilderDependencies $builderDependencies, + private readonly BuilderHelper $builderHelper, + private readonly MacroMethodsClassReflectionExtension $macroMethods, ) { $this->scope = new OutOfClassScope; } @@ -80,7 +84,8 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN $method = null; if (! $this->hasNativeOrDocumentedMethod($classReflection, $methodName)) { - $builderType = $this->activeBuilderType($classReflection); + // Keep static, so PHPStan binds it to the called model. + $builderType = $this->builderHelper->determineBuilderClass($classReflection->getName(), new StaticType($classReflection)); foreach ($builderType->getObjectClassReflections() as $builderClass) { $this->builderDependencies->track($classReflection, $builderClass); @@ -88,14 +93,20 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN $scopeMethod = $this->scopeMethods->resolve($classReflection, $methodName); - // Native builder methods and local macros run before named scopes. + // Native builder methods and local and global macros run before named scopes. if (($scopeMethod === null - || $this->hasNativeMethod($builderType, $methodName) + || $this->builderMethodTakesPrecedence($builderType, $methodName) || $this->builderMacros->hasMacro($classReflection, $methodName)) && $builderType->hasMethod($methodName)->yes()) { + $builderMethod = $builderType->getMethod($methodName, $this->scope); + + // Model::__call() makes an instance call, so the builder's mixin() reaches the query's and returns it. $method = new ForwardedModelMethodReflection( $classReflection, - $builderType->getMethod($methodName, $this->scope), + $builderMethod, + $methodName === 'mixin' && $builderMethod->getPrototype() instanceof MacroRegistrationMethodReflection + ? $builderType + : null, ); } } @@ -106,24 +117,12 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN } /** - * Return the active query builder type for a model. - * - * Preserve the raw static type so PHPStan can rebind it to the called-on model. - */ - private function activeBuilderType(ClassReflection $classReflection): Type - { - $variants = $classReflection->getMethod('query', $this->scope)->getVariants(); - - return $variants[0]->getReturnType(); - } - - /** - * Determine whether an active builder owns a native method. + * Determine whether an active builder owns a native method or global macro. */ - private function hasNativeMethod(Type $builderType, string $methodName): bool + private function builderMethodTakesPrecedence(Type $builderType, string $methodName): bool { foreach ($builderType->getObjectClassReflections() as $builderClass) { - if ($builderClass->hasNativeMethod($methodName)) { + if ($builderClass->hasNativeMethod($methodName) || $this->macroMethods->hasMethod($builderClass, $methodName)) { return true; } } diff --git a/src/phpstan/src/Methods/MacroRegistrationExtension.php b/src/phpstan/src/Methods/MacroRegistrationExtension.php new file mode 100644 index 000000000..485a9b08b --- /dev/null +++ b/src/phpstan/src/Methods/MacroRegistrationExtension.php @@ -0,0 +1,72 @@ +getName() === 'mixin' + && $methodReflection->getPrototype() instanceof MacroRegistrationMethodReflection; + } + + /** + * Return the builder an instance mixin() returns. + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + return $scope->getType($methodCall->var); + } + + /** + * Determine whether the call registers a global macro. + */ + public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $methodReflection->getName() === 'macro' + && $methodReflection->getPrototype() instanceof MacroRegistrationMethodReflection; + } + + /** + * Bind a global macro to the builder it is registered on. + */ + public function getClosureThisTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + ParameterReflection $parameter, + Scope $scope, + ): Type { + return new ObjectType($methodReflection->getDeclaringClass()->getName()); + } +} diff --git a/src/phpstan/src/Methods/ModelScopeMethodResolver.php b/src/phpstan/src/Methods/ModelScopeMethodResolver.php index ff213cb5e..9561c13db 100644 --- a/src/phpstan/src/Methods/ModelScopeMethodResolver.php +++ b/src/phpstan/src/Methods/ModelScopeMethodResolver.php @@ -5,9 +5,12 @@ namespace Hypervel\PHPStan\Methods; use Hypervel\Database\Eloquent\Attributes\Scope; +use Hypervel\Database\Eloquent\Model; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; +use PHPStan\Type\Type; +use PHPStan\Type\TypeUtils; /** * Resolve model methods exposed as named Eloquent scopes. @@ -26,6 +29,37 @@ public function resolve(ClassReflection $modelClass, string $scope): ?MethodRefl return $this->methods($modelClass)[strtolower($scope)] ?? null; } + /** + * Resolve a scope for a model type, and the class declaring it. + * + * An intersection such as Model&HasTimeline can declare the scope through an interface. A union is declined, + * as its members can resolve the name differently. + * + * @return null|array{ClassReflection, MethodReflection} + */ + public function resolveForType(Type $modelType, string $scope): ?array + { + $types = TypeUtils::flattenTypes($modelType); + + if (count($types) !== 1) { + return null; + } + + $classes = $types[0]->getObjectClassReflections(); + $modelClasses = array_filter($classes, static fn (ClassReflection $class): bool => $class->is(Model::class)); + + // The model's own scopes win, as Eloquent checks the model's attributed scopes first. + foreach ([...$modelClasses, ...array_diff_key($classes, $modelClasses)] as $class) { + $method = $this->resolve($class, $scope); + + if ($method !== null) { + return [$class, $method]; + } + } + + return null; + } + /** * Return the named scopes exposed by a model. * @@ -42,6 +76,7 @@ private function methods(ClassReflection $modelClass): array $legacyScopes = []; $attributedScopes = []; + // Unlike upstream, an @method scopeX tag creates no scope: Model::hasNamedScope() needs a real method. foreach ($modelClass->getNativeReflection()->getMethods() as $method) { if ($method->isPrivate()) { continue; @@ -55,7 +90,8 @@ private function methods(ClassReflection $modelClass): array $legacyScopes[strtolower(substr($methodName, 5))] = $modelClass->getNativeMethod($methodName); } - if ($method->getAttributes(Scope::class) !== []) { + // An implementation does not inherit an interface method's attributes. + if (! $modelClass->isInterface() && $method->getAttributes(Scope::class) !== []) { $attributedScopes[strtolower($methodName)] = $modelClass->getNativeMethod($methodName); } } diff --git a/src/phpstan/src/Methods/NamedScopeMethodExtension.php b/src/phpstan/src/Methods/NamedScopeMethodExtension.php index 88eece711..653cbc5e5 100644 --- a/src/phpstan/src/Methods/NamedScopeMethodExtension.php +++ b/src/phpstan/src/Methods/NamedScopeMethodExtension.php @@ -9,12 +9,12 @@ use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\PHPStan\Reflection\NamedScopeMethodReflection; use LogicException; -use PHPStan\Analyser\OutOfClassScope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MethodsClassReflectionExtension; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\StaticType; use PHPStan\Type\ThisType; use PHPStan\Type\Type; @@ -27,8 +27,6 @@ class NamedScopeMethodExtension implements MethodsClassReflectionExtension /** @var array */ private array $methods = []; - private readonly OutOfClassScope $scope; - /** * Create a named scope method extension. */ @@ -36,8 +34,9 @@ public function __construct( private readonly ReflectionProvider $reflectionProvider, private readonly ModelScopeMethodResolver $scopeMethods, private readonly BuilderMacroResolver $builderMacros, + private readonly BuilderHelper $builderHelper, + private readonly MacroMethodsClassReflectionExtension $macroMethods, ) { - $this->scope = new OutOfClassScope; } /** @@ -91,18 +90,18 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN return null; } - [$modelClass, $receiverType, $queryType, $static] = $host; - $scopeMethod = $this->scopeMethods->resolve($modelClass, $methodName); + [$modelType, $receiverType, $queryType, $static] = $host; + $scope = $this->scopeMethods->resolveForType($modelType, $methodName); $method = null; // Local builder macros run before named scopes in Builder::__call(). - if ($scopeMethod !== null - && ! $this->builderMacros->hasMacro($modelClass, $methodName) - && ! $this->nativeMethodTakesPrecedence($classReflection, $methodName, $queryType, $static)) { + if ($scope !== null + && ! $this->builderMacros->hasMacro($scope[0], $methodName) + && ! $this->methodTakesPrecedence($classReflection, $methodName, $queryType, $static)) { $method = new NamedScopeMethodReflection( $classReflection, - $modelClass, - $scopeMethod, + $scope[0], + $scope[1], $methodName, $receiverType, $queryType, @@ -118,7 +117,7 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN /** * Resolve the model, receiver and query types, and static mode for an Eloquent host. * - * @return null|array{ClassReflection, Type, Type, bool} + * @return null|array{Type, Type, Type, bool} */ private function host( ClassReflection $classReflection, @@ -129,40 +128,39 @@ private function host( $queryType = $this->activeBuilderType($classReflection); return [ - $classReflection, + (new StaticType($classReflection))->getStaticObjectType(), $queryType, $queryType, true, ]; } - $templateName = $isBuilderHost ? 'TModel' : 'TRelatedModel'; - $modelType = $this->templateType( $classReflection, $isBuilderHost ? Builder::class : Relation::class, - $templateName, + $isBuilderHost ? 'TModel' : 'TRelatedModel', ); - $modelClasses = $modelType->getObjectClassReflections(); + $receiverType = new ThisType($classReflection); - if (count($modelClasses) !== 1) { - return null; + if ($isBuilderHost) { + return [$modelType, $receiverType, $receiverType, false]; } - $receiverType = new ThisType($classReflection); + // An intersection such as Model&HasTimeline still names one model, whose builder the relation forwards to. + $modelClasses = array_values(array_filter( + $modelType->getObjectClassReflections(), + static fn (ClassReflection $modelClass): bool => $modelClass->is(Model::class), + )); - return [ - $modelClasses[0], - $receiverType, - $isBuilderHost ? $receiverType : $this->activeBuilderType($modelClasses[0]), - false, - ]; + return count($modelClasses) === 1 + ? [$modelType, $receiverType, $this->activeBuilderType($modelClasses[0]), false] + : null; } /** - * Determine whether a native method wins over dynamic scope dispatch. + * Determine whether a native method or a global builder macro wins over dynamic scope dispatch. */ - private function nativeMethodTakesPrecedence( + private function methodTakesPrecedence( ClassReflection $classReflection, string $methodName, Type $queryType, @@ -173,9 +171,9 @@ private function nativeMethodTakesPrecedence( return true; } - // Model and relation hosts also yield to methods owned by their active builder. + // Every host also yields to its active builder's methods, and Builder::__call() runs global macros first. foreach ($queryType->getObjectClassReflections() as $builderClass) { - if ($builderClass->hasNativeMethod($methodName)) { + if ($builderClass->hasNativeMethod($methodName) || $this->macroMethods->hasMethod($builderClass, $methodName)) { return true; } } @@ -184,15 +182,13 @@ private function nativeMethodTakesPrecedence( } /** - * Return the active query builder type for a model. + * Return the model's builder type. * - * Bind static here because scope returns are compared before PHPStan can rebind the synthesized receiver. + * Bind the model here because scope returns are compared before PHPStan can rebind the synthesized receiver. */ private function activeBuilderType(ClassReflection $modelClass): Type { - $variants = $modelClass->getMethod('query', $this->scope)->getVariants(); - - return ModelScopeTypeResolver::bindToModel($variants[0]->getReturnType(), $modelClass); + return $this->builderHelper->determineBuilderClass($modelClass->getName(), (new StaticType($modelClass))->getStaticObjectType()); } /** diff --git a/src/phpstan/src/Reflection/DynamicWhereParameterReflection.php b/src/phpstan/src/Reflection/DynamicWhereParameterReflection.php new file mode 100644 index 000000000..5eb4d6af8 --- /dev/null +++ b/src/phpstan/src/Reflection/DynamicWhereParameterReflection.php @@ -0,0 +1,61 @@ + $parameters + */ + public function __construct( + private string $methodName, + private ClassReflection $classReflection, + private array $parameters, + ?Type $returnType = null, + private bool $isVariadic = false, + ) { + $this->returnType = $returnType ?? new ObjectType(Builder::class); + } + + /** + * Return the declaring class. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->classReflection; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Return the method name. + */ + public function getName(): string + { + return $this->methodName; + } + + /** + * Return the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Return the method variants. + * + * @return list + */ + public function getVariants(): array + { + return [ + new FunctionVariant( + TemplateTypeMap::createEmpty(), + null, + $this->parameters, + $this->isVariadic, + $this->returnType, + ), + ]; + } + + /** + * Return no docblock for the synthesized signature. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Return the deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return null; + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Return the declared throw type. + */ + public function getThrowType(): ?Type + { + return null; + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } +} diff --git a/src/phpstan/src/Reflection/ForwardedFluentMethodReflection.php b/src/phpstan/src/Reflection/ForwardedFluentMethodReflection.php index e3779cad0..db324052c 100644 --- a/src/phpstan/src/Reflection/ForwardedFluentMethodReflection.php +++ b/src/phpstan/src/Reflection/ForwardedFluentMethodReflection.php @@ -4,86 +4,16 @@ namespace Hypervel\PHPStan\Reflection; -use PHPStan\Reflection\ClassMemberReflection; -use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\FunctionVariant; -use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptor; -use PHPStan\TrinaryLogic; use PHPStan\Type\ThisType; -use PHPStan\Type\Type; /** * Describe a forwarded fluent method whose runtime result is the receiving object. */ -class ForwardedFluentMethodReflection implements MethodReflection +class ForwardedFluentMethodReflection extends ForwardedMethodReflection { - /** - * Create a forwarded fluent method reflection. - */ - public function __construct( - private readonly ClassReflection $declaringClass, - private readonly MethodReflection $forwardedMethod, - ) { - } - - /** - * Return the method name. - */ - public function getName(): string - { - return $this->forwardedMethod->getName(); - } - - /** - * Return the declaring class. - */ - public function getDeclaringClass(): ClassReflection - { - return $this->declaringClass; - } - - /** - * Determine whether the method is static. - */ - public function isStatic(): bool - { - return $this->forwardedMethod->isStatic(); - } - - /** - * Determine whether the method is private. - */ - public function isPrivate(): bool - { - return $this->forwardedMethod->isPrivate(); - } - - /** - * Determine whether the method is public. - */ - public function isPublic(): bool - { - return $this->forwardedMethod->isPublic(); - } - - /** - * Return the method docblock. - */ - public function getDocComment(): ?string - { - return $this->forwardedMethod->getDocComment(); - } - - /** - * Return the method prototype. - */ - public function getPrototype(): ClassMemberReflection - { - return $this; - } - /** * Return the callable variants with the receiving object as their result. * @@ -103,52 +33,4 @@ public function getVariants(): array $this->forwardedMethod->getVariants(), ); } - - /** - * Determine whether the method is deprecated. - */ - public function isDeprecated(): TrinaryLogic - { - return $this->forwardedMethod->isDeprecated(); - } - - /** - * Return the deprecation description. - */ - public function getDeprecatedDescription(): ?string - { - return $this->forwardedMethod->getDeprecatedDescription(); - } - - /** - * Determine whether the method is final. - */ - public function isFinal(): TrinaryLogic - { - return $this->forwardedMethod->isFinal(); - } - - /** - * Determine whether the method is internal. - */ - public function isInternal(): TrinaryLogic - { - return $this->forwardedMethod->isInternal(); - } - - /** - * Return the declared throw type. - */ - public function getThrowType(): ?Type - { - return $this->forwardedMethod->getThrowType(); - } - - /** - * Determine whether the method has side effects. - */ - public function hasSideEffects(): TrinaryLogic - { - return $this->forwardedMethod->hasSideEffects(); - } } diff --git a/src/phpstan/src/Reflection/ForwardedMethodReflection.php b/src/phpstan/src/Reflection/ForwardedMethodReflection.php new file mode 100644 index 000000000..1cea2b756 --- /dev/null +++ b/src/phpstan/src/Reflection/ForwardedMethodReflection.php @@ -0,0 +1,153 @@ +forwardedMethod->getName(); + } + + /** + * Return the declaring class. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + /** + * Return the method of the object that handles the call. + */ + public function getForwardedMethod(): MethodReflection + { + return $this->forwardedMethod; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + // The receiver's __call() forwards it, so a static method of the other object is not static here: + // Builder::__callStatic(), for one, runs only the builder's own global macros. + return false; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return $this->forwardedMethod->isPrivate(); + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return $this->forwardedMethod->isPublic(); + } + + /** + * Return the method docblock. + */ + public function getDocComment(): ?string + { + return $this->forwardedMethod->getDocComment(); + } + + /** + * Return the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Return the forwarded method variants. + * + * @return list + */ + public function getVariants(): array + { + return $this->forwardedMethod->getVariants(); + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return $this->forwardedMethod->isDeprecated(); + } + + /** + * Return the deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return $this->forwardedMethod->getDeprecatedDescription(); + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return $this->forwardedMethod->isFinal(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return $this->forwardedMethod->isInternal(); + } + + /** + * Return the declared throw type. + */ + public function getThrowType(): ?Type + { + return $this->forwardedMethod->getThrowType(); + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return $this->forwardedMethod->hasSideEffects(); + } +} diff --git a/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php b/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php index 7ea829ea4..a572968c6 100644 --- a/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php +++ b/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php @@ -4,41 +4,55 @@ namespace Hypervel\PHPStan\Reflection; -use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedParametersAcceptor; +use PHPStan\Reflection\FunctionVariant; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptor; -use PHPStan\TrinaryLogic; use PHPStan\Type\Type; +use function array_map; + /** * Describe a builder method forwarded through an Eloquent model. */ -class ForwardedModelMethodReflection implements MethodReflection +class ForwardedModelMethodReflection extends ForwardedMethodReflection { /** * Create a forwarded model method reflection. + * + * @param null|Type $returnType replaces the builder method's declared result with what the instance call returns */ public function __construct( - private readonly ClassReflection $declaringClass, - private readonly MethodReflection $forwardedMethod, + ClassReflection $declaringClass, + MethodReflection $forwardedMethod, + private readonly ?Type $returnType = null, ) { + parent::__construct($declaringClass, $forwardedMethod); } /** - * Return the method name. - */ - public function getName(): string - { - return $this->forwardedMethod->getName(); - } - - /** - * Return the declaring class. + * Return the builder method's variants, with the instance call's result when it differs. + * + * @return list */ - public function getDeclaringClass(): ClassReflection + public function getVariants(): array { - return $this->declaringClass; + if ($this->returnType === null) { + return parent::getVariants(); + } + + return array_map( + fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + $variant->getParameters(), + $variant->isVariadic(), + $this->returnType, + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + ), + parent::getVariants(), + ); } /** @@ -64,78 +78,4 @@ public function isPublic(): bool { return true; } - - /** - * Return the method docblock. - */ - public function getDocComment(): ?string - { - return $this->forwardedMethod->getDocComment(); - } - - /** - * Return the method prototype. - */ - public function getPrototype(): ClassMemberReflection - { - return $this; - } - - /** - * Return the forwarded builder method variants. - * - * @return list - */ - public function getVariants(): array - { - return $this->forwardedMethod->getVariants(); - } - - /** - * Determine whether the method is deprecated. - */ - public function isDeprecated(): TrinaryLogic - { - return $this->forwardedMethod->isDeprecated(); - } - - /** - * Return the deprecation description. - */ - public function getDeprecatedDescription(): ?string - { - return $this->forwardedMethod->getDeprecatedDescription(); - } - - /** - * Determine whether the method is final. - */ - public function isFinal(): TrinaryLogic - { - return $this->forwardedMethod->isFinal(); - } - - /** - * Determine whether the method is internal. - */ - public function isInternal(): TrinaryLogic - { - return $this->forwardedMethod->isInternal(); - } - - /** - * Return the declared throw type. - */ - public function getThrowType(): ?Type - { - return $this->forwardedMethod->getThrowType(); - } - - /** - * Determine whether the method has side effects. - */ - public function hasSideEffects(): TrinaryLogic - { - return $this->forwardedMethod->hasSideEffects(); - } } diff --git a/src/phpstan/src/Reflection/MacroRegistrationMethodReflection.php b/src/phpstan/src/Reflection/MacroRegistrationMethodReflection.php new file mode 100644 index 000000000..be46022fa --- /dev/null +++ b/src/phpstan/src/Reflection/MacroRegistrationMethodReflection.php @@ -0,0 +1,52 @@ + + */ + public function getVariants(): array + { + return array_map( + static fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + array_map( + static fn (ParameterReflection $parameter): ParameterReflection => new ProxiedParameterReflection($parameter), + $variant->getParameters(), + ), + $variant->isVariadic(), + $variant->getReturnType(), + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + ), + $this->forwardedMethod->getVariants(), + ); + } +} diff --git a/src/phpstan/src/Reflection/ProxiedParameterReflection.php b/src/phpstan/src/Reflection/ProxiedParameterReflection.php index 80419ef93..d9be42aea 100644 --- a/src/phpstan/src/Reflection/ProxiedParameterReflection.php +++ b/src/phpstan/src/Reflection/ProxiedParameterReflection.php @@ -11,8 +11,9 @@ use function array_map; /** - * Describe a target method's by-reference parameter as a proxy receives it. A proxy's __call() - * gets the argument values, so the caller's variable is never passed by reference. + * Describe a target method's parameter as a proxy receives it. A proxy's __call() gets the argument + * values, so the caller's variable is never passed by reference. The parameter has none of PHPStan's + * extended metadata, such as a closure binding the target declares. */ class ProxiedParameterReflection implements ParameterReflection { diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/BuilderNames.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/BuilderNames.php new file mode 100644 index 000000000..e234a4e35 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/BuilderNames.php @@ -0,0 +1,10 @@ +whereActive(); + $builder->onlyActive(); } } diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Member.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Member.php new file mode 100644 index 000000000..f76dd775e --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Member.php @@ -0,0 +1,12 @@ +published(); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserQuery.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserQuery.php index fa3c3a57e..24f2210fb 100644 --- a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserQuery.php +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserQuery.php @@ -11,7 +11,7 @@ class UserQuery extends Builder /** * Constrain the query to active rows. */ - public function whereActive(): static + public function onlyActive(): static { return $this->where('active', true); } diff --git a/tests/PHPStan/Methods/ForwardedBuilderMethodExtensionTest.php b/tests/PHPStan/Methods/ForwardedBuilderMethodExtensionTest.php index 6ef03845a..c986d141d 100644 --- a/tests/PHPStan/Methods/ForwardedBuilderMethodExtensionTest.php +++ b/tests/PHPStan/Methods/ForwardedBuilderMethodExtensionTest.php @@ -6,10 +6,13 @@ use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\PHPStan\Methods\BuilderDependencies; +use Hypervel\PHPStan\Methods\BuilderHelper; use Hypervel\PHPStan\Methods\BuilderMacroResolver; use Hypervel\PHPStan\Methods\ForwardedBuilderMethodExtension; +use Hypervel\PHPStan\Methods\MacroMethodsClassReflectionExtension; use Hypervel\PHPStan\Methods\Macros\SoftDeletingMacros; use Hypervel\PHPStan\Methods\ModelScopeMethodResolver; +use Hypervel\PHPStan\Reflection\AttributeClassNameResolver; use Hypervel\Tests\PHPStan\TestCase; use PHPStan\Analyser\DeclarationDependencyTracker; use PHPStan\Reflection\ReflectionProvider; @@ -20,12 +23,20 @@ public function testDoesNotReflectClassesDuringConstruction(): void { $reflectionProvider = $this->createMock(ReflectionProvider::class); $reflectionProvider->expects($this->never())->method('getClass'); + $dependencyTracker = $this->createStub(DeclarationDependencyTracker::class); + $builderHelper = new BuilderHelper( + $reflectionProvider, + $dependencyTracker, + $this->createStub(AttributeClassNameResolver::class), + ); new ForwardedBuilderMethodExtension( $reflectionProvider, new ModelScopeMethodResolver, new BuilderMacroResolver($reflectionProvider, [SoftDeletes::class => SoftDeletingMacros::class]), - new BuilderDependencies($this->createStub(DeclarationDependencyTracker::class)), + new BuilderDependencies($dependencyTracker, $builderHelper), + $builderHelper, + $this->createStub(MacroMethodsClassReflectionExtension::class), ); } } diff --git a/tests/PHPStan/ModelPropertyDependencyTest.php b/tests/PHPStan/ModelPropertyDependencyTest.php index 7cc0c9d03..3d327982d 100644 --- a/tests/PHPStan/ModelPropertyDependencyTest.php +++ b/tests/PHPStan/ModelPropertyDependencyTest.php @@ -117,6 +117,25 @@ public function testChangedConstantTheMigrationsReadReanalysesModelUsage(): void $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); } + /** + * A builder attribute can name its builder by another class's constant, which the model's file does not declare. + */ + public function testChangedConstantABuilderAttributeReadsReanalysesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $constantsPath = $applicationPath . '/app/Models/BuilderNames.php'; + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + // The base builder is not a subclass of itself, so the attribute is ignored and the model creates it. + file_put_contents($constantsPath, str_replace('UserBuilder::class', '\Hypervel\Database\Eloquent\Builder::class', file_get_contents($constantsPath))); + + $this->assertSame( + ['MemberUsage.php:17 method.notFound', 'Usage.php:14 property.notFound'], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + } + /** * A guard reads one table's facts to decide what happens to another, so the files those facts were * read from are checked for every table. @@ -164,7 +183,7 @@ public function testChangedBuilderReanalysesForwardedCalls(): void file_put_contents($builderPath, str_replace('function published()', 'function unpublished()', $builder)); $this->assertSame( - ['RelationUsage.php:25 method.notFound', 'Usage.php:14 property.notFound'], + ['MemberUsage.php:17 method.notFound', 'RelationUsage.php:25 method.notFound', 'Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1), ); @@ -184,7 +203,8 @@ public function testChangedQueryClassReanalysesBuilderUsage(): void $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); - file_put_contents($queryPath, str_replace('function whereActive()', 'function whereInactive()', $query)); + // Not a where method, which the base query would handle as a dynamic where. + file_put_contents($queryPath, str_replace('function onlyActive()', 'function onlyInactive()', $query)); $this->assertSame( ['BuilderUsage.php:25 method.notFound', 'Usage.php:14 property.notFound'], diff --git a/tests/PHPStan/Type/data/custom-eloquent-builder.php b/tests/PHPStan/Type/data/custom-eloquent-builder.php new file mode 100644 index 000000000..be4b6a412 --- /dev/null +++ b/tests/PHPStan/Type/data/custom-eloquent-builder.php @@ -0,0 +1,318 @@ +first()); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::where('email', 'bar')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::foo('foo')->foo('bar')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::type('foo')); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $foo->customModels()->category('foo')); + // A relation returns itself only when the builder method returns the same builder, which cloneWithout() does not. + assertType('CustomEloquentBuilder\CustomEloquentBuilder', $foo->customModels()->cloneWithout(['orders'])); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::whereEmail(['bar'])->type('foo')->whereEmail(['bar'])); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::whereEmail(['bar'])->categories(['foo'])->whereType(['bar'])); + assertType('CustomEloquentBuilder\ModelWithCustomBuilder|null', ModelWithCustomBuilder::whereEmail(['bar'])->type('foo')->first()); + assertType('CustomEloquentBuilder\ModelWithCustomBuilder|null', ModelWithCustomBuilder::whereEmail(['bar'])->type('foo')->first()); + assertType('int<0, max>', $foo->customModels()->count()); + assertType('bool', $foo->customModels()->exists()); + assertType('Hypervel\Database\Eloquent\Collection', ModelWithCustomBuilder::get()); + assertType('CustomEloquentBuilder\ModelWithCustomBuilder', ModelWithCustomBuilder::firstOrFail()); + assertType('CustomEloquentBuilder\ModelWithCustomBuilder', ModelWithCustomBuilder::findOrFail(1)); + assertType('Hypervel\Database\Eloquent\Collection', ModelWithCustomBuilder::findOrFail([1, 2, 3])); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->has('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orHas('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->doesntHave('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orDoesntHave('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereHas('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->withWhereHas('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orWhereHas('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereDoesntHave('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orWhereDoesntHave('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->hasMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orHasMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->doesntHaveMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orDoesntHaveMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereHasMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orWhereHasMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereDoesntHaveMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orWhereDoesntHaveMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->mergeConstraintsFrom(ModelWithCustomBuilder::query())); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereHas('users')->type('foo')); + assertType('Hypervel\Database\Eloquent\Collection', ModelWithCustomBuilderAndDocBlocks::query()->get()); + assertType('Hypervel\Database\Eloquent\Collection', ModelWithCustomBuilderAndDocBlocks::all()); + assertType('CustomEloquentBuilder\CustomBuilder2', ModelWithCustomBuilderAndDocBlocks::query()); + assertType('CustomEloquentBuilder\NonGenericBuilder', $nonGenericBuilder->skip(5)); + + assertType('CustomEloquentBuilder\ModelWithNonGenericBuilder|null', ModelWithNonGenericBuilder::where('email', 'bar')->first()); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', ModelWithNonGenericBuilder::where('email', 'bar')->orderBy('email')); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', ModelWithNonGenericBuilder::whereEmail('bar')); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', ModelWithNonGenericBuilder::query()); + assertType('list', ModelWithNonGenericBuilder::query()->getColumns()); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', $nonGenericModel->newQuery()); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', ModelWithNonGenericBuilder::withTrashed()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $foo->nonGenericModels()->wherePublishable()); + assertType('Hypervel\Database\Eloquent\Collection', ModelWithNonGenericBuilder::get()); + + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereNotIn('id', [1])); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::whereNotIn('id', [1])); + assertType('string', ModelWithCustomBuilder::query()->getColumns()); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', ModelWithNonGenericBuilder::query()->whereNotIn('id', [1])); + assertType('int<0, max>', ModelWithCustomBuilder::query()->count()); + + // A method that only exists on the custom query builder returned by toBase(), forwarded + // via __call. Resolving this requires reading the actual query builder class off the + // Eloquent builder's own `toBase()` return type, instead of assuming + // Hypervel\Database\Query\Builder. + assertType('CustomEloquentBuilder\ModelWithCustomQueryBuilder|null', ModelWithCustomQueryBuilder::onlyOnCustomQueryBuilder()->first()); + + // exists() is in BuilderHelper::$passthru and is overridden on the custom query builder + // (not on Hypervel\Database\Query\Builder itself). Forwarding it must still return its + // own declared scalar return type, not fall back to assuming the Eloquent builder itself + // (i.e. treating it as chainable) just because it isn't declared on Hypervel's native + // query builder. + assertType('bool', ModelWithCustomQueryBuilder::where('email', 'bar')->exists()); + + // count() is also passthru, but unlike exists() it is NOT overridden on the custom query + // builder - it is only inherited from Hypervel\Database\Query\Builder. The declaring + // class comparison must account for this (the custom query builder "is" its parent), + // not require an exact class name match, or this would wrongly fall back to the + // chainable Eloquent builder type instead of the real passthru return type. + assertType('int<0, max>', ModelWithCustomQueryBuilder::query()->count()); +} + +/** @param ChildNonGenericBuilder|ModelWithNonGenericBuilder $builder */ +function testNonGenericBuilderDocBlock($builder): void +{ + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', $builder); +} + +/** + * @property string $email + * @property string $category + * @property string $type + */ +class ModelWithCustomBuilder extends Model +{ + // Dummy relation + /** @return HasMany */ + public function users(): HasMany + { + return $this->hasMany(User::class); + } + + /** + * @param CustomEloquentBuilder $query + * + * @phpstan-return CustomEloquentBuilder + */ + public function scopeFoo(CustomEloquentBuilder $query, string $foo): CustomEloquentBuilder + { + return $query->where(['email' => $foo]); + } + + /** @param CustomEloquentBuilder $query */ + public function scopeLockForUpdate(CustomEloquentBuilder $query): CustomEloquentBuilder + { + return $query; + } + + /** @phpstan-return CustomEloquentBuilder */ + public function testCustomBuilderReturnType(): CustomEloquentBuilder + { + return $this->where('email', 'bar'); + } + + /** + * @param \Hypervel\Database\Query\Builder $query + * @return CustomEloquentBuilder + */ + public function newEloquentBuilder($query): CustomEloquentBuilder + { + return new CustomEloquentBuilder($query); + } +} + +/** + * @template TModel of ModelWithCustomBuilder + * + * @extends Builder + */ +class CustomEloquentBuilder extends Builder +{ + public function testScopeCollision(): void + { + // Unlike upstream, the scope's builder result is this builder, which callScope() passes it. + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->lockForUpdate()); + } + + public function wherePublishable(): static + { + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->whereNotIn('status', ['draft', 'archived'])); + + return $this->whereNotIn('status', ['draft', 'archived']); + } + + public function wherePublished(): static + { + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->where('status', 'published')); + + return $this->where('status', 'published'); + } + + /** @phpstan-return $this Unlike upstream, which treats any builder result as this builder. */ + public function category(string $category): CustomEloquentBuilder + { + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->where('category', $category)); + + return $this->where('category', $category); + } + + /** @phpstan-return CustomEloquentBuilder */ + public function type(string $type): CustomEloquentBuilder + { + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->where(['type' => $type])); + + return $this->where(['type' => $type]); + } + + /** + * @param string[] $categories + * + * @phpstan-return CustomEloquentBuilder + */ + public function categories(array $categories): CustomEloquentBuilder + { + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->whereIn('category', $categories)); + + return $this->whereIn('category', $categories); + } +} + +class FooModel extends Model +{ + /** @return HasMany */ + public function customModels(): HasMany + { + return $this->hasMany(ModelWithCustomBuilder::class); + } + + /** @return HasMany */ + public function nonGenericModels(): HasMany + { + return $this->hasMany(ModelWithNonGenericBuilder::class); + } +} + +/** + * @method static CustomBuilder2|ModelWithCustomBuilderAndDocBlocks newModelQuery() + * @method static CustomBuilder2|ModelWithCustomBuilderAndDocBlocks newQuery() + * @method static CustomBuilder2|ModelWithCustomBuilderAndDocBlocks query() + */ +class ModelWithCustomBuilderAndDocBlocks extends Model +{ + /** + * @param \Hypervel\Database\Query\Builder $query + * @return CustomBuilder2 + */ + public function newEloquentBuilder($query): CustomBuilder2 + { + return new CustomBuilder2($query); + } +} + +/** + * @template TModel of ModelWithCustomBuilderAndDocBlocks + * + * @extends Builder + */ +class CustomBuilder2 extends Builder +{ +} + +/** @property string $email Unlike upstream, which runs without model property checks, whereEmail() needs this column. */ +class ModelWithNonGenericBuilder extends Model +{ + use SoftDeletes; + + /** + * @param \Hypervel\Database\Query\Builder $query + * @return ChildNonGenericBuilder + */ + public function newEloquentBuilder($query): ChildNonGenericBuilder + { + return new ChildNonGenericBuilder($query); + } +} + +/** + * @extends Builder + */ +class NonGenericBuilder extends Builder +{ + public function wherePublishable(): static + { + assertType('$this(CustomEloquentBuilder\NonGenericBuilder)', $this->whereNotIn('status', ['draft', 'archived'])); + + return $this->whereNotIn('status', ['draft', 'archived']); + } +} + +class ChildNonGenericBuilder extends NonGenericBuilder +{ + /** @return list */ + public function getColumns(): array + { + return []; + } +} + +/** + * A query builder with a method that does not exist on Hypervel\Database\Query\Builder. + * Mirrors packages (e.g. mongodb/laravel-mongodb) that replace the query layer entirely. + */ +class CustomQueryBuilder extends \Hypervel\Database\Query\Builder +{ + public function onlyOnCustomQueryBuilder(): static + { + return $this; + } + + /** Overridden here, like mongodb/laravel-mongodb's own Query\Builder::exists() does. */ + public function exists(): bool + { + return true; + } +} + +/** + * @template TModel of ModelWithCustomQueryBuilder + * + * @extends Builder + * + * @method CustomQueryBuilder toBase() + */ +class EloquentBuilderWithCustomQueryBuilder extends Builder +{ +} + +class ModelWithCustomQueryBuilder extends Model +{ + /** + * @param \Hypervel\Database\Query\Builder $query + * @return EloquentBuilderWithCustomQueryBuilder + */ + public function newEloquentBuilder($query): EloquentBuilderWithCustomQueryBuilder + { + return new EloquentBuilderWithCustomQueryBuilder($query); + } +} diff --git a/tests/PHPStan/Type/data/macros.php b/tests/PHPStan/Type/data/macros.php index 1b0670b3a..fdaf13ea6 100644 --- a/tests/PHPStan/Type/data/macros.php +++ b/tests/PHPStan/Type/data/macros.php @@ -1,11 +1,14 @@ accounts()->globalCustomMacro(b: 99)); + // Builder::__call() runs a global macro before a scope of the same name. + assertType('string', ModelWithMacroNamedScope::globalCustomMacro(b: 99)); + assertType('string', ModelWithMacroNamedScope::query()->globalCustomMacro(b: 99)); + + // Unlike upstream, Eloquent discards a forwarded query macro's result and returns the builder, which a relation + // returns as itself. Builder::__callStatic() runs only the builder's own global macros, so the query macro is + // called on a builder instance here; upstream's static PostBuilder::globalCustomDatabaseQueryMacro() is an error. assertType('string', QueryBuilder::globalCustomDatabaseQueryMacro(b: 99)); - assertType('string', Post::globalCustomDatabaseQueryMacro(b: 99)); - assertType('string', PostBuilder::globalCustomDatabaseQueryMacro(b: 99)); - assertType('string', User::first()->accounts()->globalCustomDatabaseQueryMacro(b: 99)); + assertType('App\PostBuilder', Post::globalCustomDatabaseQueryMacro(b: 99)); + assertType('App\PostBuilder', Post::query()->globalCustomDatabaseQueryMacro(b: 99)); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', User::first()->accounts()->globalCustomDatabaseQueryMacro(b: 99)); assertType('int', Route::facadeMacro()); // Hypervel: this app's untyped passport guard leaves the current guard unknown, so no guard macro is @@ -74,6 +85,39 @@ class Foo use Macroable; } +// Hypervel: Builder::__callStatic() binds a global macro to the builder and registers a mixin's methods, returning +// null. Builder::__call() calls a local macro unbound, and an instance mixin() reaches the query's, as a model's does. +function builderRegistration(Post $post): void +{ + Builder::macro('globalRegistration', function () { + assertType('Hypervel\Database\Eloquent\Builder', $this); + }); + + PostBuilder::macro('postRegistration', function () { + assertType('App\PostBuilder', $this); + }); + + assertType('null', Builder::mixin(new stdClass)); + assertType('App\PostBuilder', Post::query()->mixin(new stdClass)); + assertType('App\PostBuilder', Post::mixin(new stdClass)); + assertType('App\PostBuilder', $post->mixin(new stdClass)); +} + +class LocalMacroProvider +{ + /** + * Register a local macro, which keeps this provider as its $this. + * + * @param Builder $query + */ + public function register(Builder $query): void + { + $query->macro('localRegistration', function (Builder $builder) { + assertType('$this(Macros\LocalMacroProvider)', $this); + }); + } +} + // Hypervel: @method tags type Request's macros, and MacroThrowTypeExtension adds the macros' exceptions. function taggedMacros(Request $request, NativeValidateRequest $nativeRequest, CustomCallHandler $handler): void { @@ -163,3 +207,11 @@ public function __call(string $method, array $parameters): mixed class CustomCallException extends RuntimeException { } + +class ModelWithMacroNamedScope extends Model +{ + /** @param Builder $query */ + public function scopeGlobalCustomMacro(Builder $query): void + { + } +} diff --git a/tests/PHPStan/Type/data/model-scope-attribute-l12.php b/tests/PHPStan/Type/data/model-scope-attribute-l12.php new file mode 100644 index 000000000..0e1fa19d5 --- /dev/null +++ b/tests/PHPStan/Type/data/model-scope-attribute-l12.php @@ -0,0 +1,49 @@ + $query */ +function test(Builder $query): void +{ + assertType('Hypervel\Database\Eloquent\Builder', Activity::completed()); + assertType('Hypervel\Database\Eloquent\Builder', $query->completed()); + assertType('ModelScopeAttribute\Activity', $query->completed(false)->firstOrFail()); + assertType('Hypervel\Database\Eloquent\Builder', Activity::withVoidReturn()); + assertType('Hypervel\Database\Eloquent\Builder', $query->withVoidReturn()); + assertType('ModelScopeAttribute\Activity', $query->withVoidReturn()->firstOrFail()); + // Unlike upstream, a public attributed scope runs as a scope through the builder, as Eloquent permits. + assertType('Hypervel\Database\Eloquent\Builder', $query->notAScope()); +} + +class Activity extends Model +{ + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function completed(Builder $query, bool $completed = true): Builder + { + return $query->where('completed', $completed); + } + + /** @param Builder $query */ + #[Scope] + protected function withVoidReturn(Builder $query): void + { + $query->where('whyuse', 'void'); + } + + /** @param Builder $query */ + #[Scope] + public function notAScope(Builder $query): void + { + $query->where('whyuse', 'void'); + } +} diff --git a/tests/PHPStan/Type/data/model-scopes.php b/tests/PHPStan/Type/data/model-scopes.php new file mode 100644 index 000000000..f65abc9cf --- /dev/null +++ b/tests/PHPStan/Type/data/model-scopes.php @@ -0,0 +1,75 @@ + $intersection + * @param Builder $attributed + */ +function test(Builder $intersection, Builder $attributed): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $intersection->where('foo', 'bar')); + assertType('Hypervel\Database\Eloquent\Builder', $intersection->forTimeline()); + assertType('Hypervel\Database\Eloquent\Model&ModelScope\ForTimeline', $intersection->forTimeline()->firstOrFail()); + + // An implementation does not inherit the interface method's attribute, so it is no scope. + assertType('*ERROR*', $attributed->archived()); +} + +class Scopes extends Model +{ + private User $user; + + public function scopeWithVoidReturn(Builder $query): void + { + $query->where('whyuse', 'void'); + } + + public function scopeCount(Builder $query): void + { + $query->whereNotNull('id'); + } + + /** @param Builder $query */ + public function test(User $user, Builder $query): void + { + assertType('Hypervel\Database\Eloquent\Relations\HasOne', $this->hasOne(User::class)->active()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $this->hasMany(User::class)->active()); + assertType('Hypervel\Database\Eloquent\Builder', User::where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->active()); + + assertType('Hypervel\Database\Eloquent\Builder', $this->user->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->where('name', 'bar')->active()); + assertType('App\User|null', $this->user->where('name', 'bar')->active()->first()); + assertType('Hypervel\Database\Eloquent\Builder', $this->withVoidReturn()); + assertType('ModelScope\Scopes|null', $this->withVoidReturn()->first()); + assertType('Hypervel\Database\Eloquent\Builder', self::query()->count()); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->whereActive()); + + // Unlike upstream, User's @method scopeSomeScope tag creates no scope: Model::hasNamedScope() needs a real method. + assertType('*ERROR*', $user->someScope()); + assertType('*ERROR*', User::query()->someScope()); + assertType('*ERROR*', $user->where('foo')->someScope()); + } +} + +interface ForTimeline +{ + /** + * @param Builder $query + * @return Builder + */ + public function scopeForTimeline(Builder $query): Builder; +} + +interface WithAttributedScope +{ + /** @param Builder $query */ + #[Scope] + public function archived(Builder $query): void; +} diff --git a/tests/PHPStan/bootstrap.php b/tests/PHPStan/bootstrap.php index aa3f475c0..fbc11d3b5 100644 --- a/tests/PHPStan/bootstrap.php +++ b/tests/PHPStan/bootstrap.php @@ -37,10 +37,18 @@ return 'macro'; }); +Builder::macro('whereGlobalMacro', static function (): string { + return 'macro'; +}); + QueryBuilder::macro('globalCustomDatabaseQueryMacro', static function (string $arg = 'foobar', int $b = 5): string { return $arg; }); +QueryBuilder::macro('whereQueryMacro', static function (): string { + return 'macro'; +}); + Route::macro('facadeMacro', static function (): int { return 5; }); From a2b384c7095d15b0d9de4d9570c24dbb950d9232 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:32:05 +0000 Subject: [PATCH 118/303] Refine Eloquent query results and relationship callback types Port model, builder and relation return extensions and polymorphic relationship callback inference. Preserve custom declarations, model-specific collections, template class-string calls and uncertain relationship targets. Infer only() key shapes without treating alternatives as simultaneously required, and infer integer keys for direct cursor paginator construction. Register the applicable upstream type fixtures with Hypervel's runtime semantics, including pivot values and model-forwarded calls. Validated as part of the reviewed checkpoint with formatting, PHPStan/types and 2421 analyzer tests. --- .../RelationshipQueryCallbackExtension.php | 154 ++++++ ...aginatorConstructorReturnTypeExtension.php | 72 +++ .../ReturnTypes/EloquentBuilderExtension.php | 79 ++++ ...DynamicStaticMethodReturnTypeExtension.php | 176 +++++++ ...elOnlyDynamicMethodReturnTypeExtension.php | 198 ++++++++ .../RelationCollectionExtension.php | 81 ++++ tests/PHPStan/Type/GeneralTypeTest.php | 27 ++ .../Type/data/custom-query-collections.php | 99 ++++ .../Type/data/eloquent-builder-l11-42.php | 384 +++++++++++++++ .../Type/data/eloquent-builder-l12-19.php | 68 +++ .../Type/data/eloquent-builder-l13-31.php | 128 +++++ tests/PHPStan/Type/data/eloquent-where.php | 120 +++++ .../Type/data/model-increment-each-l13-20.php | 13 + .../Type/data/model-increment-each-l13-3.php | 13 + tests/PHPStan/Type/data/model-l11-42.php | 378 +++++++++++++++ tests/PHPStan/Type/data/model-methods.php | 56 +++ .../Type/data/model-properties-relations.php | 108 +++++ tests/PHPStan/Type/data/model-relations.php | 438 ++++++++++++++++++ .../Type/data/model-template-class-string.php | 43 ++ .../Type/data/model-with-relations.php | 70 +++ .../PHPStan/Type/data/paginator-extension.php | 176 +++++++ tests/PHPStan/Type/data/passthru-l12-15.php | 21 + tests/PHPStan/Type/data/query-builder.php | 61 +++ .../data/relationship-query-callbacks.php | 290 ++++++++++++ tests/PHPStan/Type/data/where-relation.php | 23 + tests/PHPStan/application/app/ChildUser.php | 8 + tests/PHPStan/application/app/PostComment.php | 15 + 27 files changed, 3299 insertions(+) create mode 100644 src/phpstan/src/ClosureTypes/RelationshipQueryCallbackExtension.php create mode 100644 src/phpstan/src/ReturnTypes/CursorPaginatorConstructorReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/EloquentBuilderExtension.php create mode 100644 src/phpstan/src/ReturnTypes/ModelDynamicStaticMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/ModelOnlyDynamicMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/RelationCollectionExtension.php create mode 100644 tests/PHPStan/Type/data/custom-query-collections.php create mode 100644 tests/PHPStan/Type/data/eloquent-builder-l11-42.php create mode 100644 tests/PHPStan/Type/data/eloquent-builder-l12-19.php create mode 100644 tests/PHPStan/Type/data/eloquent-builder-l13-31.php create mode 100644 tests/PHPStan/Type/data/eloquent-where.php create mode 100644 tests/PHPStan/Type/data/model-increment-each-l13-20.php create mode 100644 tests/PHPStan/Type/data/model-increment-each-l13-3.php create mode 100644 tests/PHPStan/Type/data/model-l11-42.php create mode 100644 tests/PHPStan/Type/data/model-methods.php create mode 100644 tests/PHPStan/Type/data/model-properties-relations.php create mode 100644 tests/PHPStan/Type/data/model-relations.php create mode 100644 tests/PHPStan/Type/data/model-template-class-string.php create mode 100644 tests/PHPStan/Type/data/model-with-relations.php create mode 100644 tests/PHPStan/Type/data/paginator-extension.php create mode 100644 tests/PHPStan/Type/data/passthru-l12-15.php create mode 100644 tests/PHPStan/Type/data/query-builder.php create mode 100644 tests/PHPStan/Type/data/relationship-query-callbacks.php create mode 100644 tests/PHPStan/Type/data/where-relation.php create mode 100644 tests/PHPStan/application/app/ChildUser.php create mode 100644 tests/PHPStan/application/app/PostComment.php diff --git a/src/phpstan/src/ClosureTypes/RelationshipQueryCallbackExtension.php b/src/phpstan/src/ClosureTypes/RelationshipQueryCallbackExtension.php new file mode 100644 index 000000000..c920dd721 --- /dev/null +++ b/src/phpstan/src/ClosureTypes/RelationshipQueryCallbackExtension.php @@ -0,0 +1,154 @@ +queriedModel($methodReflection->getDeclaringClass()) === null) { + return false; + } + + return match ($parameter->getName()) { + 'callback' => in_array($methodReflection->getName(), ['hasMorph', 'doesntHaveMorph', 'whereHasMorph', 'orWhereHasMorph', 'whereDoesntHaveMorph', 'orWhereDoesntHaveMorph'], true), + 'column' => in_array($methodReflection->getName(), ['whereMorphRelation', 'orWhereMorphRelation', 'whereMorphDoesntHaveRelation', 'orWhereMorphDoesntHaveRelation'], true), + default => false, + }; + } + + /** + * Determine whether the parameter is a polymorphic relationship query callback. + */ + public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $this->isMethodSupported($methodReflection, $parameter); + } + + /** + * Return the callback type for an instance call. + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type + { + return $this->getCallbackType($methodReflection, $methodCall, $parameter, $scope); + } + + /** + * Return the callback type for a static call. + */ + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type + { + return $this->getCallbackType($methodReflection, $methodCall, $parameter, $scope); + } + + /** + * Return the model a forwarded or native builder method queries. + * + * Models and relations declare the builder methods they forward, so their calls reach this + * extension with the model or relation as the declaring class. + */ + private function queriedModel(ClassReflection $declaringClass): ?Type + { + if ($declaringClass->is(Model::class)) { + return new ObjectType($declaringClass->getName()); + } + + return $declaringClass->getAncestorWithClassName(Builder::class)?->getActiveTemplateTypeMap()->getType('TModel') + ?? $declaringClass->getAncestorWithClassName(Relation::class)?->getActiveTemplateTypeMap()->getType('TRelatedModel'); + } + + /** + * Return the callback type with the builders of the queried morph types. + */ + private function getCallbackType(MethodReflection $methodReflection, MethodCall|StaticCall $call, ParameterReflection $parameter, Scope $scope): ?Type + { + $arguments = []; + + foreach ($call->getArgs() as $position => $argument) { + // An unpacked argument hides which values the relation and types are. + if ($argument->unpack) { + return null; + } + + $arguments[$argument->name?->toString() ?? $position] = $argument->value; + } + + $relation = $arguments['relation'] ?? $arguments[0] ?? null; + $types = $arguments['types'] ?? $arguments[1] ?? null; + $model = $this->queriedModel($methodReflection->getDeclaringClass()); + + if ($relation === null || $types === null || $model === null) { + return null; + } + + $fallback = []; + + foreach (TypeUtils::flattenTypes($scope->getType($relation)) as $relationType) { + $fallback[] = $relationType->isString()->yes() + ? (new BuilderOfType($model, $this->builderHelper, $relationType))->resolve() + : (new BuilderOfType($relationType->getTemplateType(Relation::class, 'TRelatedModel'), $this->builderHelper))->resolve(); + } + + $fallback = TypeCombinator::union(...$fallback); + $builders = []; + + foreach (TypeUtils::flattenTypes($scope->getType($types)) as $type) { + if ($type->isArray()->yes()) { + $type = $type->getIterableValueType(); + } + + foreach (TypeUtils::flattenTypes($type) as $target) { + $objectType = $target->getClassStringObjectType(); + $builders[] = $target->isClassString()->yes() && (new ObjectType(Model::class))->isSuperTypeOf($objectType)->yes() + ? (new BuilderOfType($objectType, $this->builderHelper))->resolve() + : $fallback; + } + } + + $queryType = TypeCombinator::union(...$builders); + + return TypeTraverser::map($parameter->getType(), static function (Type $type, callable $traverse) use ($queryType): Type { + if ($type instanceof ClosureType) { + return $type->traverse(static fn (Type $parameterType): Type => TypeCombinator::union(new ObjectType(Builder::class), new ObjectType(Relation::class))->isSuperTypeOf($parameterType)->yes() ? $queryType : $parameterType); + } + + return $traverse($type); + }); + } +} diff --git a/src/phpstan/src/ReturnTypes/CursorPaginatorConstructorReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/CursorPaginatorConstructorReturnTypeExtension.php new file mode 100644 index 000000000..bd94ba95d --- /dev/null +++ b/src/phpstan/src/ReturnTypes/CursorPaginatorConstructorReturnTypeExtension.php @@ -0,0 +1,72 @@ +getName() === '__construct'; + } + + /** + * Get the paginator type with integer keys and the value type the arguments infer. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + // A subclass declares its own templates. + if (! $methodCall->class instanceof Name || strtolower($scope->resolveName($methodCall->class)) !== strtolower(CursorPaginator::class)) { + return null; + } + + $variant = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants()); + $valueType = $variant->getResolvedTemplateTypeMap()->getType('TValue'); + + if ($valueType === null || $valueType instanceof ErrorType) { + return null; + } + + // The stored collection keeps its default, as for any hand-built paginator. + $defaults = $methodReflection->getDeclaringClass()->getTemplateTypeMap()->resolveToBounds()->getTypes(); + + return new GenericObjectType(CursorPaginator::class, [new IntegerType, $valueType, $defaults['TCollection']]); + } +} diff --git a/src/phpstan/src/ReturnTypes/EloquentBuilderExtension.php b/src/phpstan/src/ReturnTypes/EloquentBuilderExtension.php new file mode 100644 index 000000000..86adce833 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/EloquentBuilderExtension.php @@ -0,0 +1,79 @@ +getName(), 'find') || ! CollectionHelper::isFrameworkQueryMethod($methodReflection)) { + return false; + } + + $modelType = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap()->getType('TModel'); + + return $modelType !== null && $modelType->getObjectClassNames() !== []; + } + + /** + * Return the model's collection class in place of the declared Eloquent collection. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $returnType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + + if (! in_array(Collection::class, $returnType->getReferencedClasses(), true)) { + return null; + } + + // Unlike upstream, only the collections inside the return type are replaced, so a paginator + // of models stays a paginator. + return $this->collectionHelper->replaceCollectionsInType($returnType); + } +} diff --git a/src/phpstan/src/ReturnTypes/ModelDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ModelDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 000000000..a64079804 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ModelDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,176 @@ +isForwardedToBuilder($methodReflection) || $this->isNativeModelMethod($methodReflection->getName()); + } + + /** + * Return the called model's builder or collection type. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $forwardedType = $this->isForwardedToBuilder($methodReflection) + ? $this->getTypeFromForwardedBuilderCall($methodCall, $scope) + : null; + + if ($forwardedType !== null) { + return $forwardedType; + } + + // Unlike upstream, a model's own declaration, such as HasBuilder's query(), is kept, and so is an + // instance method's: parent::newQuery() runs on $this, so its late static model applies. + if (! $this->isNativeModelMethod($methodReflection->getName()) + || ! $methodReflection->isStatic() + || $methodReflection->getDeclaringClass()->getName() !== Model::class) { + return null; + } + + // Unlike upstream, the declaration decides: a callback's result, such as withoutEvents()', is not the called + // model's builder or collection. + $returnType = $methodReflection->getDeclaringClass() + ->getMethod($methodReflection->getName(), $scope) + ->getVariants()[0] + ->getReturnType(); + + if ((new ObjectType(EloquentBuilder::class))->isSuperTypeOf($returnType)->yes()) { + $modelType = $methodCall->class instanceof Name + ? $scope->resolveTypeByName($methodCall->class) + : $scope->getType($methodCall->class)->getObjectTypeOrClassStringObjectType(); + + if (! (new ObjectType(Model::class))->isSuperTypeOf($modelType)->yes()) { + return null; + } + + return new BuilderOfType($modelType instanceof ThisType ? $modelType->getStaticObjectType() : $modelType, $this->builderHelper); + } + + if (in_array(Collection::class, $returnType->getReferencedClasses(), true)) { + $modelType = $methodCall->class instanceof Name + ? new ObjectType($scope->resolveName($methodCall->class)) + : $scope->getType($methodCall->class)->getObjectTypeOrClassStringObjectType(); + + $types = []; + + foreach ($modelType->getObjectClassNames() as $modelName) { + $types[] = $this->collectionHelper->determineCollectionClass($modelName, TypeCombinator::intersect($modelType, new ObjectType($modelName))); + } + + if ($types !== []) { + return TypeCombinator::union(...$types); + } + } + + return null; + } + + /** + * Determine whether Model::__callStatic() forwards the call to the Eloquent builder. + */ + private function isForwardedToBuilder(MethodReflection $methodReflection): bool + { + return $methodReflection->getPrototype() instanceof ForwardedModelMethodReflection; + } + + /** + * Determine whether the base model declares the method. + */ + private function isNativeModelMethod(string $name): bool + { + return $this->reflectionProvider->getClass(Model::class)->hasNativeMethod($name); + } + + /** + * Resolve a forwarded call on a builder of the called model type, so `$class::findOrFail()` + * on a `class-string` keeps T. + */ + private function getTypeFromForwardedBuilderCall(StaticCall $methodCall, Scope $scope): ?Type + { + if ($methodCall->class instanceof Name || $methodCall->isFirstClassCallable()) { + return null; + } + + $modelType = $scope->getType($methodCall->class)->getObjectTypeOrClassStringObjectType(); + + // A concrete model class already resolves through ForwardedModelMethodExtension, + // a template type is replaced by its bound there. + if (! TypeUtils::containsTemplateType($modelType)) { + return null; + } + + $modelClassNames = $modelType->getObjectClassNames(); + + if (count($modelClassNames) !== 1) { + return null; + } + + $builderType = $this->builderHelper->determineBuilderClass($modelClassNames[0], $modelType); + + if ($builderType === null) { + return null; + } + + return $scope->getType(new MethodCall( + new TypeExpr($builderType), + $methodCall->name, + $methodCall->getArgs(), + )); + } +} diff --git a/src/phpstan/src/ReturnTypes/ModelOnlyDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ModelOnlyDynamicMethodReturnTypeExtension.php new file mode 100644 index 000000000..ab9c975ba --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ModelOnlyDynamicMethodReturnTypeExtension.php @@ -0,0 +1,198 @@ +getName() === 'only' + && $methodReflection->getDeclaringClass()->getName() === Model::class; + } + + /** + * Return the attribute shapes the given keys select. + * + * Each alternative of the first argument selects its own keys, so `only($flag ? 'name' : 'email')` + * returns `array{name: T}|array{email: T}` rather than one shape with both keys. Within one shape, + * a later value with several possible keys makes each of them optional, which keeps the work + * linear in the number of keys. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $slots = []; + + foreach ($methodCall->getArgs() as $argument) { + $type = $scope->getType($argument->value); + + if (! $argument->unpack) { + $slots[] = [$type, true]; + + continue; + } + + // String keys unpack as named arguments. + if (! $type->getIterableKeyType()->isInteger()->yes()) { + return null; + } + + $alternatives = $this->valueSlots($type); + + // Several alternatives merge into one slot that holds any number of their values. + $slots = [ + ...$slots, + ...(count($alternatives) === 1 ? $alternatives[0] : [[$type->getIterableValueType(), false]]), + ]; + } + + // An optional first value hides which argument only() reads first. + if ($slots === [] || ! $slots[0][1]) { + return null; + } + + $shapes = []; + + foreach (TypeUtils::flattenTypes($slots[0][0]) as $firstType) { + // only() reads an array first argument alone, and otherwise every argument as a key. + if ($firstType->isArray()->yes()) { + foreach ($this->valueSlots($firstType) as $valueSlots) { + $shapes[] = $this->keyShape($valueSlots); + } + } else { + $shapes[] = $this->keyShape([[$firstType, true], ...array_slice($slots, 1)]); + } + } + + if (in_array(null, $shapes, true)) { + return null; + } + + $model = $scope->getType($methodCall->var); + $results = []; + + foreach ($shapes as $keys) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + // A numeric key name becomes an integer offset, as it does in the result. + foreach ($keys as $name => $required) { + $builder->setOffsetValueType(ConstantTypeHelper::getTypeFromValue($name), $this->attributeType($model, (string) $name, $scope), ! $required); + } + + $results[] = $builder->getArray(); + } + + return TypeCombinator::union(...$results); + } + + /** + * Return the keys that key slots select, each mapped to whether it is always present. + * + * Null means a key is not a known string. + * + * @param list $slots each slot's key type and whether the slot is always present + * @return null|array + */ + private function keyShape(array $slots): ?array + { + $shape = []; + + foreach ($slots as [$type, $required]) { + $keys = $type->getConstantStrings(); + + if ($keys === []) { + return null; + } + + // A slot with several possible keys selects only one of them. + $present = $required && count($keys) === 1; + + foreach ($keys as $key) { + $name = $key->getValue(); + $shape[$name] = $present || ($shape[$name] ?? false); + } + } + + return $shape; + } + + /** + * Return the value slots of each alternative of an array, each with whether it is always present. + * + * @return list> + */ + private function valueSlots(Type $type): array + { + $alternatives = []; + + foreach ($type->getArrays() as $array) { + $constantArrays = $array->getConstantArrays(); + + // An array of unknown size may hold any number of its values. + if ($constantArrays === []) { + $alternatives[] = [[$array->getIterableValueType(), false]]; + + continue; + } + + foreach ($constantArrays as $constantArray) { + $slots = []; + + foreach ($constantArray->getValueTypes() as $offset => $valueType) { + $slots[] = [$valueType, ! $constantArray->isOptionalKey($offset)]; + } + + $alternatives[] = $slots; + } + } + + return $alternatives; + } + + /** + * Return the type getAttribute() reads for the key. + * + * Unlike upstream, an unknown key reads as mixed rather than null, as the property does: the + * value can be a selected alias that no column or accessor declares. + */ + private function attributeType(Type $model, string $name, Scope $scope): Type + { + return $model->hasInstanceProperty($name)->yes() + ? $model->getInstanceProperty($name, $scope)->getReadableType() + : new MixedType; + } +} diff --git a/src/phpstan/src/ReturnTypes/RelationCollectionExtension.php b/src/phpstan/src/ReturnTypes/RelationCollectionExtension.php new file mode 100644 index 000000000..b3df3c21a --- /dev/null +++ b/src/phpstan/src/ReturnTypes/RelationCollectionExtension.php @@ -0,0 +1,81 @@ +getDeclaringClass()->getActiveTemplateTypeMap()->getType('TRelatedModel'); + + if ($modelType === null && $methodReflection->getDeclaringClass()->getName() === Builder::class) { + $modelType = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap()->getType('TModel'); + } + + return $modelType !== null + && $modelType->getObjectClassNames() !== [] + && CollectionHelper::isFrameworkQueryMethod($methodReflection); + } + + /** + * Return the related model's collection class in place of the declared Eloquent collection. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $returnType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + + if (! in_array(Collection::class, $returnType->getReferencedClasses(), true)) { + return null; + } + + return $this->collectionHelper->replaceCollectionsInType($returnType); + } +} diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php index e69395470..7a8d6bc3d 100644 --- a/tests/PHPStan/Type/GeneralTypeTest.php +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -32,11 +32,20 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/data/artisan.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/auth.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/benchmark.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/builder-of-type.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-of-type.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/conditionable.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/console-commands.php'); // REMOVED: container-array-access.php; container ArrayAccess is unsupported. yield from self::gatherAssertTypes(__DIR__ . '/data/container-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/contracts.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/custom-eloquent-builder.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/custom-query-collections.php'); + // REMOVED: eloquent-builder.php; it asserts Laravel releases before 11.42. + yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-builder-l11-42.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-builder-l12-19.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-builder-l13-31.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-where.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/environment-helper.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/facade-fakes.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/facades.php'); @@ -50,16 +59,33 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/data/higher-order-collection-proxy-methods.php'); // Hypervel: covers the overrides that repeat method templates for PHPStan 2.3.5. yield from self::gatherAssertTypes(__DIR__ . '/data/inherited-method-templates.php'); + // REMOVED: model.php; it asserts Laravel releases before 11.42. + yield from self::gatherAssertTypes(__DIR__ . '/data/model-l11-42.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-date-column-casts.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-increment-each-l13-3.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-increment-each-l13-20.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-methods.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties-relations.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties-l13.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties-l13-2.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-relations.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-serialization.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-serialization-l13.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-scope-attribute-l12.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-scopes.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-template-class-string.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-with-relations.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/managers.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/optional-helper.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/paginator-extension.php'); + // REMOVED: passthru.php; it asserts Laravel releases before 12.15. + yield from self::gatherAssertTypes(__DIR__ . '/data/passthru-l12-15.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/query-builder.php'); // Upstream runs this per redisDriver setting; Hypervel only has phpredis, so it runs once here. yield from self::gatherAssertTypes(__DIR__ . '/data/redis-driver-phpredis.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/relationship-query-callbacks.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/relation-of-type.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/request-header.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/request-object.php'); // Upstream never registers request-user.php, which shares request-object.php's namespace. @@ -71,6 +97,7 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/data/translator.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/view-exists.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/view.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/where-relation.php'); // Console Commands yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/BarCommand.php'); diff --git a/tests/PHPStan/Type/data/custom-query-collections.php b/tests/PHPStan/Type/data/custom-query-collections.php new file mode 100644 index 000000000..13800094b --- /dev/null +++ b/tests/PHPStan/Type/data/custom-query-collections.php @@ -0,0 +1,99 @@ + $query + */ +function test(MemberBuilder $query, Member $member): void +{ + assertType('CustomQueryCollections\Members', $query->get()); + assertType('Hypervel\Database\Eloquent\Collection', $query->hydrate([])); + + assertType('CustomQueryCollections\Members', $member->children()->get()); + assertType('Hypervel\Database\Eloquent\Collection', $member->children()->hydrate([])); + + assertType('Hypervel\Database\Eloquent\Collection', $member->friends()->get()); + assertType('CustomQueryCollections\Members', $member->friends()->getResults()); +} + +#[CollectedBy(Members::class)] +class Member extends Model +{ + /** @use HasBuilder> */ + use HasBuilder; + + protected static string $builder = MemberBuilder::class; + + /** @return HasMany */ + public function children(): HasMany + { + return $this->hasMany(Member::class); + } + + /** @return Friends */ + public function friends(): Friends + { + return new Friends(Member::query(), $this, 'friend_id', 'id'); + } +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + */ +class Members extends Collection +{ +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class MemberBuilder extends Builder +{ + /** + * Hydrate rows into a plain collection. + * + * @return Collection + */ + public function hydrate(array $items): Collection + { + return new Collection; + } +} + +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * + * @extends HasMany + */ +class Friends extends HasMany +{ + /** + * Get the friends as a plain collection. + * + * @return Collection + */ + public function get(array|string $columns = ['*']): Collection + { + return new Collection; + } +} diff --git a/tests/PHPStan/Type/data/eloquent-builder-l11-42.php b/tests/PHPStan/Type/data/eloquent-builder-l11-42.php new file mode 100644 index 000000000..7029b839c --- /dev/null +++ b/tests/PHPStan/Type/data/eloquent-builder-l11-42.php @@ -0,0 +1,384 @@ + $userBuilder + * @param Builder $userOrTeamBuilder + * @param Builder $templateBuilder + */ +function test( + User $user, + Post $post, + Builder $userBuilder, + OnlyUsers&User $userAndAuth, + Builder $userOrTeamBuilder, + Builder $templateBuilder, +): void { + User::query()->has('accounts', '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->has('users', '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->doesntHave('accounts', 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->doesntHave('users', 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->whereHas('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->whereHas('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->orWhereHas('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->orWhereHas('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->hasMorph('commentable', [Account::class], '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->hasMorph('commentable', [User::class], '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->doesntHaveMorph('commentable', [Account::class], 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->doesntHaveMorph('commentable', [User::class], 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->orWhereHasMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->orWhereHasMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereDoesntHaveMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereDoesntHaveMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->orWhereDoesntHaveMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->orWhereDoesntHaveMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->whereDoesntHave('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->whereDoesntHave('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->orWhereDoesntHave('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->orWhereDoesntHave('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->firstWhere(function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->firstWhere(function (PostBuilder $query) { + assertType('App\PostBuilder', $query); + }); + + Post::query()->where(static function (PostBuilder $query) { + assertType('App\PostBuilder', $query + ->orWhere('bar', 'LIKE', '%foo%') + ->orWhereRelation('users', 'name', 'LIKE', '%foo%')); + }); + + assertType('Hypervel\Database\Eloquent\Collection', User::where([ + ['active', true], + ['id', '>=', 5], + ['id', '<=', 10], + ])->get()); + + assertType('Hypervel\Database\Eloquent\Collection', User::where('id', 1)->get()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->where('id', 1)->get()); + // Unlike upstream, which runs without model property checks, these dynamic wheres name a real column. + assertType('Hypervel\Database\Eloquent\Collection', User::where('id', 1) + ->whereNotNull('name') + ->where('email', 'bar') + ->whereName(['bar']) + ->get()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->whereNotNull('name') + ->where('email', 'bar') + ->whereName(['bar']) + ->get()); + // Unlike upstream, User's public email() accessor applies, as it does in Eloquent, so email reads as mixed. + assertType('Hypervel\Support\Collection', User::whereIn('id', [1, 2, 3])->get()->mapWithKeys(function (User $user): array { + return [$user->name => $user->email]; + })); + + assertType('mixed', (new User)->where('email', 1)->max('email')); + assertType('bool', (new User)->where('email', 1)->exists()); + assertType('Hypervel\Database\Eloquent\Builder', User::with('accounts')->whereNull('name')); + assertType('Hypervel\Database\Eloquent\Builder', User::with('accounts') + ->where('email', 'bar') + ->orWhere('name', 'baz')); + + assertType('App\User|null', User::with(['accounts'])->find(1)); + assertType('App\User', User::with(['accounts'])->findOrFail(1)); + assertType('App\User', User::with(['accounts'])->findOrNew(1)); + assertType('Hypervel\Database\Eloquent\Model|null', (new CustomBuilder(User::query()->getQuery()))->with('email')->find(1)); + + assertType('Hypervel\Database\Eloquent\Collection', User::with(['accounts'])->find([1, 2, 3])); + assertType('Hypervel\Database\Eloquent\Collection', User::with(['accounts'])->findOrNew([1, 2, 3])); + assertType('Hypervel\Database\Eloquent\Collection', User::hydrate([])); + assertType('Hypervel\Database\Eloquent\Collection', User::fromQuery('SELECT * FROM users')); + + assertType('Hypervel\Database\Eloquent\Builder', $userBuilder->whereNotNull('test')); + + assertType('Hypervel\Database\Eloquent\Builder', $user->newQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $user->newModelQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $user->newQueryWithoutRelationships()); + assertType('Hypervel\Database\Eloquent\Builder', $user->newQueryWithoutScopes()); + assertType('Hypervel\Database\Eloquent\Builder', $user->newQueryWithoutScope('foo')); + assertType('Hypervel\Database\Eloquent\Builder', $user->newQueryForRestoration([1])); + + assertType('App\PostBuilder', $post->newQuery()); + assertType('App\PostBuilder', $post->newModelQuery()); + assertType('App\PostBuilder', $post->newQueryWithoutRelationships()); + assertType('App\PostBuilder', $post->newQueryWithoutScopes()); + assertType('App\PostBuilder', $post->newQueryWithoutScope('foo')); + assertType('App\PostBuilder', $post->newQueryForRestoration([1])); + + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newModelQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newQueryWithoutRelationships()); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newQueryWithoutScopes()); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newQueryWithoutScope('foo')); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newQueryForRestoration([1])); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth::query()); + + assertType('Hypervel\Support\LazyCollection', User::query()->lazy()); + assertType('Hypervel\Support\LazyCollection', User::query()->lazyById()); + assertType('Hypervel\Support\LazyCollection', User::query()->lazyByIdDesc()); + assertType('Hypervel\Support\LazyCollection', User::query()->cursor()); + assertType('Hypervel\Support\LazyCollection', $post->newQuery()->lazy()); + assertType('Hypervel\Support\LazyCollection', $post->newQuery()->lazyById()); + assertType('Hypervel\Support\LazyCollection', $post->newQuery()->lazyByIdDesc()); + + assertType('Hypervel\Database\Eloquent\Builder', User::query()->groupBy('foo', 'bar')); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->whereEmail('bar')); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->whereIdAndEmail(1, 'foo@example.com')); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->whereEmail(1)); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNull('name') + ->orderBy('email') + ->toBase() + ); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNotBetween('a', [1, 5]) + ->orWhereNotBetween('a', [1, 5]) + ->toBase() + ); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->withTrashed()); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNull('name') + ->orderBy(\Hypervel\Support\Facades\DB::raw('name')) + ->toBase() + ); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNull('name') + ->orderBy(User::whereNotNull('name')) + ->toBase() + ); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNull('name') + ->latest(\Hypervel\Support\Facades\DB::raw('created_at')) + ->toBase() + ); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNull('name') + ->oldest(\Hypervel\Support\Facades\DB::raw('created_at')) + ->toBase() + ); + assertType('Hypervel\Support\Collection<(int|string), mixed>', User::query() + ->whereNull('name') + ->pluck(\Hypervel\Support\Facades\DB::raw('created_at')) + ->toBase() + ); + assertType('int', User::query()->increment(\Hypervel\Support\Facades\DB::raw('counter'))); + assertType('int', User::query()->decrement(\Hypervel\Support\Facades\DB::raw('counter'))); + // Unlike upstream, registering a local macro returns null, as Builder::__call() does. + assertType('null', $userBuilder->macro('customMacro', function () { + })); + assertType('string', $userBuilder->globalCustomMacro('foo')); + assertType('App\User', User::with('accounts') + ->where('email', 'bar') + ->orWhere('name', 'baz') + ->firstOrFail() + ); + assertType('App\User|null', User::with('accounts') + ->where('email', 'bar') + ->orWhere('name', 'baz') + ->first() + ); + assertType('App\User|null', User::query()->firstWhere(['email' => 'foo@bar.com'])); + assertType('App\User|null', User::with('accounts') + ->orWhere(\Hypervel\Support\Facades\DB::raw('name'), 'like', '%john%') + ->first() + ); + assertType('App\User|null', User::with('accounts') + ->where(\Hypervel\Support\Facades\DB::raw('name'), 'like', '%john%') + ->first() + ); + assertType('App\User|null', User::with('accounts') + ->firstWhere(\Hypervel\Support\Facades\DB::raw('name'), 'like', '%john%') + ); + assertType('mixed', User::with('accounts') + ->value(\Hypervel\Support\Facades\DB::raw('name')) + ); + assertType('int', User::query()->restore()); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->joinSub( + Post::query()->whereIn('id', [1, 2, 3]), + 'users', + 'users.id', + 'posts.id' + )); + + // Unlike upstream, paginators carry their collection class as a third template. + assertType('Hypervel\Pagination\LengthAwarePaginator>', User::query()->paginate()); + assertType('array', User::query()->paginate()->items()); + + User::chunk(1000, fn ($collection) => assertType('Hypervel\Database\Eloquent\Collection', $collection)); + User::chunkById(1000, fn ($collection) => assertType('Hypervel\Database\Eloquent\Collection', $collection)); + assertType('Hypervel\Support\Collection', User::chunkMap(function ($model) { + assertType('App\User', $model); + + return $model->name; + }, 1000)); + $userBuilder->chunk(1000, fn ($collection) => assertType('Hypervel\Database\Eloquent\Collection', $collection)); + $userBuilder->chunkById(1000, fn ($collection) => assertType('Hypervel\Database\Eloquent\Collection', $collection)); + assertType('Hypervel\Support\Collection', $userBuilder->chunkMap(function ($model) { + assertType('App\User', $model); + + return $model->name; + }, 1000)); + + assertType('App\Team|App\User', $userOrTeamBuilder->findOrFail(4)); + assertType('Hypervel\Database\Eloquent\Builder', $userOrTeamBuilder->where('id', 5)); + + assertType('Hypervel\Database\Eloquent\Builder', $templateBuilder->select()); +} + +class Foo extends Model +{ + /** @use FooTrait */ + use FooTrait; +} + +/** @template TModel of Model */ +trait FooTrait +{ + /** @return Builder */ + public function doFoo(): Builder + { + return $this->newQuery(); + } +} + +/** @property string $email */ +class TestModel extends Model +{ + public function test(): void + { + // Unlike upstream, forwarding through a model keeps its late-static type, as static::query() does. + assertType('Hypervel\Database\Eloquent\Collection', $this->where('email', 1)->get()); + assertType('Hypervel\Database\Eloquent\Builder', static::query()->where('email', 'bar')); + assertType('Hypervel\Database\Eloquent\Builder', $this->where('email', 'bar')); + } +} + +/** @extends Builder */ +class CustomBuilder extends Builder +{ +} + +/** @template TModel of User|Team */ +abstract class UnionClass +{ + /** @return TModel */ + public function test(int $id): Model + { + assertType('TModel of App\Team|App\User (class EloquentBuilder\UnionClass, argument)', $this->getQuery()->findOrFail($id)); + + return $this->getQuery()->findOrFail($id); + } + + /** @return Builder */ + abstract public function getQuery(): Builder; +} + +/** @extends UnionClass */ +class TeamClass extends UnionClass +{ + public function foo(): void + { + assertType('App\Team', $this->test(5)); + } + + /** @inheritDoc */ + public function getQuery(): Builder + { + return Team::query(); + } +} diff --git a/tests/PHPStan/Type/data/eloquent-builder-l12-19.php b/tests/PHPStan/Type/data/eloquent-builder-l12-19.php new file mode 100644 index 000000000..1c195c155 --- /dev/null +++ b/tests/PHPStan/Type/data/eloquent-builder-l12-19.php @@ -0,0 +1,68 @@ + */ +class CustomBuilder extends \Hypervel\Database\Eloquent\Builder {} + +/** @extends Builder */ +class AnotherCustomBuilder extends \Hypervel\Database\Eloquent\Builder {} + +function test(): void +{ + assertType('EloquentBuilder1215\CustomBuilder', A::query()); + assertType('EloquentBuilder1215\AnotherCustomBuilder', B::query()); + assertType('EloquentBuilder1215\CustomBuilder', C::query()); + assertType('EloquentBuilder1215\CustomBuilder', D::query()); + assertType('EloquentBuilder1215\CustomBuilder', E::query()); + assertType('Hypervel\Database\Eloquent\Builder', F::query()); + + // Unlike upstream, which reads a class constant's class as the builder, the constant's value names it, as + // instantiating the attribute reads it. + assertType('EloquentBuilder1215\CustomBuilder', G::query()); + assertType('EloquentBuilder1215\CustomBuilder', H::query()); +} diff --git a/tests/PHPStan/Type/data/eloquent-builder-l13-31.php b/tests/PHPStan/Type/data/eloquent-builder-l13-31.php new file mode 100644 index 000000000..d0d422737 --- /dev/null +++ b/tests/PHPStan/Type/data/eloquent-builder-l13-31.php @@ -0,0 +1,128 @@ +', Post::query()); + assertType('EloquentBuilderL1331\BaseBuilder', Post::where('id', 1)); + assertType('EloquentBuilderL1331\BaseBuilder', Comment::query()); + assertType('EloquentBuilderL1331\VideoBuilder', Video::query()); + assertType('Hypervel\Database\Eloquent\Builder', Image::query()); + assertType('EloquentBuilderL1331\BaseBuilder', Tag::query()); + assertType('EloquentBuilderL1331\BaseBuilder', TraitParentChild::query()); + assertType('EloquentBuilderL1331\VideoBuilder', TraitChild::query()); + assertType('Hypervel\Database\Eloquent\Builder', NestedTraitModel::query()); + assertType('Hypervel\Database\Eloquent\Builder', InvalidBuilderChild::query()); +} + +#[UseEloquentBuilder(BaseBuilder::class)] +abstract class BaseModel extends Model +{ + +} + +class Post extends BaseModel +{ + +} + +class Comment extends Post +{ + +} + +#[UseEloquentBuilder(VideoBuilder::class)] +class Video extends BaseModel +{ + +} + +class Image extends BaseModel +{ + /** @return Builder */ + public function newEloquentBuilder($query): Builder + { + return new Builder($query); + } +} + +#[UseEloquentBuilder(BaseBuilder::class)] +trait HasBaseBuilder +{ + +} + +#[UseEloquentBuilder(VideoBuilder::class)] +trait HasVideoBuilder +{ + +} + +trait UsesHasBaseBuilder +{ + use HasBaseBuilder; +} + +class Tag extends Model +{ + use HasBaseBuilder; +} + +abstract class TraitParent extends Model +{ + use HasBaseBuilder; +} + +class TraitParentChild extends TraitParent +{ + +} + +class TraitChild extends BaseModel +{ + use HasVideoBuilder; +} + +/** Hypervel only reads the traits the class uses itself, so the attribute of HasBaseBuilder is skipped. */ +class NestedTraitModel extends Model +{ + use UsesHasBaseBuilder; +} + +/** Hypervel ignores a class that is not an Eloquent builder. */ +#[UseEloquentBuilder(Model::class)] +abstract class InvalidBuilderParent extends Model +{ + +} + +class InvalidBuilderChild extends InvalidBuilderParent +{ + +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class BaseBuilder extends Builder +{ + +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class VideoBuilder extends Builder +{ + +} diff --git a/tests/PHPStan/Type/data/eloquent-where.php b/tests/PHPStan/Type/data/eloquent-where.php new file mode 100644 index 000000000..7ab6954c0 --- /dev/null +++ b/tests/PHPStan/Type/data/eloquent-where.php @@ -0,0 +1,120 @@ +', User::query()->where(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + + User::query()->where(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }, null, 2); + + assertType('Hypervel\Database\Eloquent\Builder', User::query()->where(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2)); + + User::where(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, 2); + + $user->where(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', null); + + assertType('App\User|null', User::query()->firstWhere(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + + assertType('App\User|null', User::firstWhere(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2)); + + User::query()->whereNot(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }, null, null, 'or'); + + User::query()->whereNot(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2); + + User::query()->orWhereNot(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }, null); + + User::query()->orWhereNot(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2); + + Post::where(function ($query) { + assertType('App\PostBuilder', $query); + }); + + Post::query()->where(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2); + + $user->posts()->where(function ($query) { + assertType('App\PostBuilder', $query); + }); + + $user->posts()->where(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2); + + User::query()->where(value: 2, column: function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->where(operator: '=', value: 2, column: function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }); + + User::query()->where(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder', $query); + }, $operator, 2); + + User::query()->where(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder', $query); + }, $unknown, 2); + + User::query()->orWhere(function ($query) { + assertType('(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder)', $query); + }); + + User::query()->orWhere(function ($query) { + assertType('(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder)', $query); + }, null); + + User::query()->orWhere(function ($query) { + assertType('(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder)', $query); + }, null, null); + + Post::orWhere(function ($query) { + assertType('(App\PostBuilder|Hypervel\Database\Query\Builder)', $query); + }, '=', 2); +} + +/** + * @param Builder $unknown + */ +function dynamicWheres(Builder $unknown, User $user): void +{ + // Any column may belong to a subclass of an abstract model. + assertType('Hypervel\Database\Eloquent\Builder', $unknown->whereNameAndEmail('Taylor', 'taylor@example.com')); + + // Unlike upstream, a missing column does not remove the method: ModelPropertyColumnRule reports it. + assertType('Hypervel\Database\Eloquent\Builder', User::query()->whereMissingColumn(1)); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->accounts()->whereMissingColumn(1)); +} diff --git a/tests/PHPStan/Type/data/model-increment-each-l13-20.php b/tests/PHPStan/Type/data/model-increment-each-l13-20.php new file mode 100644 index 000000000..1f800d084 --- /dev/null +++ b/tests/PHPStan/Type/data/model-increment-each-l13-20.php @@ -0,0 +1,13 @@ +incrementEachQuietly(['counter' => 1])); + assertType('int', $user->decrementEachQuietly(['counter' => 1])); +} diff --git a/tests/PHPStan/Type/data/model-increment-each-l13-3.php b/tests/PHPStan/Type/data/model-increment-each-l13-3.php new file mode 100644 index 000000000..be8733e34 --- /dev/null +++ b/tests/PHPStan/Type/data/model-increment-each-l13-3.php @@ -0,0 +1,13 @@ +incrementEach(['counter' => 1])); + assertType('int|false', $user->decrementEach(['counter' => 1])); +} diff --git a/tests/PHPStan/Type/data/model-l11-42.php b/tests/PHPStan/Type/data/model-l11-42.php new file mode 100644 index 000000000..c63b325b4 --- /dev/null +++ b/tests/PHPStan/Type/data/model-l11-42.php @@ -0,0 +1,378 @@ +create()); + return static::query()->create(); + } + + public function newQueryKeepsStatic(): void + { + assertType('Hypervel\Database\Eloquent\Builder', $this->newQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $this->newModelQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $this->newQueryWithoutRelationships()); + assertType('Hypervel\Database\Eloquent\Builder', $this->newQueryWithoutScopes()); + assertType('Hypervel\Database\Eloquent\Builder', $this->newQueryWithoutScope('foo')); + assertType('Hypervel\Database\Eloquent\Builder', $this->newQueryForRestoration([1])); + } +} + +class Child extends AbstractModel {} + +class TreeNode extends Model +{ + use SoftDeletes; + + /** @return HasMany */ + public function children(): HasMany + { + return $this->hasMany(static::class); + } + + public function forwardedLateStaticModel(): void + { + // Forwarded signatures keep the late-static model instead of the builder or relation they run on. + assertType('static(Model\TreeNode)', static::restoreOrCreate()); + assertType('static(Model\TreeNode)|null', $this->children()->first()); + assertType('static(Model\TreeNode)', $this->children()->restoreOrCreate()); + } + + protected function setKeysForSaveQuery(Builder $query): Builder + { + // A parent:: call of an instance method runs on $this, so the late-static model stays. + assertType('Hypervel\Database\Eloquent\Builder', parent::setKeysForSaveQuery($query)); + + return parent::setKeysForSaveQuery($query); + } +} + +class ChildPost extends Post +{ + public function parentQuery(): void + { + assertType('App\PostBuilder', parent::newQuery()); + } +} + +final class FinalModel extends Model +{ + public function newQueryOnFinalModel(): void + { + // `static` and the class name are the same type here, so the plain object type stays. + assertType('Hypervel\Database\Eloquent\Builder', $this->newQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $this->newModelQuery()); + } +} + +class Foo +{ + public function __construct(private User $user) + { + assertType('Hypervel\Database\Eloquent\Builder', $this->user::query()); + } +} + +class Bar extends Model +{ + use HasBar; + + public function test(): void + { + assertType('Hypervel\Database\Eloquent\Builder', self::query()); + assertType('Hypervel\Database\Eloquent\Builder', static::query()); + + assertType('Model\Bar|null', self::query()->first()); + assertType('static(Model\Bar)|null', static::query()->first()); + assertType('Hypervel\Database\Eloquent\Builder', static::query()->orWhere('foo', 'bar')); + assertType('Hypervel\Database\Eloquent\Builder', static::query()->select('foo')); + } +} + +trait HasBar +{ + /** @return mixed[] */ + public static function decodeHashId(string $hash_id): array + { + return []; + } + + public static function findByHashId(string $id): ?self + { + return self::find(static::decodeHashId($id))->first(); + } +} + +/** + * @param class-string $modelClass + * @param class-string|class-string $userOrPostClass + * @param class-string|class-string $userOrAccountClass + */ +function test( + Model $model, + string $modelClass, + string $userOrPostClass, + string $userOrAccountClass, + FormRequest $request, + User $user, +): void { + /** @var array $requestData */ + $requestData = $request->validated(); + + assertType('Model\Child', Child::new()); + + assertType('App\User|null', User::find(1)); + assertType('Model\Bar|null', Bar::findByHashId('1')); + assertType('Hypervel\Database\Eloquent\Model|null', $model::find(1)); + assertType('Hypervel\Database\Eloquent\Model|null', $modelClass::find(1)); + assertType('App\Post|App\User|null', $userOrPostClass::find(1)); + assertType('Hypervel\Database\Eloquent\Collection|Hypervel\Database\Eloquent\Collection', $userOrPostClass::find([1, 2, 3])); + assertType('App\AccountCollection|Hypervel\Database\Eloquent\Collection', $userOrAccountClass::find([1, 2, 3])); + assertType('App\AccountCollection|Hypervel\Database\Eloquent\Collection', $userOrAccountClass::all()); + + assertType('Hypervel\Database\Eloquent\Collection', User::find([1, 2, 3])); + assertType('Hypervel\Database\Eloquent\Collection', User::findMany([1, 2, 3])); + assertType('App\User', User::findOrFail(1)); + assertType('Hypervel\Database\Eloquent\Collection', User::findOrFail([1, 2, 3])); + assertType('Hypervel\Database\Eloquent\Collection', User::findOrFail([1, 2, 3])->makeHidden('foo')); + assertType('App\User|null', User::findOrFail([1, 2, 3])->makeHidden('foo')->first()); + + assertType('Hypervel\Database\Eloquent\Collection', User::find((array) $requestData['user_ids'])); + assertType('App\User|null', User::find((int) '1')); + assertType('App\User', $user->make([])); + assertType('Hypervel\Database\Query\Builder', User::getQuery()); + assertType('Hypervel\Database\Query\Builder', User::toBase()); + assertType('Hypervel\Database\Eloquent\Collection', User::all()); + // Unlike upstream, a callback's result stays its own type rather than the called model's collection or builder. + assertType('App\AccountCollection', User::withoutEvents(fn () => Account::all())); + assertType('Hypervel\Database\Eloquent\Builder', User::unguarded(fn () => Account::query())); + assertType('Hypervel\Database\Eloquent\Builder', User::join('tickets.tickets', 'tickets.tickets.id', '=', 'tickets.sale_ticket.ticket_id')); + + assertType('Hypervel\Database\Eloquent\Builder', (new Thread)->where(['name' => 'bar'])); + assertType('Hypervel\Database\Eloquent\Builder', Thread::where(['name' => 'bar'])); + assertType('Hypervel\Database\Eloquent\Builder', (new Thread)->whereName(['bar'])); + assertType('Hypervel\Database\Eloquent\Builder', Thread::whereName(['bar'])); + assertType('Hypervel\Database\Eloquent\Builder', (new Thread)->whereIn('id', [1, 2, 3])); + assertType('Hypervel\Database\Eloquent\Builder', (new User)->withWhereHas('accounts', function ($query) { + return $query->where('active', true); + })); + + assertType('int|false', $user->increment('counter')); + assertType('int|false', $user->decrement('counter')); + assertType('int', $user->incrementQuietly('counter')); + assertType('int', $user->decrementQuietly('counter')); + + assertType('App\User|null', User::first()); + assertType('App\User', User::make([])); + assertType('App\User', User::create([])); + assertType('App\User', User::forceCreate([])); + + assertType('Hypervel\Database\Eloquent\Collection', User::findOrNew([])); + assertType('App\User', User::firstOrNew([])); + assertType('App\User', User::updateOrCreate([])); + assertType('App\User', User::firstOrCreate([])); + assertType('App\User', User::createOrFirst([])); + + assertType('Hypervel\Database\Eloquent\Builder', Thread::valid()); + assertType('Hypervel\Database\Eloquent\Builder', Thread::valid()->orWhere->valid()); + assertType('Hypervel\Database\Eloquent\Builder', Thread::valid()->orWhereNot->valid()); + assertType('Hypervel\Database\Eloquent\Builder', Thread::valid()->whereNot->valid()); + assertType('Hypervel\Database\Eloquent\Builder', User::with(['accounts' => function ($relation) { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + + return $relation->where('active', true); + }])); + $user->with([ + 'accounts' => function ($relation) { + assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $relation); + }, + 'group' => function ($relation) { + assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $relation); + }, + ]); + assertType('Hypervel\Database\Eloquent\Builder', (new User)->withGlobalScope('test', function () { + })); + assertType('Hypervel\Database\Eloquent\Builder', (new User)->withoutGlobalScope('test')); + assertType('Hypervel\Database\Eloquent\Builder', User::onlyTrashed()); + assertType('Hypervel\Database\Eloquent\Builder', User::withTrashed()); + assertType('Hypervel\Database\Eloquent\Builder', User::withTrashed(false)); + assertType('App\User', User::onlyTrashed()->findOrFail(5)); + assertType('bool', $user->restore()); + assertType('App\User', User::restoreOrCreate(['id' => 1])); + assertType('App\User|null', User::firstWhere(['email' => 'foo@bar.com'])); + assertType('Hypervel\Database\Eloquent\Builder', $user->with('accounts')); + assertType('Hypervel\Database\Eloquent\Builder', $user->with('accounts')->with('group')); + assertType('Hypervel\Database\Eloquent\Builder', $user->with(['accounts' => function (Relation $relation) { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation->orderBy('id')); + }])); + assertType('Hypervel\Database\Eloquent\Builder', User::lockForUpdate()); + assertType('Hypervel\Database\Eloquent\Builder', User::sharedLock()); + assertType('Hypervel\Database\Eloquent\Builder', User::query()); + + assertType('Hypervel\Database\Eloquent\Collection', Thread::methodReturningCollectionOfAnotherModel()); + assertType('App\Thread|Hypervel\Database\Eloquent\Collection', Thread::methodReturningUnionWithCollection()); + assertType('App\User|Hypervel\Database\Eloquent\Collection', Thread::methodReturningUnionWithCollectionOfAnotherModel()); + assertType('mixed', $user->min('id')); + assertType('App\User', User::sole()); + + /** @var class-string $className */ + $className = User::class; + + assertType('App\User', new $className()); + assertType('App\User', $className::create()); + assertType('Hypervel\Database\Eloquent\Builder', (new $className())->newQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $className::query()); + assertType('Hypervel\Database\Eloquent\Builder', $className::query()->active()); + assertType('Hypervel\Database\Eloquent\Collection', $className::query()->active()->get()); + + assertType('string', Thread::valid()->toRawSql()); + // Unlike upstream, a passthru dumpRawSql() returns the raw query that toBase() gives it, whose rows are not models. + assertType('Hypervel\Database\Query\Builder', Thread::valid()->dumpRawSql()); + assertType('stdClass|null', Thread::valid()->dumpRawSql()->first()); + + User::has('accounts', '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::has('users', '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::doesntHave('accounts', 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::doesntHave('users', 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::where(function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::where(function (PostBuilder $query) { + assertType('App\PostBuilder', $query); + }); + + User::orWhere(function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::orWhere(function (PostBuilder $query) { + assertType('App\PostBuilder', $query); + }); + + User::whereHas('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::whereHas('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::withWhereHas('accounts', function (Builder|Relation $query) { + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany', $query); + }); + + Post::withWhereHas('users', function (Builder|Relation $query) { + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\BelongsToMany', $query); + }); + + User::orWhereHas('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::orWhereHas('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::hasMorph('commentable', [Account::class], '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::hasMorph('commentable', [User::class], '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::doesntHaveMorph('commentable', [Account::class], 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::doesntHaveMorph('commentable', [User::class], 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::whereHasMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::whereHasMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::orWhereHasMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::orWhereHasMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::whereDoesntHaveMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::whereDoesntHaveMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::orWhereDoesntHaveMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::orWhereDoesntHaveMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::whereDoesntHave('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::whereDoesntHave('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::orWhereDoesntHave('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::orWhereDoesntHave('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::firstWhere(function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::firstWhere(function (PostBuilder $query) { + assertType('App\PostBuilder', $query); + }); +} diff --git a/tests/PHPStan/Type/data/model-methods.php b/tests/PHPStan/Type/data/model-methods.php new file mode 100644 index 000000000..5257b826c --- /dev/null +++ b/tests/PHPStan/Type/data/model-methods.php @@ -0,0 +1,56 @@ +only('name', 'email')); + assertType('array{name: string, email: mixed}', $user->only(['name', 'email'])); + assertType('array{name: string, nonexistent: mixed}', $user->only(['name', 'nonexistent'])); + assertType('array', $user->only(['name', $foo])); + + $columns = ['name', 'email']; + $foo = 'nonexistent'; + assertType('array{name: string, email: mixed}', $user->only(...$columns)); + assertType('array{name: string, email: mixed}', $user->only($columns)); + assertType('array{nonexistent: mixed}', $user->only($foo)); + assertType( + 'array{name: string, email: mixed, nonexistent: mixed}', + $user->only([...$columns, $foo]), + ); +} + +/** + * @param list<'id'> $ids + * @param \Iterator $iterator + */ +function testOnlyAlternatives(User $user, OnlyOverride $override, bool $flag, array $ids, \Iterator $iterator): void +{ + assertType('array{name: string, id?: int}', $user->only('name', ...$iterator)); + assertType('array{id: int}|array{name: string}', $user->only($flag ? 'name' : 'id')); + assertType('array{id: int}|array{name: string}', $user->only($flag ? ['name'] : ['id'])); + assertType('array{name: string, id?: int, email?: mixed}', $user->only('name', $flag ? 'id' : 'email')); + assertType('array{name: string, id?: int}', $user->only('name', ...$ids)); + assertType('array', $user->only(...$ids)); + assertType('array{1: mixed}', $user->only('1')); + assertType('array', $override->only('name')); +} + +class OnlyOverride extends User +{ + /** + * Get a subset of the model's attributes. + * + * @return array + */ + public function only(mixed $attributes): array + { + return parent::only($attributes); + } +} diff --git a/tests/PHPStan/Type/data/model-properties-relations.php b/tests/PHPStan/Type/data/model-properties-relations.php new file mode 100644 index 000000000..997637d2f --- /dev/null +++ b/tests/PHPStan/Type/data/model-properties-relations.php @@ -0,0 +1,108 @@ + */ + public function hasManyRelation(): HasMany + { + return $this->hasMany(Bar::class); + } + + /** @return HasManyThrough */ + public function hasManyThroughRelation(): HasManyThrough + { + return $this->hasManyThrough(Bar::class, User::class); + } + + /** @return HasOneThrough */ + public function hasOneThroughRelation(): HasOneThrough + { + return $this->hasOneThrough(Baz::class, User::class); + } + + /** @return HasMany|BelongsTo */ + public function relationReturningUnion(): HasMany|BelongsTo + { + return $this->name === 'foo' ? $this->hasMany(Bar::class) : $this->belongsTo(Baz::class); + } + + /** @return HasMany|BelongsTo */ + public function relationReturningUnion2(): HasMany|BelongsTo + { + return $this->name === 'foo' ? $this->hasMany(Bar::class) : $this->belongsTo(Baz::class); + } + + /** @return Ancestors */ + public function ancestors(): Ancestors + { + // + } +} + +/** + * @property Foo $belongsToRelation + */ +class Bar extends Model +{ + /** @return BelongsTo */ + public function belongsToRelation(): BelongsTo + { + return $this->belongsTo(Foo::class); + } + + /** @return MorphTo */ + public function morphToRelation(): MorphTo + { + return $this->morphTo('foo'); + } + + /** @return MorphTo */ + public function morphToUnionRelation(): MorphTo + { + return $this->morphTo('foo'); + } +} + +class Baz extends Model +{ +} + +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * + * @extends HasMany + */ +class Ancestors extends HasMany +{ +} + +function test(Foo $foo, Bar $bar, Account $account): void +{ + assertType('Hypervel\Database\Eloquent\Collection', $foo->hasManyRelation); + assertType('Hypervel\Database\Eloquent\Collection', $foo->hasManyThroughRelation); + assertType('ModelPropertiesRelations\Baz|null', $foo->hasOneThroughRelation); + assertType('ModelPropertiesRelations\Foo', $bar->belongsToRelation); + assertType('Hypervel\Database\Eloquent\Model|null', $bar->morphToRelation); + assertType('App\Account|App\User|null', $bar->morphToUnionRelation); + assertType('ModelPropertiesRelations\Bar|null', $foo->hasManyRelation->first()); + assertType('ModelPropertiesRelations\Bar|null', $foo->hasManyRelation()->find(1)); + assertType('App\User|null', $account->ownerRelation); + assertType('Hypervel\Database\Eloquent\Collection|ModelPropertiesRelations\Baz|null', $foo->relationReturningUnion); + assertType('Hypervel\Database\Eloquent\Collection|ModelPropertiesRelations\Baz|null', $foo->relationReturningUnion2); + assertType('Hypervel\Database\Eloquent\Collection', $foo->ancestors); +} diff --git a/tests/PHPStan/Type/data/model-relations.php b/tests/PHPStan/Type/data/model-relations.php new file mode 100644 index 000000000..9e226ba5e --- /dev/null +++ b/tests/PHPStan/Type/data/model-relations.php @@ -0,0 +1,438 @@ +accounts()->firstOrCreate([])); + assertType('App\Account', $appUser->accounts()->createOrFirst([])); + // Unlike upstream, only models loaded through the relation's own query are typed with their pivot. + assertType('App\Post', $appUser->posts()->create()); + assertType('App\Account', $appUser->accounts()->create()); + assertType('App\Account', $appUser->syncableRelation()->create()); + assertType('int', $appUser->accounts()->increment('id')); + assertType('int', $appUser->accounts()->decrement('id')); + assertType('int', $appUser->accounts()->increment('id', 5)); + assertType('int', $appUser->accounts()->decrement('id', 5)); + // Unlike upstream, the paginator names its collection class. + assertType('Hypervel\Pagination\LengthAwarePaginator>', $appUser->accounts()->paginate(5)); + assertType( + 'Hypervel\Database\Eloquent\Relations\MorphTo', + $address->addressable()->where('name', 'bar') + ); + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $appUser->address()->where('name', 'bar')); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $appUser->accounts()->active()); + assertType('App\RoleCollection', $appUser->roles()->get()); + /** @var Group $group */ + $group = $appUser->group; + + $appUser->__children = $appUser->children; + + assertType('App\AccountCollection', $group->accounts()->where('active', 1)->get()); + assertType('App\Account', $appUser->accounts()->make()); + assertType('App\RoleCollection', $appUser->roles()->find([1])); + assertType('App\RoleCollection', $appUser->roles()->findMany([1, 2, 3])); + assertType('App\RoleCollection', $appUser->roles()->findOrNew([1])); + assertType('App\RoleCollection', $appUser->roles()->findOrFail([1])); + assertType('42|App\RoleCollection', $appUser->roles()->findOr([1], fn () => 42)); + assertType('42|App\RoleCollection', $appUser->roles()->findOr([1], callback: fn () => 42)); + assertType('App\Role', $appUser->roles()->findOrNew(1)); + assertType('App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $appUser->roles()->findOrFail(1)); + assertType('(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})|null', $appUser->roles()->find(1)); + // Unlike upstream, which leaves the scalar-ID findOr() assertions disabled. + assertType('42|(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $appUser->roles()->findOr(1, fn () => 42)); + assertType('42|(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $appUser->roles()->findOr(1, callback: fn () => 42)); + assertType('(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})|null', $appUser->roles()->first()); + assertType('42|(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $appUser->roles()->firstOr(fn () => 42)); + assertType('42|(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $appUser->roles()->firstOr(callback: fn () => 42)); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', (new User())->accounts()->where('name', 'bar')); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', (new User())->accounts()->whereIn('id', [1, 2, 3])); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', (new User())->accounts()->whereActive(true)); + assertType('App\Account', $appUser->accounts()->create()); + assertType('App\Account|null', (new User())->accounts()->where('name', 'bar')->first()); + assertType('App\User', User::with('accounts')->whereHas('accounts')->firstOrFail()); + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $account->ownerRelation()); + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $account->parent()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $appUser->children()); + assertType('Hypervel\Database\Eloquent\Collection', $appUser->__children); + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $appUser->parent()); + assertType('App\Account|null', $appUser->accounts()->firstWhere('name', 'bar')); + assertType('App\Group|null', $appUser->group()->firstWhere('name', 'bar')); + assertType('App\Account|null', $appUser->accounts->first()); + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $appUser->group()->withTrashed()); + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $appUser->group()->onlyTrashed()); + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $appUser->group()->withoutTrashed()); + assertType('Hypervel\Database\Eloquent\Relations\HasManyThrough', $appUser->transactions()); + assertType('Hypervel\Database\Eloquent\Builder', User::with([ + 'accounts' => function (HasMany $query) { + return $query->where('foo', 'bar'); + }, + ])); + assertType('Hypervel\Database\Eloquent\Builder', User::with([ + 'group' => [ + 'accounts', + ], + ])); + assertType('App\User|null', $model->nullableUser); + assertType('App\User', $model->nonNullableUser); + assertType('string|null', $model->nullableFoo); + assertType('string', $model->nonNullableFoo); + + // Relationship counts + assertType('int<0, max>', $appUser->group_count); + assertType('int<0, max>', $appUser->accounts_count); + assertType('int<0, max>', $appUser->accounts_snake_count); + assertType('int<0, max>', $appUser->accounts_camel_count); + assertType('int<0, max>', $appUser->accountsCamel_count); + assertType('int<0, max>', $appUser->syncable_relation_count); + + $users = (new Post())->users(); + assertType('Hypervel\Database\Eloquent\Collection', $users->getEager()); + assertType('Hypervel\Database\Eloquent\Collection', $users->get()); + assertType('Hypervel\Database\Eloquent\Builder', $users->getQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $users->getRelationExistenceCountQuery(User::query(), Post::query())); + assertType('App\PostBuilder', $appUser->posts()->getRelationExistenceCountQuery(Post::query(), User::query())); + assertType('App\User', $users->make()); + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\BelongsTo', $union->group()); + assertType("Hypervel\Database\Eloquent\Relations\BelongsToMany|Hypervel\Database\Eloquent\Relations\BelongsToMany", $union->posts()); + + assertType('App\Account', $appUser->accounts()->sole()); + assertType('App\Group', $appUser->group()->sole()); + assertType('App\Post', $appUser->posts()->sole()); + + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $user->address()); + assertType('Hypervel\Database\Eloquent\Collection', $user->address()->getResults()); + assertType('Hypervel\Database\Eloquent\Collection', $user->address); + assertType('Hypervel\Database\Eloquent\Collection', $user->address()->get()); + assertType('App\Address', $user->address()->make()); + assertType('App\Address', $user->address()->create()); + assertType('Hypervel\Database\Eloquent\Relations\HasOne', $child->oneAddress()); + assertType('App\Address', $child->oneAddress()->make()); + assertType('App\Address', $child->oneAddress()->create([])); + assertType('App\Address', $child->oneAddress()->getRelated()); + assertType('App\ChildUser', $child->oneAddress()->getParent()); + assertType('Hypervel\Database\Eloquent\Relations\HasOne', $user->oneAddress()); + assertType('Hypervel\Database\Eloquent\Relations\HasOne', $user->oneAddress()->where('zip')); + assertType('Hypervel\Database\Eloquent\Relations\HasOne', $user->oneAddress()->orderBy('zip')); + + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->accountsCamel()); + assertType('App\AccountCollection', $user->accountsCamel()->getResults()); + assertType('App\AccountCollection', $user->accountsCamel); + assertType('App\AccountCollection', $user->accountsCamel()->makeMany([])); + assertType('App\AccountCollection', $user->accountsCamel()->createMany([])); + assertType('App\AccountCollection', $user->accountsCamel()->createManyQuietly([])); + assertType('App\Account', $user->accountsCamel()->make()); + assertType('App\Account', $user->accountsCamel()->create()); + assertType('App\Account|false', $user->accountsCamel()->save(new Account())); + assertType('App\Account|false', $user->accountsCamel()->saveQuietly(new Account())); + + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $user->roles()); + assertType('App\RoleCollection', $user->roles()->getResults()); + assertType('App\RoleCollection', $user->roles); + assertType('App\RoleCollection', $user->roles()->find([1])); + assertType('App\RoleCollection', $user->roles()->findMany([1, 2, 3])); + assertType('App\RoleCollection', $user->roles()->findOrNew([1])); + assertType('App\RoleCollection', $user->roles()->findOrFail([1])); + assertType('42|App\RoleCollection', $user->roles()->findOr([1], fn () => 42)); + assertType('42|App\RoleCollection', $user->roles()->findOr([1], callback: fn () => 42)); + assertType('App\Role', $user->roles()->findOrNew(1)); + assertType('App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->findOrFail(1)); + assertType('(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})|null', $user->roles()->find(1)); + assertType('42|(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $user->roles()->findOr(1, fn () => 42)); + assertType('42|(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $user->roles()->findOr(1, callback: fn () => 42)); + assertType('(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})|null', $user->roles()->first()); + assertType('42|(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $user->roles()->firstOr(fn () => 42)); + assertType('42|(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $user->roles()->firstOr(callback: fn () => 42)); + assertType('(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})|null', $user->roles()->firstWhere('foo')); + assertType('App\Role', $user->roles()->firstOrNew()); + assertType('App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->firstOrFail()); + assertType('App\Role', $user->roles()->firstOrCreate()); + assertType('App\Role', $user->roles()->create()); + assertType('App\Role', $user->roles()->createOrFirst()); + assertType('App\Role', $user->roles()->updateOrCreate([])); + assertType('App\Role', $user->roles()->save(new Role())); + assertType('App\Role', $user->roles()->saveQuietly(new Role())); + $roles = $user->roles()->getResults(); + assertType('App\RoleCollection', $user->roles()->saveMany($roles)); + assertType('array', $user->roles()->saveMany($roles->all())); + assertType('App\RoleCollection', $user->roles()->saveManyQuietly($roles)); + assertType('array', $user->roles()->saveManyQuietly($roles->all())); + assertType('array', $user->roles()->createMany($roles)); + assertType('array{attached: array, detached: array, updated: array}', $user->roles()->sync($roles)); + assertType('array{attached: array, detached: array, updated: array}', $user->roles()->syncWithoutDetaching($roles)); + assertType('array{attached: array, detached: array, updated: array}', $user->roles()->syncWithPivotValues($roles, [])); + assertType('Hypervel\Support\LazyCollection', $user->roles()->lazy()); + assertType('Hypervel\Support\LazyCollection', $user->roles()->lazyById()); + assertType('Hypervel\Support\LazyCollection', $user->roles()->cursor()); + + assertType('Hypervel\Database\Eloquent\Relations\HasOneThrough', $user->car()); + assertType('App\Car|null', $user->car()->getResults()); + assertType('App\Car|null', $user->car); + assertType('Hypervel\Database\Eloquent\Collection', $user->car()->find([1])); + assertType('42|Hypervel\Database\Eloquent\Collection', $user->car()->findOr([1], fn () => 42)); + assertType('42|Hypervel\Database\Eloquent\Collection', $user->car()->findOr([1], callback: fn () => 42)); + assertType('App\Car|null', $user->car()->find(1)); + assertType('42|App\Car', $user->car()->findOr(1, fn () => 42)); + assertType('42|App\Car', $user->car()->findOr(1, callback: fn () => 42)); + assertType('App\Car|null', $user->car()->first()); + assertType('42|App\Car', $user->car()->firstOr(fn () => 42)); + assertType('42|App\Car', $user->car()->firstOr(callback: fn () => 42)); + assertType('Hypervel\Support\LazyCollection', $user->car()->lazy()); + assertType('Hypervel\Support\LazyCollection', $user->car()->lazyById()); + assertType('Hypervel\Support\LazyCollection', $user->car()->cursor()); + // Unlike upstream, through() with a relation name also names the local relationship. + assertType('Hypervel\Database\Eloquent\PendingHasThroughRelationship>', $user->through('mechanic')); + + assertType('Hypervel\Database\Eloquent\Relations\HasManyThrough', $user->parts()); + assertType('Hypervel\Database\Eloquent\Collection', $user->parts()->getResults()); + assertType('Hypervel\Database\Eloquent\Collection', $user->parts); + assertType('Hypervel\Database\Eloquent\Relations\HasOneThrough', $user->firstPart()); + + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $post->user()); + assertType('App\User|null', $post->user()->getResults()); + assertType('App\User|null', $post->user); + assertType('App\User', $post->user()->make()); + assertType('App\User', $post->user()->create()); + assertType('App\Post', $post->user()->associate(new User())); + assertType('App\Post', $post->user()->dissociate()); + assertType('App\Post', $post->user()->disassociate()); + assertType('App\Post', $post->user()->getChild()); + + assertType('Hypervel\Database\Eloquent\Relations\MorphOne', $post->image()); + assertType('App\Image|null', $post->image()->getResults()); + assertType('App\Image|null', $post->image); + assertType('App\Image', $post->image()->forceCreate([])); + + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $post->comments()); + assertType('Hypervel\Database\Eloquent\Collection', $post->comments()->getResults()); + assertType('Hypervel\Database\Eloquent\Collection', $post->comments); + assertType('Hypervel\Database\Eloquent\Relations\MorphOne', $post->latestComment()); + + assertType('Hypervel\Database\Eloquent\Relations\MorphTo', $comment->commentable()); + assertType('Hypervel\Database\Eloquent\Model|null', $comment->commentable()->getResults()); + assertType('Hypervel\Database\Eloquent\Model|null', $comment->commentable); + assertType('Hypervel\Database\Eloquent\Collection', $comment->commentable()->getEager()); + assertType('Hypervel\Database\Eloquent\Model', $comment->commentable()->createModelByType('foo')); + assertType('App\Comment', $comment->commentable()->associate(new Post())); + assertType('App\Comment', $comment->commentable()->dissociate()); + + assertType('Hypervel\Database\Eloquent\Relations\MorphToMany', $post->tags()); + assertType('Hypervel\Database\Eloquent\Collection', $post->tags()->getResults()); + assertType('Hypervel\Database\Eloquent\Collection', $post->tags); + + $user->roles()->where(function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + assertType( + 'Hypervel\Database\Eloquent\Relations\HasManyThrough', + $user->through($user->mechanic())->has(fn ($mechanic) => $mechanic->parts()), + ); + + assertType( + 'App\AccountCollection', + $user->accountsCamel()->createMany([ + ['name' => 'foo'], + ['name' => 'bar'], + ]), + ); +} + +/** + * @property-read User $relation + */ +class RelationCreateExample extends Model +{ + /** @return HasMany */ + public function relation(): HasMany + { + return $this->hasMany(User::class); + } + + public function addRelation(): User + { + return $this->relation()->create([]); + } +} + +class ModelWithoutPropertyAnnotation extends Model +{ + /** @return HasMany */ + public function relation(): HasMany + { + return $this->hasMany(User::class); + } +} + +/** + * @property-read User|null $nullableUser + * @property-read User $nonNullableUser + * @property-read string|null $nullableFoo + * @property-read string $nonNullableFoo + */ +class ModelWithPropertyAnnotations extends Model +{ + /** @return HasOne */ + public function nullableUser(): HasOne + { + return $this->hasOne(User::class); + } + + /** @return HasOne */ + public function nonNullableUser(): HasOne + { + return $this->hasOne(User::class); + } + + public function getNullableFooAttribute(): ?string + { + return rand() ? 'foo' : null; + } + + public function getNonNullableFooAttribute(): string + { + return 'foo'; + } +} + +class ExtendsModelWithPropertyAnnotations extends ModelWithPropertyAnnotations +{ +} + +/** @extends HasMany */ +class CustomBuilderHasMany extends HasMany +{ + public function testBuilder(): void + { + assertType('App\PostBuilder', $this->getRelationQuery()); + assertType('App\PostBuilder', $this->getQuery()); + assertType('Hypervel\Database\Query\Builder', $this->getBaseQuery()); + } +} + +/** @extends \Hypervel\Database\Eloquent\Relations\BelongsToMany<\App\Team, User> */ +class CustomBuilderBelongsToMany extends \Hypervel\Database\Eloquent\Relations\BelongsToMany +{ + public function testBuilder(): void + { + assertType('App\ChildTeamBuilder', $this->prepareQueryBuilder()); + assertType('App\ChildTeamBuilder', $this->getRelationQuery()); + assertType('App\ChildTeamBuilder', $this->getRelationExistenceCountQuery(\App\Team::query(), User::query())); + + $this->firstWhere(function ($query) { + assertType('App\ChildTeamBuilder', $query); + }); + } +} + +/** @extends \Hypervel\Database\Eloquent\Relations\HasManyThrough */ +class CustomBuilderHasManyThrough extends \Hypervel\Database\Eloquent\Relations\HasManyThrough +{ + public function testBuilder(): void + { + assertType('App\PostBuilder', $this->prepareQueryBuilder()); + assertType('App\PostBuilder', $this->getRelationQuery()); + } +} + +function firstWhereCallbacks(User $user, string|null $operator): void +{ + assertType('(App\Post&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})|null', $user->posts()->firstWhere(function ($query) { + assertType('App\PostBuilder', $query); + })); + + $user->roles()->firstWhere(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + $user->posts()->firstWhere(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2); + + $user->posts()->firstWhere(value: 2, column: function ($query) { + assertType('App\PostBuilder', $query); + }, operator: null); + + $user->posts()->firstWhere(function ($query) { + assertType('App\PostBuilder|Hypervel\Database\Query\Builder', $query); + }, $operator, 2); +} + +function chunkCallbacks(User $user): void +{ + $user->accounts()->chunk(10, fn ($accounts, $page) => assertType('App\AccountCollection', $accounts)); + $user->accounts()->each(fn ($account, $key) => assertType('App\Account', $account)); + + $user->roles()->chunk(10, function ($roles, $page) { + assertType('App\RoleCollection', $roles); + assertType('int', $page); + }); + $user->roles()->chunkById(10, function ($roles, $page) { + assertType('App\RoleCollection', $roles); + assertType('int', $page); + }); + $user->roles()->chunkByIdDesc(10, function ($roles, $page) { + assertType('App\RoleCollection', $roles); + assertType('int', $page); + }); + $user->roles()->each(function ($role, $key) { + assertType('App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $role); + assertType('int', $key); + }); + $user->roles()->eachById(function ($role, $key) { + assertType('App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $role); + assertType('int', $key); + }); + $user->posts()->where('id', '>', 1)->chunk(10, function ($posts) { + assertType('Hypervel\Database\Eloquent\Collection', $posts); + }); + + $user->parts()->chunk(10, function ($parts, $page) { + assertType('Hypervel\Database\Eloquent\Collection', $parts); + assertType('int', $page); + }); + $user->parts()->chunkById(10, function ($parts, $page) { + assertType('Hypervel\Database\Eloquent\Collection', $parts); + assertType('int', $page); + }); + $user->parts()->chunkByIdDesc(10, function ($parts, $page) { + assertType('Hypervel\Database\Eloquent\Collection', $parts); + assertType('int', $page); + }); + $user->parts()->each(function ($part, $key) { + assertType('App\Part', $part); + assertType('int', $key); + }); + $user->parts()->eachById(function ($part, $key) { + assertType('App\Part', $part); + assertType('int', $key); + }); + $user->car()->chunk(10, function ($cars) { + assertType('Hypervel\Database\Eloquent\Collection', $cars); + }); +} diff --git a/tests/PHPStan/Type/data/model-template-class-string.php b/tests/PHPStan/Type/data/model-template-class-string.php new file mode 100644 index 000000000..6fe3522bc --- /dev/null +++ b/tests/PHPStan/Type/data/model-template-class-string.php @@ -0,0 +1,43 @@ + $modelClass + * @param class-string $postClass + * @param TModel $model + */ +function test(string $modelClass, string $postClass, Model $model): void +{ + assertType('TModel of Hypervel\Database\Eloquent\Model (function ModelTemplateClassString\test(), argument)', $modelClass::findOrFail(1)); + assertType('TModel of Hypervel\Database\Eloquent\Model (function ModelTemplateClassString\test(), argument)|null', $modelClass::find(1)); + assertType('Hypervel\Database\Eloquent\Collection', $modelClass::find([1, 2])); + assertType('TModel of Hypervel\Database\Eloquent\Model (function ModelTemplateClassString\test(), argument)', $modelClass::create([])); + assertType('TModel of Hypervel\Database\Eloquent\Model (function ModelTemplateClassString\test(), argument)|null', $modelClass::where('id', 1)->first()); + assertType('Hypervel\Database\Eloquent\Collection', $modelClass::whereKey(1)->get()); + assertType('int<0, max>', $modelClass::count()); + assertType('TModel of Hypervel\Database\Eloquent\Model (function ModelTemplateClassString\test(), argument)', $model::findOrFail(1)); + assertType('TPost of App\Post (function ModelTemplateClassString\test(), argument)', $postClass::findOrFail(1)); + assertType('App\PostBuilder', $postClass::where('id', 1)); +} + +/** + * @template TNeverQuery of ModelWithNeverQuery + * @param class-string $neverQueryClass + * @param class-string|class-string $postOrUserClass + */ +function testReceivers(string $neverQueryClass, string $postOrUserClass): void +{ + assertType('TNeverQuery of BuilderOfType\ModelWithNeverQuery (function ModelTemplateClassString\testReceivers(), argument)', $neverQueryClass::findOrFail(1)); + assertType('Hypervel\Database\Eloquent\Collection|Hypervel\Database\Eloquent\Collection', $postOrUserClass::get()); +} + diff --git a/tests/PHPStan/Type/data/model-with-relations.php b/tests/PHPStan/Type/data/model-with-relations.php new file mode 100644 index 000000000..aa1efb328 --- /dev/null +++ b/tests/PHPStan/Type/data/model-with-relations.php @@ -0,0 +1,70 @@ +', User::with('accounts')); + assertType('Hypervel\Database\Eloquent\Builder', User::with(['accounts', 'group'])); + assertType('Hypervel\Database\Eloquent\Builder', User::with([])); + + assertType('Hypervel\Database\Eloquent\Builder', User::with(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation->where('active', true)->orderBy('id')); + }])); + + $user->with(relations: ['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }]); + + User::with([ + 'accounts' => function ($relation): void { + assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $relation); + }, + 'group' => function ($relation): void { + assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $relation); + }, + ]); + + User::with(['accounts' => fn ($relation) => assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation)]); + User::with(['posts.comments' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); + }]); + + User::with([$name => function ($relation): void { + assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $relation); + }]); + + User::with([$dynamic => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + $relation->where('active', true); + }]); + + User::with(['accounts', 'group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }]); + + User::with(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }, $dynamic => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }]); +} + +class ChildUser extends User +{ + public function insideClass(): void + { + static::with(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }]); + $this->with(['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }]); + } +} diff --git a/tests/PHPStan/Type/data/paginator-extension.php b/tests/PHPStan/Type/data/paginator-extension.php new file mode 100644 index 000000000..41795bac8 --- /dev/null +++ b/tests/PHPStan/Type/data/paginator-extension.php @@ -0,0 +1,176 @@ +>', User::paginate()); + assertType('array', User::paginate()->all()); + assertType('array', User::paginate()->items()); + assertType('App\User|null', User::paginate()[0]); + + assertType('Hypervel\Pagination\Paginator>', User::simplePaginate()); + assertType('array', User::simplePaginate()->all()); + assertType('array', User::simplePaginate()->items()); + assertType('App\User|null', User::simplePaginate()[0]); + + assertType('Hypervel\Pagination\CursorPaginator>', User::cursorPaginate()); + assertType('array', User::cursorPaginate()->all()); + assertType('array', User::cursorPaginate()->items()); + assertType('App\User|null', User::cursorPaginate()[0]); + + assertType('ArrayIterator', User::query()->paginate()->getIterator()); + + // HasMany + assertType('Hypervel\Pagination\LengthAwarePaginator>', (new User())->accounts()->paginate()); + + // BelongsToMany + assertType('Hypervel\Pagination\LengthAwarePaginator>', (new User())->posts()->paginate()); +} + +function paginateCollections(): void +{ + assertType('Hypervel\Database\Eloquent\Collection', User::query()->paginate()->getCollection()); + assertType('App\AccountCollection', Account::query()->paginate()->getCollection()); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->paginate()->getCollection()); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->paginate()->getCollection()); + assertType('Hypervel\Support\Collection', (new LengthAwarePaginator([new \stdClass()], 1, 15))->getCollection()); + + assertType('array', User::query()->paginate()->getCollection()->all()); + + $paginator = User::query()->paginate(); + $paginator->setCollection(new AccountCollection(['account' => new Account()])); + assertType('App\AccountCollection', $paginator->getCollection()); + assertType('Hypervel\Pagination\LengthAwarePaginator>', $paginator); + assertType('App\AccountCollection', $paginator->getCollection()->filterByActive()); + + // Unlike upstream, the value keeps its literal type: Hypervel keeps Laravel's covariant TValue, + // which PHPStan does not generalize. + $paginator->setCollection(new Collection(['name' => 'Taylor'])); + assertType("Hypervel\Support\Collection", $paginator->getCollection()); + + // through() replaces the values in place, so the stored collection is only known to be a + // collection of the new values. + $accounts = Account::query()->paginate(); + $accounts->through(static fn (Account $account): array => ['account' => $account]); + assertType('Hypervel\Pagination\LengthAwarePaginator>', $accounts); + assertType('Hypervel\Support\Collection', $accounts->getCollection()); +} + +function simplePaginateCollections(): void +{ + assertType('Hypervel\Database\Eloquent\Collection', User::query()->simplePaginate()->getCollection()); + assertType('App\AccountCollection', Account::query()->simplePaginate()->getCollection()); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->simplePaginate()->getCollection()); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->simplePaginate()->getCollection()); + assertType('Hypervel\Support\Collection', (new Paginator([new \stdClass()], 15))->getCollection()); + + $paginator = User::query()->simplePaginate(); + $paginator->setCollection(new AccountCollection(['account' => new Account()])); + assertType('App\AccountCollection', $paginator->getCollection()); + assertType('Hypervel\Pagination\Paginator>', $paginator); + assertType('App\AccountCollection', $paginator->getCollection()->filterByActive()); + + // Unlike upstream, the value keeps its literal type: Hypervel keeps Laravel's covariant TValue, + // which PHPStan does not generalize. + $paginator->setCollection(new Collection(['name' => 'Taylor'])); + assertType("Hypervel\Support\Collection", $paginator->getCollection()); + + $accounts = Account::query()->simplePaginate(); + $accounts->through(static fn (Account $account): array => ['account' => $account]); + assertType('Hypervel\Pagination\Paginator>', $accounts); + assertType('Hypervel\Support\Collection', $accounts->getCollection()); +} + +function cursorPaginateCollections(): void +{ + assertType('Hypervel\Database\Eloquent\Collection', User::query()->cursorPaginate()->getCollection()); + assertType('App\AccountCollection', Account::query()->cursorPaginate()->getCollection()); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->cursorPaginate()->getCollection()); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->cursorPaginate()->getCollection()); + assertType('Hypervel\Support\Collection', (new CursorPaginator([new \stdClass()], 15))->getCollection()); + + $paginator = User::query()->cursorPaginate(); + $paginator->setCollection(new AccountCollection(['account' => new Account()])); + assertType('App\AccountCollection', $paginator->getCollection()); + assertType('Hypervel\Pagination\CursorPaginator>', $paginator); + assertType('App\AccountCollection', $paginator->getCollection()->filterByActive()); + + // Unlike upstream, the value keeps its literal type: Hypervel keeps Laravel's covariant TValue, + // which PHPStan does not generalize. + $paginator->setCollection(new Collection(['name' => 'Taylor'])); + assertType("Hypervel\Support\Collection", $paginator->getCollection()); + + $accounts = Account::query()->cursorPaginate(); + $accounts->through(static fn (Account $account): array => ['account' => $account]); + assertType('Hypervel\Pagination\CursorPaginator>', $accounts); + assertType('Hypervel\Support\Collection', $accounts->getCollection()); +} + +/** + * @param LengthAwarePaginator $arrays + * @param Paginator $integers + * @param CursorPaginator $strings + * @param LengthAwarePaginator $nullable + * @param CursorPaginator $union + * @param Paginator $intersection + */ +function collectionValueTypes(LengthAwarePaginator $arrays, Paginator $integers, CursorPaginator $strings, LengthAwarePaginator $nullable, CursorPaginator $union, Paginator $intersection): void +{ + assertType('Hypervel\Support\Collection', $arrays->getCollection()); + assertType('Hypervel\Support\Collection', $integers->getCollection()); + assertType('Hypervel\Support\Collection', $strings->getCollection()); + assertType('App\Account|null', $nullable->getCollection()->first()); + + // Unlike upstream, model values do not imply the model's collection: a paginator annotated + // with two arguments can be built from any collection, so it keeps the base collection. + assertType('Hypervel\Support\Collection', $union->getCollection()); + assertType('Hypervel\Support\Collection', $intersection->getCollection()); +} + +/** + * @extends CursorPaginator + */ +class AccountCursorPaginator extends CursorPaginator +{ +} + +/** + * @param array $accounts + */ +function cursorPaginatorKeys(array $accounts): void +{ + // Unlike upstream, the keys are integers whatever the input's keys: setItems() renumbers them. + $paginator = new CursorPaginator($accounts, 15); + assertType('Hypervel\Pagination\CursorPaginator>', $paginator); + + $paginator->setCollection(new Collection(['first' => new Account()])); + assertType('Hypervel\Pagination\CursorPaginator>', $paginator); + + assertType('PaginatorExtension\AccountCursorPaginator', new AccountCursorPaginator($accounts, 15)); + // Without items to infer the value type from, the declaration's bounds apply. + assertType('Hypervel\Pagination\CursorPaginator<(int|string), mixed, Hypervel\Support\Collection<(int|string), mixed>>', new CursorPaginator(null, 15)); +} + +function perPageCallback(): void +{ + assertType('Hypervel\Pagination\LengthAwarePaginator>', User::query()->paginate(static function ($total) { + assertType('int', $total); + + return 15; + })); +} diff --git a/tests/PHPStan/Type/data/passthru-l12-15.php b/tests/PHPStan/Type/data/passthru-l12-15.php new file mode 100644 index 000000000..42d3a55fe --- /dev/null +++ b/tests/PHPStan/Type/data/passthru-l12-15.php @@ -0,0 +1,21 @@ +', User::query()->getCountForPagination()); + assertType('mixed', User::query()->aggregate('sum', ['id'])); + assertType('mixed', User::query()->doesntExistOr(fn () => 'x')); + assertType('mixed', User::query()->existsOr(fn () => 'x')); + assertType('Hypervel\Support\Collection', User::query()->explain()); + // Unlike upstream, the bindings are keyed by the builder's TBindingType, which custom builders can extend. + assertType("array<'from'|'groupBy'|'having'|'join'|'order'|'select'|'union'|'unionOrder'|'where', list>", User::query()->getRawBindings()); + assertType('string', User::query()->implode('name')); + assertType('int', User::query()->insertOrIgnoreUsing(['name'], 'select 1')); + assertType('float|int', User::query()->numericAggregate('sum', ['id'])); + assertType('mixed', User::query()->rawValue('max(id)')); +} diff --git a/tests/PHPStan/Type/data/query-builder.php b/tests/PHPStan/Type/data/query-builder.php new file mode 100644 index 000000000..eb452d6c7 --- /dev/null +++ b/tests/PHPStan/Type/data/query-builder.php @@ -0,0 +1,61 @@ +pluck('email', 'id'); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $record); + assertType('Hypervel\Support\LazyCollection', DB::table('user')->cursor()); + + $subQuery = DB::table('addresses') + ->select(['id', 'user_id']); + + $builder = DB::table('users') + ->joinSub($subQuery, 'addresses', 'users.user_id', '=', DB::raw('addresses.user_id')); + + assertType('Hypervel\Database\Query\Builder', $builder); + + $builder = DB::table('users') + ->where(DB::raw('id'), '=', 1); + + assertType('Hypervel\Database\Query\Builder', $builder); + + $builder = DB::table('users') + ->where(DB::raw('id'), '=', 1) + ->orWhere(DB::raw('id'), '=', 2); + + assertType('Hypervel\Database\Query\Builder', $builder); + + $builder = DB::table('users') + ->whereNull(DB::raw('email_verified_at')); + + assertType('Hypervel\Database\Query\Builder', $builder); + + $builder = DB::table('users') + ->whereNotNull(DB::raw('email_verified_at')); + + assertType('Hypervel\Database\Query\Builder', $builder); + + $builder = DB::table('users') + ->whereBetween('id', new \ArrayObject([1, 2])); + + assertType('Hypervel\Database\Query\Builder', $builder); + + // Unlike upstream, the query builder's TValue types a found row. + assertType('stdClass|null', DB::table('users')->find(1, [DB::raw('email_verified_at')])); + assertType('mixed', DB::table('users')->aggregate('sum', [DB::raw('id')])); + assertType('float|int', DB::table('users')->numericAggregate('sum', [DB::raw('id')])); + // Unlike upstream, these are not numeric strings: PostgreSQL returns interval and money results as text. + assertType('float|int|string', DB::table('users')->sum(DB::raw('id'))); + assertType('float|int|string|null', DB::table('users')->avg(DB::raw('id'))); + assertType('float|int|string|null', DB::table('users')->average(DB::raw('id'))); + // Unlike upstream, the bindings are keyed by the builder's TBindingType, which custom builders can extend. + assertType("array<'from'|'groupBy'|'having'|'join'|'order'|'select'|'union'|'unionOrder'|'where', list>", DB::table('users')->getRawBindings()); +} diff --git a/tests/PHPStan/Type/data/relationship-query-callbacks.php b/tests/PHPStan/Type/data/relationship-query-callbacks.php new file mode 100644 index 000000000..205d93b18 --- /dev/null +++ b/tests/PHPStan/Type/data/relationship-query-callbacks.php @@ -0,0 +1,290 @@ +whereHas('posts', function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + User::query()->whereHas('posts.comments', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Owner::query()->whereHas('items', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Owner::query()->whereHas('items.category', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + + $query->whereHas('labels', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + }); + + User::query()->whereHas($relation, function (Builder $query) { + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->whereHas($unknown, function (Builder $query) { + // Unlike upstream, which uses the declaring model, an unknown relation queries an unknown model. + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->whereHas($user->posts(), function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + $builder = User::query(); + assertType('Hypervel\Database\Eloquent\Builder', $builder->has('accounts')); + assertType('Hypervel\Database\Eloquent\Builder', $builder->doesntHave('accounts', callback: null)); + assertType('Hypervel\Database\Eloquent\Builder', $builder->whereDoesntHave('accounts')); + + $builder->orWhereHas(relation: 'posts.comments', count: 2, callback: function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + $builder->whereDoesntHave($user->accounts(), function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); +} + +/** + * @param class-string $postClass + * @param list> $classes + * @param list $unknownTypes + */ +function morphRelationships(string $postClass, array $classes, string $unknown, array $unknownTypes, Comment $comment): void +{ + Comment::query()->whereHasMorph('commentable', Post::class, function (Builder $query, string $type) { + assertType('App\PostBuilder', $query); + assertType('string', $type); + }); + + Comment::query()->whereHasMorph('commentable', [Post::class, Account::class], function (Builder $query) { + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', $postClass, function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + Comment::query()->whereHasMorph('commentable', $classes, function (Builder $query) { + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', '*', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', ['*'], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', [], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', 'post', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', $unknown, function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', $unknownTypes, function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', [Post::class, 'account'], function (Builder $query) { + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph($comment->commentable(), Post::class, function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + Comment::query()->whereHasMorph($comment->commentable(), '*', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); +} + +/** @param list $unknownTypes */ +function declaredMorphTarget(string $unknown, array $unknownTypes, PostComment $comment): void +{ + PostComment::query()->whereHasMorph('commentable', '*', function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + PostComment::query()->whereHasMorph('commentable', [], function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + PostComment::query()->whereHasMorph('commentable', 'post', function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + PostComment::query()->whereHasMorph('commentable', $unknown, function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + PostComment::query()->whereHasMorph('commentable', $unknownTypes, function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + PostComment::query()->whereHasMorph($comment->commentable(), '*', function (Builder $query) { + assertType('App\PostBuilder', $query); + }); +} + +function morphCallForms(PostComment $comment): void +{ + PostComment::whereHasMorph(types: '*', callback: function (Builder $query, $type) { + assertType('App\PostBuilder', $query); + assertType('string', $type); + }, relation: 'commentable'); + + $comment->whereHasMorph('commentable', [Account::class, 'post'], function (Builder $query) { + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Builder', $query); + }); + + PostComment::query()->hasMorph(callback: function (Builder $query) { + assertType('App\PostBuilder', $query); + }, types: Post::class, relation: 'commentable'); + + PostComment::query()->whereHasMorph($comment->commentable(), Account::class, function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + assertType('Hypervel\Database\Eloquent\Builder', PostComment::hasMorph('commentable', Post::class)); + assertType('Hypervel\Database\Eloquent\Builder', PostComment::query()->doesntHaveMorph('commentable', '*', callback: null)); + assertType('Hypervel\Database\Eloquent\Builder', PostComment::whereDoesntHaveMorph('commentable', [])); +} + +/** @param 'accounts'|'group' $relation */ +function eagerRelationships(User $user, string $relation, string $unknown): void +{ + User::query()->withWhereHas('posts', function (Builder|Relation $query) { + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Relations\BelongsToMany', $query); + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Relations\BelongsToMany', $query->where('active', true)); + }); + + User::query()->withWhereHas('posts.comments', function (BuilderContract $query) { + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\MorphMany', $query); + }); + + User::withWhereHas('accounts', function (Builder|Relation $query) { + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany', $query); + }); + + $user->withWhereHas(callback: function ($query) { + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\BelongsTo', $query); + }, relation: 'group'); + + User::withWhereHas($relation, function (Builder|Relation $query) { + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany', $query); + }); + + User::withWhereHas($unknown, function (Builder|Relation $query) { + assertType('Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $query); + }); + + assertType('Hypervel\Database\Eloquent\Builder', User::withWhereHas('accounts')); + assertType('App\PostBuilder', Post::query()->withWhereHas('comments', null)); +} + +/** @param 'posts'|'accounts' $relation */ +function relationShortcuts(User $user, string $relation): void +{ + User::whereRelation('posts', function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + User::query()->orWhereRelation('posts.comments', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + $user->whereDoesntHaveRelation($user->posts(), function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + User::query()->orWhereDoesntHaveRelation(column: function ($query) { + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Builder', $query); + }, relation: $relation); + + User::withWhereRelation('posts', function (Builder|Relation $query) { + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Relations\BelongsToMany', $query); + }); + + User::query()->withWhereRelation(relation: 'posts.comments', column: function ($query) { + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\MorphMany', $query); + }); +} + +function morphRelationShortcuts(PostComment $comment): void +{ + Comment::whereMorphRelation('commentable', Post::class, function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + Comment::query()->orWhereMorphRelation('commentable', [Post::class, Account::class], function (Builder $query) { + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Builder', $query); + }); + + $comment->whereMorphDoesntHaveRelation($comment->commentable(), '*', function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + PostComment::query()->orWhereMorphDoesntHaveRelation(column: function ($query) { + assertType('App\PostBuilder', $query); + }, types: [], relation: 'commentable'); +} + +function directEagerCallbacks(string $unknown): void +{ + User::query()->with('accounts', function (Relation $query) { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $query); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $query->orderBy('id')); + }); + + User::query()->with('posts.comments', function (Relation $query) { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $query); + }); + + User::query()->with(callback: function ($query) { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $query); + }, relations: 'group'); + + User::query()->with($unknown, function (Relation $query) { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $query); + }); +} + +/** @param array{relation: 'commentable'} $relation */ +function hypervelCallForms(Post $post, array $relation): void +{ + $post->comments()->whereHasMorph('commentable', Post::class, function (Builder $query) { + assertType('App\PostBuilder', $query); + }); + + // An unpacked argument hides the relation, so the declared callback type applies. + Comment::query()->whereHasMorph(...$relation, types: '*', callback: function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); +} diff --git a/tests/PHPStan/Type/data/where-relation.php b/tests/PHPStan/Type/data/where-relation.php new file mode 100644 index 000000000..112ab3b03 --- /dev/null +++ b/tests/PHPStan/Type/data/where-relation.php @@ -0,0 +1,23 @@ +', $query); + + $query = $query->whereRelation('orderTypes', 'order_types.id', '=', 1); + assertType('Hypervel\Database\Eloquent\Builder', $query); + + $collection = $query->get(); + assertType('Hypervel\Database\Eloquent\Collection', $collection); +} diff --git a/tests/PHPStan/application/app/ChildUser.php b/tests/PHPStan/application/app/ChildUser.php new file mode 100644 index 000000000..5f9e3c323 --- /dev/null +++ b/tests/PHPStan/application/app/ChildUser.php @@ -0,0 +1,8 @@ + */ + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} From ea51672c7773202ca6aca592406ea8bece3444bb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:32:05 +0000 Subject: [PATCH 119/303] Validate model columns without losing dynamic forwarding types Check pagination, pluck and dynamic-where columns against model properties while keeping the builder or relation result type. Recognize actual synthesized dynamic methods so native methods, macros and scopes retain precedence. Keep abstract or unknown model knowledge from creating false missing-column claims, without letting a bare Model mixin give every concrete model every property. Preserve property acceptance for dynamic strings and concrete union alternatives. Add column diagnostics, nullsafe coverage and a Scout mixin regression. Validated with the checkpoint's rule tests, type fixtures, full PHPStan and complete analyzer suite. --- .../src/Properties/ModelPropertyExtension.php | 8 +- .../src/Properties/ModelPropertyHelper.php | 33 +- .../src/Rules/ModelPropertyColumnRule.php | 289 ++++++++++++++++++ .../GenericModelPropertyType.php | 16 +- .../Rules/ModelPropertyColumnRuleTest.php | 125 ++++++++ .../Rules/data/model-property-columns.php | 118 +++++++ tests/PHPStan/Type/data/model-properties.php | 2 +- .../application/app/SearchableUser.php | 17 ++ 8 files changed, 593 insertions(+), 15 deletions(-) create mode 100644 src/phpstan/src/Rules/ModelPropertyColumnRule.php create mode 100644 tests/PHPStan/Rules/ModelPropertyColumnRuleTest.php create mode 100644 tests/PHPStan/Rules/data/model-property-columns.php create mode 100644 tests/PHPStan/application/app/SearchableUser.php diff --git a/src/phpstan/src/Properties/ModelPropertyExtension.php b/src/phpstan/src/Properties/ModelPropertyExtension.php index 6a8bb0f13..90ea43edc 100644 --- a/src/phpstan/src/Properties/ModelPropertyExtension.php +++ b/src/phpstan/src/Properties/ModelPropertyExtension.php @@ -26,7 +26,9 @@ public function __construct( * Determine whether the model has a database property, unless an accessor or mutator handles it. * * A property the schema facts cannot establish is left to a declared relation, which Eloquent - * reads when no attribute has that name. + * reads when no attribute has that name. An abstract model claims no such property: a model + * trait's `@mixin Model`, as on Scout's Searchable, would otherwise give every model every + * property. ModelPropertyHelper::acceptsPropertyName() accepts any name for an abstract model instead. */ public function hasProperty(ClassReflection $classReflection, string $propertyName): bool { @@ -37,7 +39,9 @@ public function hasProperty(ClassReflection $classReflection, string $propertyNa $hasProperty = $this->modelPropertyHelper->hasDatabaseProperty($classReflection, $propertyName); return $hasProperty->yes() - || ($hasProperty->maybe() && ! $this->modelRelationsExtension->hasProperty($classReflection, $propertyName)); + || ($hasProperty->maybe() + && ! $classReflection->isAbstract() + && ! $this->modelRelationsExtension->hasProperty($classReflection, $propertyName)); } /** diff --git a/src/phpstan/src/Properties/ModelPropertyHelper.php b/src/phpstan/src/Properties/ModelPropertyHelper.php index 28bd2aaba..52ea22df3 100644 --- a/src/phpstan/src/Properties/ModelPropertyHelper.php +++ b/src/phpstan/src/Properties/ModelPropertyHelper.php @@ -22,9 +22,11 @@ use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeUtils; use ReflectionException; use ReflectionNamedType; +use function array_all; use function array_combine; use function array_key_exists; use function array_keys; @@ -81,6 +83,27 @@ public function __construct( ) { } + /** + * Determine if a property name is accepted for a model type, as any name is for an abstract model. + * + * An abstract model, including Model itself and template bounds, stands for its subclasses, so any + * name may be one of their attributes. ModelPropertyExtension does not claim these names. + */ + public function acceptsPropertyName(Type $modelType, string $propertyName): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity( + TypeUtils::flattenTypes($modelType), + static function (Type $type) use ($propertyName): TrinaryLogic { + $classReflections = $type->getObjectClassReflections(); + $isAbstractModel = static fn (ClassReflection $classReflection): bool => $classReflection->isAbstract() && $classReflection->is(Model::class); + + return $classReflections !== [] && array_all($classReflections, $isAbstractModel) + ? TrinaryLogic::createYes() + : $type->hasInstanceProperty($propertyName); + }, + ); + } + /** * Determine if the model has a database property, or maybe has one when its schema facts are unknown. */ @@ -90,18 +113,20 @@ public function hasDatabaseProperty(ClassReflection $classReflection, string $pr return TrinaryLogic::createNo(); } - if ($classReflection->isAbstract()) { + if (ReflectionHelper::hasPropertyTag($classReflection, $propertyName)) { return TrinaryLogic::createNo(); } - if (ReflectionHelper::hasPropertyTag($classReflection, $propertyName)) { - return TrinaryLogic::createNo(); + // Unlike upstream, a model without readable schema facts is not claimed to lack the property: + // an abstract model stands for its subclasses, and an uninstantiable one has no known table. + if ($classReflection->isAbstract()) { + return TrinaryLogic::createMaybe(); } try { $modelInstance = $this->modelHelper->getInstance($classReflection); } catch (ReflectionException) { - return TrinaryLogic::createNo(); + return TrinaryLogic::createMaybe(); } // HasAttributes::hasAttribute() accepts a cast key without a column, such as a class cast built from other columns. diff --git a/src/phpstan/src/Rules/ModelPropertyColumnRule.php b/src/phpstan/src/Rules/ModelPropertyColumnRule.php new file mode 100644 index 000000000..6ff67406b --- /dev/null +++ b/src/phpstan/src/Rules/ModelPropertyColumnRule.php @@ -0,0 +1,289 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkModelProperties%')] +class ModelPropertyColumnRule implements Rule +{ + /** + * The column parameter of each checked method, and the classes whose implementation of it is checked. + * + * @var array}> + */ + private const array METHODS = [ + 'paginate' => ['columns', 1, [EloquentBuilder::class, BelongsToMany::class, HasOneOrManyThrough::class]], + 'simplepaginate' => ['columns', 1, [EloquentBuilder::class, BelongsToMany::class, HasOneOrManyThrough::class]], + 'cursorpaginate' => ['columns', 1, [EloquentBuilder::class, BelongsToMany::class, HasOneOrManyThrough::class]], + 'pluck' => ['column', 0, [EloquentBuilder::class]], + ]; + + /** + * Create the rule. + */ + public function __construct( + private BuilderHelper $builderHelper, + private ModelPropertyHelper $modelPropertyHelper, + private RuleLevelHelper $ruleLevelHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report a column that is not a property of the queried model. + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + // PHPStan also analyses a nullsafe call as a method call on the non-null object, so it is checked there once. + if (! ($node instanceof MethodCall || $node instanceof StaticCall) || ! $node->name instanceof Identifier) { + return []; + } + + // Query\Builder::__call() matches the prefix case-sensitively. + if (str_starts_with($node->name->toString(), 'where')) { + return $this->checkDynamicWhere($node, $scope, $node->name->toString()); + } + + $methodName = $node->name->toLowerString(); + + if (! isset(self::METHODS[$methodName])) { + return []; + } + + [$parameterName, $position, $implementations] = self::METHODS[$methodName]; + $argument = $node->getArg($parameterName, $position); + + if ($argument === null || $argument->unpack) { + return []; + } + + $callerType = $this->callerType($node, $scope); + $callerClasses = $callerType->getObjectClassReflections(); + + if (count($callerClasses) !== 1) { + return []; + } + + [$modelType, $implementation] = $this->resolveCall($callerClasses[0], $callerType, $node->name->toString()); + + if ($modelType === null || ! in_array($implementation, $implementations, strict: true)) { + return []; + } + + $property = new GenericModelPropertyType($modelType, $this->modelPropertyHelper); + $columns = new ArrayType(new IntegerType, TypeCombinator::union($property, new ObjectType(Expression::class), new ConstantStringType('*'))); + + // These are the types the upstream stubs declare. The relations take only a list natively. + $expectedType = match (true) { + $methodName === 'pluck' => TypeCombinator::union(new ObjectType(Expression::class), $property), + $implementation === EloquentBuilder::class => TypeCombinator::union($columns, $property, new ConstantStringType('*')), + default => $columns, + }; + $argumentType = $scope->getType($argument->value); + $accepts = $this->ruleLevelHelper->accepts($expectedType, $argumentType, $scope->isDeclareStrictTypes()); + + if ($accepts->result) { + return []; + } + + $verbosity = VerbosityLevel::getRecommendedLevelByType($expectedType, $argumentType); + + return [ + RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s of %s %s::%s() expects %s, %s given.', + $position + 1, + $parameterName, + $node instanceof StaticCall ? 'static method' : 'method', + $callerType->describe(VerbosityLevel::typeOnly()), + $node->name->toString(), + $expectedType->describe($verbosity), + $argumentType->describe($verbosity), + ))->identifier('argument.type')->acceptsReasonsTip($accepts->reasons)->build(), + ]; + } + + /** + * Report a dynamic where, such as whereEmail(), naming a column that is not a property of the queried model. + * + * The forwarding extensions resolve every dynamic where, as Query\Builder::__call() runs it, so the method's + * existence does not depend on the model. + * + * @return list + */ + private function checkDynamicWhere(MethodCall|StaticCall $node, Scope $scope, string $methodName): array + { + $callerType = $this->callerType($node, $scope); + $callerClasses = $callerType->getObjectClassReflections(); + + // A native method, such as the builder's where(), is not a dynamic where. + if (count($callerClasses) !== 1 || $callerClasses[0]->hasNativeMethod($methodName)) { + return []; + } + + $modelType = $this->queriedModel($callerClasses[0], $callerType); + + // An unknown model may have any column. + if ($modelType === null || $modelType->getObjectClassReflections() === []) { + return []; + } + + $method = $scope->getMethodReflection($callerType, $methodName)?->getPrototype(); + + // Each forwarding hop, such as a relation's to its builder, wraps the synthesized method. + while ($method instanceof ForwardedMethodReflection) { + $method = $method->getForwardedMethod()->getPrototype(); + } + + if (! $method instanceof EloquentBuilderMethodReflection) { + return []; + } + + // Split like Query\Builder::dynamicWhere(); each segment names a column. + $segments = preg_split('/(And|Or)(?=[A-Z])/', substr($methodName, 5), -1, PREG_SPLIT_DELIM_CAPTURE); + + foreach ($segments === false ? [] : $segments as $segment) { + $column = Str::snake($segment); + + if ($segment === 'And' || $segment === 'Or' || $this->modelPropertyHelper->acceptsPropertyName($modelType, $column)->yes()) { + continue; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Call to an undefined %s %s::%s().', + $node instanceof StaticCall ? 'static method' : 'method', + $callerType->describe(VerbosityLevel::typeOnly()), + $methodName, + )) + ->identifier($node instanceof StaticCall ? 'staticMethod.notFound' : 'method.notFound') + ->tip(sprintf('Column %s is not a property of %s.', $column, $modelType->describe(VerbosityLevel::typeOnly()))) + ->build(), + ]; + } + + return []; + } + + /** + * Return the type the method is called on. + */ + private function callerType(MethodCall|StaticCall $node, Scope $scope): Type + { + return match (true) { + $node instanceof MethodCall => $scope->getType($node->var), + $node->class instanceof Name => $scope->resolveTypeByName($node->class), + default => $scope->getType($node->class)->getObjectTypeOrClassStringObjectType(), + }; + } + + /** + * Return the model a builder, relation or model queries. + */ + private function queriedModel(ClassReflection $callerClass, Type $callerType): ?Type + { + return match (true) { + $callerClass->is(Relation::class) => $callerType->getTemplateType(Relation::class, 'TRelatedModel'), + $callerClass->is(EloquentBuilder::class) => $callerType->getTemplateType(EloquentBuilder::class, 'TModel'), + $callerClass->is(Model::class) => $callerType, + default => null, + }; + } + + /** + * Resolve the queried model and the class whose method runs, following Relation::__call() and + * Model::__call() to the model's builder. + * + * @return array{null|Type, null|string} + */ + private function resolveCall(ClassReflection $callerClass, Type $callerType, string $methodName): array + { + $modelType = $this->queriedModel($callerClass, $callerType); + + // A model's own method of that name is not forwarded. + if ($modelType === null || ($callerClass->is(Model::class) && $callerClass->hasNativeMethod($methodName))) { + return [null, null]; + } + + return [$modelType, $callerClass->hasNativeMethod($methodName) + ? $callerClass->getNativeMethod($methodName)->getDeclaringClass()->getName() + : $this->builderImplementation($modelType, $methodName)]; + } + + /** + * Return the class whose method the model's builder runs. + */ + private function builderImplementation(Type $modelType, string $methodName): ?string + { + $modelClasses = $modelType->getObjectClassNames(); + $builderClasses = count($modelClasses) === 1 + ? $this->builderHelper->determineBuilderClass($modelClasses[0], $modelType)?->getObjectClassReflections() ?? [] + : []; + + return count($builderClasses) === 1 + ? $builderClasses[0]->getNativeMethod($methodName)->getDeclaringClass()->getName() + : null; + } +} diff --git a/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php b/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php index 22b8b9e41..0ad325cff 100644 --- a/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php +++ b/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php @@ -119,13 +119,14 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult return AcceptsResult::createNo([sprintf('Database table "%s" does not have column "%s"', $tableName, $propertyName)]); } + $accepted = $this->modelPropertyHelper->acceptsPropertyName($genericType, $givenString); $reasons = []; - if (! $genericType->hasInstanceProperty($givenString)->yes()) { + if (! $accepted->yes()) { $reasons[] = sprintf('The given string should be a property of %s, %s given.', $this->type->describe(VerbosityLevel::value()), $givenString); } - return new AcceptsResult($genericType->hasInstanceProperty($givenString), $reasons); + return new AcceptsResult($accepted, $reasons); } if ($type instanceof self) { @@ -149,11 +150,8 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $constantStrings = $type->getConstantStrings(); if (count($constantStrings) === 1) { - if (! $this->getGenericType()->hasInstanceProperty($constantStrings[0]->getValue())->yes()) { - return IsSuperTypeOfResult::createNo(); - } - - return IsSuperTypeOfResult::createYes(); + // Unlike upstream, an unknown property stays maybe, as accepts() keeps it. + return new IsSuperTypeOfResult($this->modelPropertyHelper->acceptsPropertyName($this->getGenericType(), $constantStrings[0]->getValue()), []); } if ($type instanceof self) { @@ -164,7 +162,9 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult return $type->isSubTypeOf($this); } - return IsSuperTypeOfResult::createNo(); + // Unlike upstream, an arbitrary string may name a property, as accepts() allows. PHPStan rechecks an + // accepted intersection, such as a non-empty array with string keys, through isSuperTypeOf(). + return $type->isString()->yes() ? IsSuperTypeOfResult::createMaybe() : IsSuperTypeOfResult::createNo(); } /** diff --git a/tests/PHPStan/Rules/ModelPropertyColumnRuleTest.php b/tests/PHPStan/Rules/ModelPropertyColumnRuleTest.php new file mode 100644 index 000000000..e9cd093ed --- /dev/null +++ b/tests/PHPStan/Rules/ModelPropertyColumnRuleTest.php @@ -0,0 +1,125 @@ + */ +class ModelPropertyColumnRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(ModelPropertyColumnRule::class); + } + + public function testRule(): void + { + $builderColumns = "'*'|array|model property of App\\User"; + $userTip = 'Type #2 from the union: The given string should be a property of App\User, nonexistent given.'; + $userArrayTip = 'Type #2 from the union: Type #3 from the union: The given string should be a property of App\User, nonexistent given.'; + $userColumnTip = 'Column nonexistent is not a property of App\User.'; + + $this->analyse([__DIR__ . '/data/model-property-columns.php'], [ + [ + 'Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.', + 45, + $userTip, + ], + [ + "Parameter #2 \$columns of method Hypervel\\Database\\Eloquent\\Builder::paginate() expects {$builderColumns}, array{'id', 'nonexistent'} given.", + 48, + $userArrayTip, + ], + [ + "Parameter #2 \$columns of method Hypervel\\Database\\Eloquent\\Builder::simplePaginate() expects {$builderColumns}, array{'nonexistent'} given.", + 50, + $userArrayTip, + ], + [ + "Parameter #2 \$columns of method Hypervel\\Database\\Eloquent\\Builder::cursorPaginate() expects {$builderColumns}, 'nonexistent' given.", + 51, + 'Type #3 from the union: The given string should be a property of App\User, nonexistent given.', + ], + [ + 'Parameter #1 $column of static method App\User::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.', + 53, + $userTip, + ], + [ + "Parameter #2 \$columns of static method App\\User::paginate() expects {$builderColumns}, array{'nonexistent'} given.", + 54, + $userArrayTip, + ], + [ + 'Parameter #1 $column of method Hypervel\Database\Eloquent\Relations\HasMany::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\Account, string given.', + 56, + 'Type #2 from the union: The given string should be a property of App\Account, nonexistent given.', + ], + [ + "Parameter #2 \$columns of method Hypervel\\Database\\Eloquent\\Relations\\BelongsToMany::paginate() expects array, array{'nonexistent'} given.", + 57, + 'Type #3 from the union: The given string should be a property of App\Role, nonexistent given.', + ], + [ + "Parameter #2 \$columns of method Hypervel\\Database\\Eloquent\\Relations\\HasManyThrough::simplePaginate() expects array, array{'nonexistent'} given.", + 58, + 'Type #3 from the union: The given string should be a property of App\Transaction, nonexistent given.', + ], + [ + 'Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\SearchableUser, string given.', + 64, + 'Type #2 from the union: The given string should be a property of App\SearchableUser, nonexistent given.', + ], + [ + 'Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User|ModelPropertyColumns\AbstractSubject, string given.', + 67, + 'Type #2 from the union: The given string should be a property of App\User|ModelPropertyColumns\AbstractSubject, nonexistent given.', + ], + [ + 'Parameter #1 $column of static method App\User::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.', + 70, + $userTip, + ], + [ + 'Call to an undefined method Hypervel\Database\Eloquent\Builder::whereNameOrNonexistent().', + 105, + $userColumnTip, + ], + [ + 'Call to an undefined static method App\User::whereNonexistent().', + 106, + $userColumnTip, + ], + [ + 'Call to an undefined method App\User::whereNonexistent().', + 107, + $userColumnTip, + ], + [ + 'Call to an undefined method Hypervel\Database\Eloquent\Relations\HasMany::whereNonexistent().', + 108, + 'Column nonexistent is not a property of App\Account.', + ], + [ + 'Call to an undefined method ModelPropertyColumns\WhereBuilder::whereNonexistent().', + 109, + $userColumnTip, + ], + [ + 'Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.', + 110, + $userTip, + ], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/data/model-property-columns.php b/tests/PHPStan/Rules/data/model-property-columns.php new file mode 100644 index 000000000..53d738569 --- /dev/null +++ b/tests/PHPStan/Rules/data/model-property-columns.php @@ -0,0 +1,118 @@ + + */ +class AliasBuilder extends Builder +{ + /** + * Pluck a column by an alias the builder maps. + * + * @return Collection + */ + public function pluck(Expression|string $column, ?string $key = null): Collection + { + return parent::pluck($column, $key); + } +} + +abstract class AbstractSubject extends Model +{ +} + +/** + * @param AliasBuilder $aliases + * @param Builder $unknown + * @param Builder $mixed + */ +function test(User $user, AliasBuilder $aliases, Builder $unknown, Builder $mixed, string $column): void +{ + User::query()->pluck('name'); + User::query()->pluck('nonexistent'); + User::query()->pluck(DB::raw('count(*)')); + User::query()->pluck($column); + User::query()->paginate(15, ['id', 'nonexistent']); + User::query()->paginate(15, ['*', 'users.id']); + User::query()->simplePaginate(columns: ['nonexistent']); + User::query()->cursorPaginate(15, 'nonexistent'); + + User::pluck('nonexistent'); + User::paginate(15, ['nonexistent']); + + $user->accounts()->pluck('nonexistent'); + $user->roles()->paginate(15, ['nonexistent']); + $user->transactions()->simplePaginate(15, ['nonexistent']); + + $aliases->pluck('alias'); + $unknown->pluck('anything'); + + SearchableUser::query()->pluck('name'); + SearchableUser::query()->pluck('nonexistent'); + + $mixed->pluck('name'); + $mixed->pluck('nonexistent'); + + $class = User::class; + $class::pluck('nonexistent'); +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class WhereBuilder extends Builder +{ + /** + * Constrain a column of whichever model the builder queries. + */ + public function active(): static + { + return $this->whereActive(true); + } + + /** + * Declare a where method that the query does not. + */ + public function whereAlias(string $alias): static + { + return $this->where('name', $alias); + } +} + +/** + * @param WhereBuilder $custom + * @param Builder $unknown + * @param null|Builder $optional + */ +function dynamicWheres(User $user, WhereBuilder $custom, Builder $unknown, ?Builder $optional): void +{ + User::query()->whereNameAndEmail('Taylor', 'taylor@example.com'); + User::query()->whereNameOrNonexistent('Taylor', 1); + User::whereNonexistent(1); + $user->whereNonexistent(1); + $user->accounts()->whereNonexistent(1); + $custom->whereNonexistent(1); + $optional?->pluck('nonexistent'); + + $custom->whereAlias('Taylor'); + User::query()->whereActive(); + User::query()->whereGlobalMacro(); + User::query()->whereQueryMacro(); + $user->accounts()->whereIn('id', [1]); + $unknown->whereAnything(1); +} diff --git a/tests/PHPStan/Type/data/model-properties.php b/tests/PHPStan/Type/data/model-properties.php index ae3d81d3e..ff5a9bdf9 100644 --- a/tests/PHPStan/Type/data/model-properties.php +++ b/tests/PHPStan/Type/data/model-properties.php @@ -123,7 +123,7 @@ function test( function testUnknownSchema(UnknownSchemaUser $user): void { // Unknown schema facts do not hide a declared relation, and only a trailing _count names a relation count. - assertType('Hypervel\Database\Eloquent\Collection', $user->posts); + assertType('Hypervel\Database\Eloquent\Collection', $user->posts); assertType('int<0, max>', $user->stock_count_items_count); assertType('mixed', $user->name); } diff --git a/tests/PHPStan/application/app/SearchableUser.php b/tests/PHPStan/application/app/SearchableUser.php new file mode 100644 index 000000000..a9676dc20 --- /dev/null +++ b/tests/PHPStan/application/app/SearchableUser.php @@ -0,0 +1,17 @@ + Date: Sun, 8 Nov 2026 16:32:05 +0000 Subject: [PATCH 120/303] Port Eloquent stubs with standalone stub validation coverage Add the Eloquent model, builder and relation stubs with analyzer-only property, builder, relation and collection types. Supply referenced declarations needed when PHPStan validates the stubs without application reflection. Preserve source native signatures and PHPDoc where the stubs replace declarations, including repository offset contracts. Keep collection and paginator variance declarations in source, and treat unreflectable provider writes as unknown during discovery. Add explicit stub mirror checks and integration diagnostics for repository offsets, dynamic property keys, unknown relations and static builder calls. Validated with full PHPStan/types and the complete analyzer test suite. --- .../src/Support/ConfigSourceResolver.php | 6 + src/phpstan/stubs/common/BelongsToMany.stub | 273 ++++++++ .../stubs/common/Cache/Repository.stub | 37 +- .../stubs/common/Config/Repository.stub | 43 +- .../common/Contracts/Database/Query.stub | 9 + .../stubs/common/Contracts/Support.stub | 4 +- .../stubs/common/Database/Database.stub | 8 + .../stubs/common/Database/Eloquent.stub | 20 + .../common/Database/Eloquent/Relations.stub | 8 + src/phpstan/stubs/common/Database/Query.stub | 14 + src/phpstan/stubs/common/EloquentBuilder.stub | 629 ++++++++++++++++++ src/phpstan/stubs/common/HasOneOrMany.stub | 191 ++++++ .../stubs/common/HasOneOrManyThrough.stub | 229 +++++++ src/phpstan/stubs/common/Model.stub | 88 +++ src/phpstan/stubs/common/Relation.stub | 80 +++ src/phpstan/stubs/common/SortDirection.stub | 8 + tests/PHPStan/Integration/IntegrationTest.php | 32 + .../Integration/data/builder-static-calls.php | 16 + .../data/model-property-dynamic-keys.php | 12 + .../Integration/data/repository-offsets.php | 20 + .../data/unknown-relation-model.php | 15 + tests/PHPStan/Unit/StubMirrorsTest.php | 126 ++++ .../Unit/Support/ConfigSourceResolverTest.php | 10 + 23 files changed, 1875 insertions(+), 3 deletions(-) create mode 100644 src/phpstan/stubs/common/BelongsToMany.stub create mode 100644 src/phpstan/stubs/common/Contracts/Database/Query.stub create mode 100644 src/phpstan/stubs/common/Database/Eloquent.stub create mode 100644 src/phpstan/stubs/common/Database/Eloquent/Relations.stub create mode 100644 src/phpstan/stubs/common/Database/Query.stub create mode 100644 src/phpstan/stubs/common/EloquentBuilder.stub create mode 100644 src/phpstan/stubs/common/HasOneOrMany.stub create mode 100644 src/phpstan/stubs/common/HasOneOrManyThrough.stub create mode 100644 src/phpstan/stubs/common/Model.stub create mode 100644 src/phpstan/stubs/common/Relation.stub create mode 100644 src/phpstan/stubs/common/SortDirection.stub create mode 100644 tests/PHPStan/Integration/data/builder-static-calls.php create mode 100644 tests/PHPStan/Integration/data/model-property-dynamic-keys.php create mode 100644 tests/PHPStan/Integration/data/repository-offsets.php create mode 100644 tests/PHPStan/Integration/data/unknown-relation-model.php create mode 100644 tests/PHPStan/Unit/StubMirrorsTest.php diff --git a/src/phpstan/src/Support/ConfigSourceResolver.php b/src/phpstan/src/Support/ConfigSourceResolver.php index a13027e15..715c5bb35 100644 --- a/src/phpstan/src/Support/ConfigSourceResolver.php +++ b/src/phpstan/src/Support/ConfigSourceResolver.php @@ -460,6 +460,12 @@ private function getProviderSources(): array */ private function discoverProvider(ServiceProvider $provider, array &$merges, array &$writtenKeys, array &$mergedFiles): bool { + // An anonymous provider cannot be reflected by name. Neither can any provider when PHPStan + // validates stub files that are not in the vendor directory, against the stubs alone. + if (! $this->reflectionProvider->hasClass($provider::class)) { + return true; + } + $providerClass = $this->reflectionProvider->getClass($provider::class); $classLikes = $this->getClassLikes($providerClass); diff --git a/src/phpstan/stubs/common/BelongsToMany.stub b/src/phpstan/stubs/common/BelongsToMany.stub new file mode 100644 index 000000000..1a8a83c9f --- /dev/null +++ b/src/phpstan/stubs/common/BelongsToMany.stub @@ -0,0 +1,273 @@ +> + * + * @todo use TAccessor when PHPStan bug is fixed: https://github.com/phpstan/phpstan/issues/12756 + */ +class BelongsToMany extends Relation +{ + /** + * Prepare the query builder for query execution. + * + * @return builder-of + */ + protected function prepareQueryBuilder(): Builder; + + /** + * Find a related model by its primary key or return a new instance of the related model. + * + * @param array|Expression|'*'> $columns + * @return ( + * $id is Model ? TRelatedModel : ( + * $id is (Arrayable>|array) + * ? collection-of + * : TRelatedModel + * ) + * ) + */ + public function findOrNew(mixed $id, array $columns = ['*']); + + /** + * Get the first related model record matching the attributes or instantiate it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @param array $joining + * @return TRelatedModel + */ + public function firstOrCreate(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @param array $joining + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrFirst(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model; + + /** + * Get the first record matching the attributes, including trashed records, and restore it. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @param array $joining + * @return TRelatedModel + */ + public function restoreOrCreate(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, find the matching record, including trashed records, and restore it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @param array $joining + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrRestore(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model; + + /** + * Create or update a related record matching the attributes, and fill it with values. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @param array $joining + * @return TRelatedModel + */ + public function updateOrCreate(array $attributes, Closure|array $values = [], array $joining = [], bool $touch = true): Model; + + /** + * Create a related record matching the attributes, or increment the existing record. + * + * @param array, mixed> $attributes + * @param model-property $column + * @param array, mixed> $extra + * @return TRelatedModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model; + + /** + * Find a related model by its primary key. + * + * @param array|Expression|'*'> $columns + * @return ( + * $id is Model ? (TRelatedModel&object{pivot: TPivotModel})|null : ( + * $id is (Arrayable>|array) + * ? collection-of + * : (TRelatedModel&object{pivot: TPivotModel})|null + * ) + * ) + */ + public function find(mixed $id, array $columns = ['*']); + + /** + * Find multiple related models by their primary keys. + * + * @param array|Arrayable> $ids + * @param array|Expression|'*'> $columns + * @return collection-of + */ + public function findMany(Arrayable|array $ids, array $columns = ['*']); + + /** + * Find a related model by its primary key or throw an exception. + * + * @param array|Expression|'*'> $columns + * @return ( + * $id is Model ? TRelatedModel&object{pivot: TPivotModel} : ( + * $id is (Arrayable>|array) + * ? collection-of + * : TRelatedModel&object{pivot: TPivotModel} + * ) + * ) + * + * @throws ModelNotFoundException + */ + public function findOrFail(mixed $id, array $columns = ['*']); + + /** + * Find a related model by its primary key or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|Expression|'*'>|model-property|'*' $columns + * @param null|(Closure(): TValue) $callback + * @return ( + * $id is Model ? (TRelatedModel&object{pivot: TPivotModel})|TValue : ( + * $id is (Arrayable>|array) + * ? collection-of|TValue + * : (TRelatedModel&object{pivot: TPivotModel})|TValue + * ) + * ) + */ + public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Add a basic where clause to the query, and return the first result. + * + * @param array, mixed>|(Closure(($operator is null ? builder-of : QueryBuilder)): mixed)|model-property $column + * @return null|(object{pivot: TPivotModel}&TRelatedModel) + */ + public function firstWhere(Closure|string|array $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model; + + /** + * Execute the query and get the first result. + * + * @param array|Expression|'*'> $columns + * @return null|(object{pivot: TPivotModel}&TRelatedModel) + */ + public function first(array $columns = ['*']): ?Model; + + /** + * Execute the query and get the first result or throw an exception. + * + * @param array|Expression|'*'> $columns + * @return object{pivot: TPivotModel}&TRelatedModel + * + * @throws ModelNotFoundException + */ + public function firstOrFail(array $columns = ['*']): Model; + + /** + * Execute the query and get the first result or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|Expression|'*'> $columns + * @param null|(Closure(): TValue) $callback + * @return (object{pivot: TPivotModel}&TRelatedModel)|TValue + */ + public function firstOr(Closure|array $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Execute the query as a "select" statement. + * + * @param array|Expression|'*'> $columns + * @return collection-of + */ + public function get(array $columns = ['*']); + + /** + * Chunk the results of the query. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunk(int $count, callable $callback): bool; + + /** + * Chunk the results of a query by comparing numeric IDs. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; + + /** + * Chunk the results of a query by comparing IDs in descending order. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; + + /** + * Chunk the results of a query by comparing IDs in a given order. + * + * @param callable(collection-of, int): mixed $callback + */ + public function orderedChunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null, SortDirection|bool $descending = false): bool; + + /** + * Create a new instance of the related model. + * + * @param array, mixed> $attributes + * @param array $joining + * @return TRelatedModel + */ + public function create(array $attributes = [], array $joining = [], bool $touch = true): Model; +} diff --git a/src/phpstan/stubs/common/Cache/Repository.stub b/src/phpstan/stubs/common/Cache/Repository.stub index ac62039ec..2c286efe3 100644 --- a/src/phpstan/stubs/common/Cache/Repository.stub +++ b/src/phpstan/stubs/common/Cache/Repository.stub @@ -2,5 +2,40 @@ namespace Hypervel\Cache; +use UnitEnum; + +/* + * A class declared in a stub takes the nearest ancestor stub's docblock for a method its own stub + * omits, here PHPStan's ArrayAccess stub, so the offset methods repeat the source. + */ class Repository -{} +{ + /** + * Determine if a cached value exists. + * + * @param string|UnitEnum $offset + */ + public function offsetExists($offset): bool; + + /** + * Retrieve an item from the cache by key. + * + * @param string|UnitEnum $offset + */ + public function offsetGet($offset): mixed; + + /** + * Store an item in the cache for the default time. + * + * @param string|UnitEnum $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void; + + /** + * Remove an item from the cache. + * + * @param string|UnitEnum $offset + */ + public function offsetUnset($offset): void; +} diff --git a/src/phpstan/stubs/common/Config/Repository.stub b/src/phpstan/stubs/common/Config/Repository.stub index b0babf630..01245dcd4 100644 --- a/src/phpstan/stubs/common/Config/Repository.stub +++ b/src/phpstan/stubs/common/Config/Repository.stub @@ -2,5 +2,46 @@ namespace Hypervel\Config; +/* + * A class declared in a stub takes the nearest ancestor stub's docblock for a method its own stub + * omits, here PHPStan's ArrayAccess stub, so the offset methods repeat the source. + */ class Repository -{} +{ + /** + * Determine if the given configuration option exists. + * + * @param int|string $offset + */ + public function offsetExists($offset): bool; + + /** + * Get a configuration option. + * + * @param int|string $offset + */ + public function offsetGet($offset): mixed; + + /** + * Set a configuration option. + * + * Boot or tests only. The config repository is a process-global singleton; + * per-request mutation races across coroutines and affects every concurrent + * request. + * + * @param int|string $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void; + + /** + * Unset a configuration option. + * + * Boot or tests only. The config repository is a process-global singleton; + * per-request mutation races across coroutines and affects every concurrent + * request. + * + * @param int|string $offset + */ + public function offsetUnset($offset): void; +} diff --git a/src/phpstan/stubs/common/Contracts/Database/Query.stub b/src/phpstan/stubs/common/Contracts/Database/Query.stub new file mode 100644 index 000000000..edd7ccd05 --- /dev/null +++ b/src/phpstan/stubs/common/Contracts/Database/Query.stub @@ -0,0 +1,9 @@ + + */ interface Arrayable {} diff --git a/src/phpstan/stubs/common/Database/Database.stub b/src/phpstan/stubs/common/Database/Database.stub index 999d0f4ed..7d9693ebb 100644 --- a/src/phpstan/stubs/common/Database/Database.stub +++ b/src/phpstan/stubs/common/Database/Database.stub @@ -8,3 +8,11 @@ abstract class Connection implements ConnectionInterface interface ConnectionInterface {} class DatabaseManager {} + +class QueryException extends \PDOException {} + +class UniqueConstraintViolationException extends QueryException {} + +class RecordsNotFoundException extends \RuntimeException {} + +class MultipleRecordsFoundException extends \RuntimeException {} diff --git a/src/phpstan/stubs/common/Database/Eloquent.stub b/src/phpstan/stubs/common/Database/Eloquent.stub new file mode 100644 index 000000000..23891af76 --- /dev/null +++ b/src/phpstan/stubs/common/Database/Eloquent.stub @@ -0,0 +1,20 @@ + $abilities, mixed $user = null) + * @method $this fetchUsing(mixed ...$fetchUsing) + * @method $this useWritePdo() + * + * @mixin QueryBuilder + */ +class Builder +{ + /** + * Add a relationship count / exists condition to the query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + * + * @throws RuntimeException + */ + public function has(Relation|string $relation, string $operator = '>=', Expression|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static; + + /** + * Add a relationship count / exists condition to the query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + */ + public function doesntHave(Relation|string $relation, string $boolean = 'and', ?Closure $callback = null): static; + + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + */ + public function whereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; + + /** + * Add a relationship count / exists condition to the query with where clauses and an "or". + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + */ + public function orWhereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; + + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + */ + public function whereDoesntHave(Relation|string $relation, ?Closure $callback = null): static; + + /** + * Add a relationship count / exists condition to the query with where clauses and an "or". + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + */ + public function orWhereDoesntHave(Relation|string $relation, ?Closure $callback = null): static; + + /** + * Add a basic where clause to a relationship query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param array|(Closure(($relation is string ? builder-of : builder-of)): mixed)|Expression|string $column + */ + public function whereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add an "or where" clause to a relationship query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param array|(Closure(($relation is string ? builder-of : builder-of)): mixed)|Expression|string $column + */ + public function orWhereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a basic count / exists condition to a relationship query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param array|(Closure(($relation is string ? builder-of : builder-of)): mixed)|Expression|string $column + */ + public function whereDoesntHaveRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add an "or where" clause to a relationship query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param array|(Closure(($relation is string ? builder-of : builder-of)): mixed)|Expression|string $column + */ + public function orWhereDoesntHaveRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * Also load the relationship with the same condition. + * + * @template TRelation of string + * + * @param TRelation $relation + * @param null|(Closure(($relation is literal-string ? builder-of : Builder<*>)|relation-of): mixed) $callback + */ + public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; + + /** + * Add a basic where clause to a relationship query and eager-load the relationship with the same conditions. + * + * @template TRelation of string + * + * @param TRelation $relation + * @param array|(Closure(($relation is literal-string ? builder-of : Builder<*>)|relation-of): mixed)|Expression|string $column + */ + public function withWhereRelation(string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Specify relationships that should be eager loaded. + * + * @template TRelation of string + * + * @param TRelation|array|(callable(relation-of): mixed)|string> $relations + * @param null|(Closure(relation-of): mixed)|string $callback + */ + public function with(array|string $relations, Closure|string|null $callback = null): static; + + /** + * Set the relationships that should be eager loaded while removing any previously added eager loading specifications. + * + * @template TRelation of string + * + * @param string|array|(callable(relation-of): mixed)|string> $relations + */ + public function withOnly(array|string $relations): static; + + /** + * Create and return an un-saved model instance. + * + * @param array, mixed> $attributes + * @return TModel + */ + public function make(array $attributes = []): Model; + + /** + * Save a new model and return the instance. + * + * @param array, mixed> $attributes + * @return TModel + */ + public function create(array $attributes = []): Model; + + /** + * Save a new model and return the instance without raising model events. + * + * @param array, mixed> $attributes + * @return TModel + */ + public function createQuietly(array $attributes = []): Model; + + /** + * Create a collection of models from plain arrays. + * + * @param array $items + * @return collection-of + */ + public function hydrate(array $items); + + /** + * Create a collection of models from a raw query. + * + * @param array $bindings + * @return collection-of + */ + public function fromQuery(string $query, array $bindings = []); + + /** + * Find a model by its primary key. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return ($id is Model ? null|TModel : ($id is (array|Arrayable>) ? collection-of : null|TModel)) + */ + public function find(mixed $id, array|string $columns = ['*']); + + /** + * Find a sole model by its primary key. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return TModel + * + * @throws ModelNotFoundException + * @throws MultipleRecordsFoundException + */ + public function findSole(mixed $id, array|string $columns = ['*']): Model; + + /** + * Find multiple models by their primary keys. + * + * @param Arrayable|array $ids + * @param array|Expression|'*'>|model-property|'*' $columns + * @return collection-of + */ + public function findMany(Arrayable|array $ids, array|string $columns = ['*']); + + /** + * Find a model by its primary key or throw an exception. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return ($id is Model ? TModel : ($id is (array|Arrayable>) ? collection-of : TModel)) + * + * @throws ModelNotFoundException + */ + public function findOrFail(mixed $id, array|string $columns = ['*']); + + /** + * Find a model by its primary key or return fresh model instance. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return ($id is Model ? TModel : ($id is (array|Arrayable>) ? collection-of : TModel)) + */ + public function findOrNew(mixed $id, array|string $columns = ['*']); + + /** + * Find a model by its primary key or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|array|Expression|'*'>|model-property|'*' $columns + * @param null|(Closure(): TValue) $callback + * @return ( + * $id is Model ? TModel|TValue : ( + * $id is (Arrayable>|array) + * ? collection-of|TValue + * : TModel|TValue + * ) + * ) + */ + public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Execute the query and get the first result. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return null|TModel + */ + public function first(array|string $columns = ['*']); + + /** + * Get the first record matching the attributes or instantiate it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TModel + */ + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TModel + */ + public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model; + + /** + * Execute the query and get the first result or throw an exception. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return TModel + * + * @throws ModelNotFoundException + */ + public function firstOrFail(array|string $columns = ['*']): Model; + + /** + * Execute the query and get the first result or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|Expression|'*'> $columns + * @param null|(Closure(): TValue) $callback + * @return TModel|TValue + */ + public function firstOr(Closure|array $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrFirst(array $attributes = [], Closure|array $values = []): Model; + + /** + * Create or update a record matching the attributes, and fill it with values. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TModel + */ + public function updateOrCreate(array $attributes, Closure|array $values = []): Model; + + /** + * Create a record matching the attributes, or increment the existing record. + * + * @param array, mixed> $attributes + * @param model-property $column + * @param array, mixed> $extra + * @return TModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model; + + /** + * Save a new model and return the instance. Allow mass-assignment. + * + * @param array, mixed> $attributes + * @return TModel + */ + public function forceCreate(array $attributes): Model; + + /** + * Save a new model instance with mass assignment without raising model events. + * + * @param array, mixed> $attributes + * @return TModel + */ + public function forceCreateQuietly(array $attributes = []): Model; + + /** + * Update records in the database. + * + * @param array, mixed> $values + */ + public function update(array $values): int; + + /** + * Insert new records or update the existing ones. + * + * @param array, mixed>|array, mixed>> $values + * @param model-property|array> $uniqueBy + * @param null|array, mixed> $update + */ + public function upsert(array $values, array|string $uniqueBy, ?array $update = null): int; + + /** + * Insert into the database after merging the model's default attributes, setting timestamps, and casting values. + * + * @param array, mixed>> $values + */ + public function fillAndInsert(array $values): bool; + + /** + * Insert (ignoring errors) into the database after merging the model's default attributes, setting timestamps, and casting values. + * + * @param array, mixed>> $values + */ + public function fillAndInsertOrIgnore(array $values): int; + + /** + * Insert a record into the database and get its ID after merging the model's default attributes, setting timestamps, and casting values. + * + * @param array, mixed> $values + */ + public function fillAndInsertGetId(array $values): int; + + /** + * Enrich the given values by merging in the model's default attributes, adding timestamps, and casting values. + * + * @param array, mixed>> $values + * @return array> + */ + public function fillForInsert(array $values): array; + + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return TModel + * + * @throws ModelNotFoundException + * @throws MultipleRecordsFoundException + */ + public function sole(array|string $columns = ['*']): Model; + + /** + * Add a basic where clause to the query. + * + * @param array, mixed>|(Closure(($operator is null ? static : QueryBuilder)): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this + */ + public function where(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static; + + /** + * Add an "or where" clause to the query. + * + * A null operator passed as the second of two arguments becomes a comparison with null, which + * makes a callback a subquery, so the callback type cannot follow the operator here. + * + * @param array, mixed>|(Closure(__benevolent): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + */ + public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a basic where clause to the query, and return the first result. + * + * @param array, mixed>|(Closure(($operator is null ? static : QueryBuilder)): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return null|TModel + */ + public function firstWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model; + + /** + * Add a basic "where not" clause to the query. + * + * @param array, mixed>|(Closure(($operator is null ? static : QueryBuilder)): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + */ + public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static; + + /** + * Add an "or where not" clause to the query. + * + * @param array, mixed>|(Closure(($operator is null ? static : QueryBuilder)): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + */ + public function orWhereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static; + + /** + * Specify attributes that should be added to any new models created by this builder. + * + * The given key / value pairs will also be added as where conditions to the query. + * + * @param array, mixed>|Expression|model-property $attributes + */ + public function withAttributes(Expression|array|string $attributes, mixed $value = null, bool $asConditions = true): static; + + /** + * Add an "order by" clause for a timestamp to the query. + * + * @param null|Closure|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + */ + public function latest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static; + + /** + * Add an "order by" clause for a timestamp to the query. + * + * @param null|Closure|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + */ + public function oldest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static; + + /** + * Execute the query as a "select" statement. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return collection-of + */ + public function get(array|string $columns = ['*']); + + /** + * Get the hydrated models without eager loading. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return array + */ + public function getModels(array|string $columns = ['*']): array; + + /** + * Get a single column's value from the first result of a query. + * + * @param Expression|model-property $column + */ + public function value(Expression|string $column): mixed; + + /** + * Get a single column's value from the first result of a query if it's the sole matching record. + * + * @param Expression|model-property $column + * + * @throws ModelNotFoundException + * @throws MultipleRecordsFoundException + */ + public function soleValue(Expression|string $column): mixed; + + /** + * Get a single column's value from the first result of the query or throw an exception. + * + * @param Expression|model-property $column + * + * @throws ModelNotFoundException + */ + public function valueOrFail(Expression|string $column): mixed; + + /** + * Chunk the results of the query. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunk(int $count, callable $callback): bool; + + /** + * Chunk the results of a query by comparing IDs. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; + + /** + * Chunk the results of a query by comparing IDs in descending order. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; + + /** + * Chunk the results of a query by comparing IDs in a given order. + * + * @param callable(collection-of, int): mixed $callback + */ + public function orderedChunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null, SortDirection|bool $descending = false): bool; + + /** + * Increment a column's value by a given amount. + * + * @param Expression|model-property $column + * @param array, mixed> $extra + */ + public function increment(Expression|string $column, mixed $amount = 1, array $extra = []): int; + + /** + * Decrement a column's value by a given amount. + * + * @param Expression|model-property $column + * @param array, mixed> $extra + */ + public function decrement(Expression|string $column, mixed $amount = 1, array $extra = []): int; + + /** + * Increment the given column's values by the given amounts. + * + * @param array, float|int|numeric-string> $columns + * @param array, mixed> $extra + */ + public function incrementEach(array $columns, array $extra = []): int; + + /** + * Decrement the given column's values by the given amounts. + * + * @param array, float|int|numeric-string> $columns + * @param array, mixed> $extra + */ + public function decrementEach(array $columns, array $extra = []): int; + + /** + * Qualify the given column name by the model's table. + * + * @param Expression|model-property $column + */ + public function qualifyColumn(Expression|string $column): string; + + /** + * Qualify the given columns with the model's table. + * + * @param array> $columns + * @return array + */ + public function qualifyColumns(array $columns): array; +} diff --git a/src/phpstan/stubs/common/HasOneOrMany.stub b/src/phpstan/stubs/common/HasOneOrMany.stub new file mode 100644 index 000000000..ddd825128 --- /dev/null +++ b/src/phpstan/stubs/common/HasOneOrMany.stub @@ -0,0 +1,191 @@ + + */ +abstract class HasOneOrMany extends Relation +{ + /** + * Create and return an un-saved instance of the related model. + * + * @param array, mixed> $attributes + * @return TRelatedModel + */ + public function make(array $attributes = []): Model; + + /** + * Create and return an un-saved instance of the related models. + * + * @param iterable, mixed>> $records + * @return collection-of + */ + public function makeMany(iterable $records); + + /** + * Find a model by its primary key or return a new instance of the related model. + * + * @param array|Expression|'*'> $columns + * @return ($id is Model ? TRelatedModel : ($id is (array|Arrayable>) ? collection-of : TRelatedModel)) + */ + public function findOrNew(mixed $id, array $columns = ['*']); + + /** + * Get the first related model record matching the attributes or instantiate it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrFirst(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes, including trashed records, and restore it. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function restoreOrCreate(array $attributes = [], Closure|array $values = []): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, find the matching record, including trashed records, and restore it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrRestore(array $attributes = [], Closure|array $values = []): Model; + + /** + * Create or update a related record matching the attributes, and fill it with values. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function updateOrCreate(array $attributes, Closure|array $values = []): Model; + + /** + * Create a related record matching the attributes, or increment the existing record. + * + * @param array, mixed> $attributes + * @param model-property $column + * @param array, mixed> $extra + * @return TRelatedModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model; + + /** + * Insert new records or update the existing ones. + * + * @param array, mixed>|array, mixed>> $values + * @param model-property|array> $uniqueBy + * @param null|array, mixed> $update + */ + public function upsert(array $values, array|string $uniqueBy, ?array $update = null): int; + + /** + * Create a new instance of the related model. + * + * @param array, mixed> $attributes + * @return TRelatedModel + */ + public function create(array $attributes = []): Model; + + /** + * Create a new instance of the related model without raising any events to the parent model. + * + * @param array, mixed> $attributes + * @return TRelatedModel + */ + public function createQuietly(array $attributes = []): Model; + + /** + * Create a new instance of the related model. Allow mass-assignment. + * + * @param array, mixed> $attributes + * @return TRelatedModel + */ + public function forceCreate(array $attributes = []): Model; + + /** + * Create a new instance of the related model with mass assignment without raising model events. + * + * @param array, mixed> $attributes + * @return TRelatedModel + */ + public function forceCreateQuietly(array $attributes = []): Model; + + /** + * Create a Collection of new instances of the related model. + * + * @param iterable, mixed>> $records + * @return collection-of + */ + public function createMany(iterable $records); + + /** + * Create a Collection of new instances of the related model without raising any events to the parent model. + * + * @param iterable, mixed>> $records + * @return collection-of + */ + public function createManyQuietly(iterable $records); + + /** + * Create a Collection of new instances of the related model, allowing mass-assignment. + * + * @param iterable, mixed>> $records + * @return collection-of + */ + public function forceCreateMany(iterable $records); + + /** + * Create a Collection of new instances of the related model, allowing mass-assignment and without raising any events to the parent model. + * + * @param iterable, mixed>> $records + * @return collection-of + */ + public function forceCreateManyQuietly(iterable $records); +} diff --git a/src/phpstan/stubs/common/HasOneOrManyThrough.stub b/src/phpstan/stubs/common/HasOneOrManyThrough.stub new file mode 100644 index 000000000..56658aab0 --- /dev/null +++ b/src/phpstan/stubs/common/HasOneOrManyThrough.stub @@ -0,0 +1,229 @@ + + */ +abstract class HasOneOrManyThrough extends Relation +{ + /** + * Prepare the query builder for query execution. + * + * @param array|Expression|'*'> $columns + * @return builder-of + */ + protected function prepareQueryBuilder(array $columns = ['*']): Builder; + + /** + * Add the constraints for an internal relationship existence query. + * + * Unlike Laravel, which inherits Relation's types, the parent query is the declaring model's: Relation's + * parent here is the intermediate model. + * + * @param builder-of $query + * @param builder-of $parentQuery + * @param array|Expression|'*'>|model-property|Expression|'*' $columns + * @return builder-of + */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder; + + /** + * Get the first related model record matching the attributes or instantiate it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrFirst(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes, including trashed records, and restore it. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function restoreOrCreate(array $attributes = [], Closure|array $values = []): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, find the matching record, including trashed records, and restore it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrRestore(array $attributes = [], Closure|array $values = []): Model; + + /** + * Create or update a related record matching the attributes, and fill it with values. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function updateOrCreate(array $attributes, Closure|array $values = []): Model; + + /** + * Create a related record matching the attributes, or increment the existing record. + * + * @param array, mixed> $attributes + * @param model-property $column + * @param array, mixed> $extra + * @return TRelatedModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model; + + /** + * Add a basic where clause to the query, and return the first result. + * + * @param array, mixed>|(Closure(($operator is null ? builder-of : QueryBuilder)): mixed)|model-property $column + * @return null|TRelatedModel + */ + public function firstWhere(Closure|string|array $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model; + + /** + * Execute the query and get the first related model. + * + * @param array|Expression|'*'> $columns + * @return null|TRelatedModel + */ + public function first(array $columns = ['*']): ?Model; + + /** + * Execute the query and get the first result or throw an exception. + * + * @param array|Expression|'*'> $columns + * @return TRelatedModel + * + * @throws ModelNotFoundException + */ + public function firstOrFail(array $columns = ['*']): Model; + + /** + * Execute the query and get the first result or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|Expression|'*'> $columns + * @param null|(Closure(): TValue) $callback + * @return TRelatedModel|TValue + */ + public function firstOr(Closure|array $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Find a related model by its primary key. + * + * @param array|Expression|'*'> $columns + * @return ($id is Model ? null|TRelatedModel : ($id is (array|Arrayable>) ? collection-of : null|TRelatedModel)) + */ + public function find(mixed $id, array $columns = ['*']); + + /** + * Find multiple related models by their primary keys. + * + * @param array|Arrayable> $ids + * @param array|Expression|'*'> $columns + * @return collection-of + */ + public function findMany(Arrayable|array $ids, array $columns = ['*']); + + /** + * Find a related model by its primary key or throw an exception. + * + * @param array|Expression|'*'> $columns + * @return ($id is Model ? TRelatedModel : ($id is (array|Arrayable>) ? collection-of : TRelatedModel)) + * + * @throws ModelNotFoundException + */ + public function findOrFail(mixed $id, array $columns = ['*']); + + /** + * Find a related model by its primary key or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|Expression|'*'>|model-property|'*' $columns + * @param null|(Closure(): TValue) $callback + * @return ( + * $id is Model ? TRelatedModel|TValue : ( + * $id is (Arrayable>|array) + * ? collection-of|TValue + * : TRelatedModel|TValue + * ) + * ) + */ + public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Execute the query as a "select" statement. + * + * @param array|Expression|'*'> $columns + * @return collection-of + */ + public function get(array $columns = ['*']); + + /** + * Chunk the results of the query. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunk(int $count, callable $callback): bool; + + /** + * Chunk the results of a query by comparing numeric IDs. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; + + /** + * Chunk the results of a query by comparing IDs in descending order. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; +} diff --git a/src/phpstan/stubs/common/Model.stub b/src/phpstan/stubs/common/Model.stub new file mode 100644 index 000000000..429bf0ebe --- /dev/null +++ b/src/phpstan/stubs/common/Model.stub @@ -0,0 +1,88 @@ +, mixed> $attributes + * @param array $options + * @return bool + */ + public function update(array $attributes = [], array $options = []): bool; + + /** + * Fill the model with an array of attributes. + * + * @param array, mixed> $attributes + * @return $this + * + * @throws MassAssignmentException + */ + public function fill(array $attributes): static; + + /** + * Fill the model with an array of attributes. Force mass assignment. + * + * @param array, mixed> $attributes + * @return $this + */ + public function forceFill(array $attributes): static; + + /** @return builder-of */ + public static function query(): Builder; + + /** @return builder-of */ + public function newQuery(): Builder; + + /** @return builder-of */ + public function newModelQuery(): Builder; + + /** @return builder-of */ + public function newQueryWithoutRelationships(): Builder; + + /** @return builder-of */ + public function newQueryWithoutScopes(): Builder; + + /** @return builder-of */ + public function newQueryWithoutScope(Scope|int|string $scope): Builder; + + /** + * @param array|int|string $ids + * @return builder-of + */ + public function newQueryForRestoration(array|int|string $ids): Builder; + + /** + * Begin querying a model with eager loading. + * + * @template TRelation of string + * @param string|array): mixed)|string|array> $relations + * @return builder-of + */ + public static function with(array|string $relations): Builder; + + /** + * Eager load relations on the model. + * + * @template TRelation of string + * @param string|array): mixed)|string|array> $relations + * @return $this + */ + public function load(array|string $relations): static; + + /** + * Eager load relations on the model if they are not already eager loaded. + * + * @template TRelation of string + * @param string|array): mixed)|string|array> $relations + * @return $this + */ + public function loadMissing(array|string $relations): static; +} diff --git a/src/phpstan/stubs/common/Relation.stub b/src/phpstan/stubs/common/Relation.stub new file mode 100644 index 000000000..553203d8b --- /dev/null +++ b/src/phpstan/stubs/common/Relation.stub @@ -0,0 +1,80 @@ + + */ +abstract class Relation +{ + /** + * Execute the query as a "select" statement. + * + * @param array|Expression|'*'> $columns + * @return collection-of + */ + public function get(array $columns = ['*']); + + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return TRelatedModel + * + * @throws ModelNotFoundException + * @throws MultipleRecordsFoundException + */ + public function sole(array|string $columns = ['*']): Model; + + /** + * Add the constraints for a relationship count query. + * + * @param builder-of $query + * @param builder-of $parentQuery + * @return builder-of + */ + public function getRelationExistenceCountQuery(Builder $query, Builder $parentQuery): Builder; + + /** + * Add the constraints for an internal relationship existence query. + * + * Essentially, these queries compare on column names like whereColumn. + * + * @param builder-of $query + * @param builder-of $parentQuery + * @param array|Expression|'*'>|model-property|Expression|'*' $columns + * @return builder-of + */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder; + + /** + * Get the query builder that will contain the relationship constraints. + * + * @return builder-of + */ + protected function getRelationQuery(): Builder; + + /** + * Get the underlying query for the relation. + * + * @return builder-of + */ + public function getQuery(): Builder; +} diff --git a/src/phpstan/stubs/common/SortDirection.stub b/src/phpstan/stubs/common/SortDirection.stub new file mode 100644 index 000000000..b2a15fc89 --- /dev/null +++ b/src/phpstan/stubs/common/SortDirection.stub @@ -0,0 +1,8 @@ + ['Parameter #1 $offset of method Hypervel\Config\Repository::offsetGet() expects int|string, stdClass given.'], + 15 => ['Parameter #1 $offset of method Hypervel\Config\Repository::offsetSet() expects int|string, stdClass given.'], + 18 => ['Parameter #1 $offset of method Hypervel\Cache\Repository::offsetGet() expects string|UnitEnum, stdClass given.'], + 19 => ['Parameter #1 $offset of method Hypervel\Cache\Repository::offsetExists() expects string|UnitEnum, int given.'], + ], + ]; + + // Hypervel: a key that is not a literal may name a property, including in a non-empty array. + yield [ + __DIR__ . '/data/model-property-dynamic-keys.php', + [ + 11 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::forceFill() expects array, array given.'], + ], + ]; + + // Hypervel: a builder-of parameter of an unknown relation model accepts any builder. + yield [__DIR__ . '/data/unknown-relation-model.php']; + + // Hypervel: Builder::__callStatic() registers macros and mixins and runs only the builder's global macros, + // while a model forwards static calls through an instance. + yield [ + __DIR__ . '/data/builder-static-calls.php', + [ + 14 => ['Static call to instance method App\PostBuilder::globalCustomDatabaseQueryMacro().'], + 15 => ['Static call to instance method Hypervel\Database\Eloquent\Builder::flushMacros().'], + ], + ]; + // Hypervel: optional() passes its callback only values that are not null. yield [__DIR__ . '/data/optional-callbacks.php']; diff --git a/tests/PHPStan/Integration/data/builder-static-calls.php b/tests/PHPStan/Integration/data/builder-static-calls.php new file mode 100644 index 000000000..cb564f109 --- /dev/null +++ b/tests/PHPStan/Integration/data/builder-static-calls.php @@ -0,0 +1,16 @@ + 1); + Builder::mixin(new \stdClass); + User::globalCustomDatabaseQueryMacro(); + PostBuilder::globalCustomDatabaseQueryMacro(); + Builder::flushMacros(); +} diff --git a/tests/PHPStan/Integration/data/model-property-dynamic-keys.php b/tests/PHPStan/Integration/data/model-property-dynamic-keys.php new file mode 100644 index 000000000..01ec23653 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-dynamic-keys.php @@ -0,0 +1,12 @@ +forceFill([$column => 'value']); + User::query()->update([$column => 'value']); + $user->forceFill(['missing_column' => 'value']); +} diff --git a/tests/PHPStan/Integration/data/repository-offsets.php b/tests/PHPStan/Integration/data/repository-offsets.php new file mode 100644 index 000000000..44d3f166e --- /dev/null +++ b/tests/PHPStan/Integration/data/repository-offsets.php @@ -0,0 +1,20 @@ +offsetGet('app.name'); + $config->offsetGet(new stdClass()); + $config->offsetSet(new stdClass(), 'value'); + + $cache->offsetGet('key'); + $cache->offsetGet(new stdClass()); + $cache->offsetExists(1); +} diff --git a/tests/PHPStan/Integration/data/unknown-relation-model.php b/tests/PHPStan/Integration/data/unknown-relation-model.php new file mode 100644 index 000000000..69d3f3932 --- /dev/null +++ b/tests/PHPStan/Integration/data/unknown-relation-model.php @@ -0,0 +1,15 @@ + $relation + * @param Builder<\App\User> $query + */ +function existence(Relation $relation, Builder $query): void +{ + $relation->getRelationExistenceCountQuery($query, $query); +} diff --git a/tests/PHPStan/Unit/StubMirrorsTest.php b/tests/PHPStan/Unit/StubMirrorsTest.php new file mode 100644 index 000000000..d465011c2 --- /dev/null +++ b/tests/PHPStan/Unit/StubMirrorsTest.php @@ -0,0 +1,126 @@ +assertSame( + $this->normalize($reflection->getDocComment()), + $this->normalize($this->findStubDocComment($stub, [T_CLASS, T_INTERFACE], $reflection->getShortName())), + ); + } + + /** + * Get the stubs whose class docblock repeats the source's. + * + * @return iterable + */ + public static function classMirrors(): iterable + { + yield 'query builder' => ['Database/Query.stub', QueryBuilder::class]; + yield 'model not found exception' => ['Database/Eloquent.stub', ModelNotFoundException::class]; + yield 'arrayable' => ['Contracts/Support.stub', Arrayable::class]; + yield 'eloquent builder' => ['EloquentBuilder.stub', EloquentBuilder::class]; + yield 'has one or many' => ['HasOneOrMany.stub', HasOneOrMany::class]; + yield 'has one or many through' => ['HasOneOrManyThrough.stub', HasOneOrManyThrough::class]; + yield 'relation' => ['Relation.stub', Relation::class]; + } + + #[Test] + #[DataProvider('methodMirrors')] + public function itRepeatsTheSourceMethodDocblock(string $stub, string $class, string $method): void + { + $this->assertSame( + $this->normalize((new ReflectionMethod($class, $method))->getDocComment()), + $this->normalize($this->findStubDocComment($stub, [T_FUNCTION], $method)), + ); + } + + /** + * Get the stub methods whose docblock repeats the source's. + * + * @return iterable + */ + public static function methodMirrors(): iterable + { + foreach (['offsetExists', 'offsetGet', 'offsetSet', 'offsetUnset'] as $method) { + yield "config {$method}" => ['Config/Repository.stub', ConfigRepository::class, $method]; + yield "cache {$method}" => ['Cache/Repository.stub', CacheRepository::class, $method]; + } + } + + /** + * Find the docblock of a stub declaration by its keyword and name. + * + * @param list $keywords + */ + private function findStubDocComment(string $stub, array $keywords, string $name): ?string + { + $tokens = token_get_all((string) file_get_contents(self::STUBS_PATH . $stub)); + $docComment = null; + + foreach ($tokens as $index => $token) { + if (! is_array($token)) { + $docComment = null; + + continue; + } + + if ($token[0] === T_DOC_COMMENT) { + $docComment = $token[1]; + + continue; + } + + if (in_array($token[0], $keywords, true)) { + $next = $tokens[$index + 2] ?? null; + + if (is_array($next) && $next[0] === T_STRING && $next[1] === $name) { + return $docComment; + } + } + + if (! in_array($token[0], [T_WHITESPACE, T_PUBLIC, T_STATIC, T_ABSTRACT, T_FINAL, T_READONLY], true)) { + $docComment = null; + } + } + + return null; + } + + /** + * Collapse a docblock's whitespace, so indentation and line wrapping do not count. + */ + private function normalize(false|string|null $docComment): string + { + return (string) preg_replace('/\s+/', ' ', (string) $docComment); + } +} diff --git a/tests/PHPStan/Unit/Support/ConfigSourceResolverTest.php b/tests/PHPStan/Unit/Support/ConfigSourceResolverTest.php index 66f17a63c..f6d16158a 100644 --- a/tests/PHPStan/Unit/Support/ConfigSourceResolverTest.php +++ b/tests/PHPStan/Unit/Support/ConfigSourceResolverTest.php @@ -61,6 +61,16 @@ public static function providerWrites(): array ]; } + /** + * An anonymous provider cannot be reflected by name, so what it writes is unknown. + */ + public function testAnonymousProviderWritesAreUnknown(): void + { + $provider = new class(m::mock(Application::class)) extends ServiceProvider {}; + + $this->assertTrue($this->createResolver($provider::class)->hasUnknownWrites()); + } + /** * Named arguments bind by name, creating a callable outside a callback writes nothing, * and a callable created from a read method only reads. From 8a0caa0b73c3a153f60bb0c7900a18a0b4bbe404 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:32:05 +0000 Subject: [PATCH 121/303] Remove obsolete local macro binding suppressions Remove five PHPStan ignores from SoftDeletingScope and SearchableScope now that local Eloquent macro callbacks retain their lexical object and class scope during analysis. These ignores became unmatched after the registration typing correction. Runtime scope behavior is unchanged; full PHPStan passes without the suppressions. --- src/database/src/Eloquent/SoftDeletingScope.php | 3 --- src/scout/src/SearchableScope.php | 2 -- 2 files changed, 5 deletions(-) diff --git a/src/database/src/Eloquent/SoftDeletingScope.php b/src/database/src/Eloquent/SoftDeletingScope.php index 13da16b9e..42d2f55da 100644 --- a/src/database/src/Eloquent/SoftDeletingScope.php +++ b/src/database/src/Eloquent/SoftDeletingScope.php @@ -121,7 +121,6 @@ protected function addWithTrashed(Builder $builder): void return $builder->withoutTrashed(); } - // @phpstan-ignore argument.type (local macros retain their lexical $this at runtime) return $builder->withoutGlobalScope($this); }); } @@ -136,7 +135,6 @@ protected function addWithoutTrashed(Builder $builder): void $builder->macro('withoutTrashed', function (Builder $builder) { $model = $builder->getModel(); - // @phpstan-ignore argument.type (local macros retain their lexical $this at runtime) $builder->withoutGlobalScope($this)->whereNull( $builder->qualifyColumn($model->getQualifiedDeletedAtColumn()) ); @@ -155,7 +153,6 @@ protected function addOnlyTrashed(Builder $builder): void $builder->macro('onlyTrashed', function (Builder $builder) { $model = $builder->getModel(); - // @phpstan-ignore argument.type (local macros retain their lexical $this at runtime) $builder->withoutGlobalScope($this)->whereNotNull( $builder->qualifyColumn($model->getQualifiedDeletedAtColumn()) ); diff --git a/src/scout/src/SearchableScope.php b/src/scout/src/SearchableScope.php index de4748007..ca1b39575 100644 --- a/src/scout/src/SearchableScope.php +++ b/src/scout/src/SearchableScope.php @@ -49,7 +49,6 @@ public function extend(EloquentBuilder $builder): void /* @phpstan-ignore method.notFound (searchable() macro is registered by the Searchable trait) */ $searchableModels->searchable(); - // @phpstan-ignore staticMethod.notFound (local macros retain their lexical class scope at runtime) static::dispatchEvent(ModelsImported::class, $models); Scout::reportImportProgress($models); }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); @@ -66,7 +65,6 @@ public function extend(EloquentBuilder $builder): void /* @phpstan-ignore method.notFound (unsearchable() macro is registered by the Searchable trait) */ $models->unsearchable(); - // @phpstan-ignore staticMethod.notFound (local macros retain their lexical class scope at runtime) static::dispatchEvent(ModelsFlushed::class, $models); }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); }); From 62254c6ec344444e1053b4cc5ca76c2261886f91 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:32:05 +0000 Subject: [PATCH 122/303] Record the reviewed Eloquent forwarding checkpoint Document the completed builder and relation flow and its intentional differences from Larastan, including dispatch, custom declarations, uncertain model types and macro registration. Update the remaining port checklist and make the outstanding upstream Integration cases the next checkpoint. Retain the final consumer migration, cache correctness and analysis-cost verification obligations, and keep implementation details beside their code. --- .../2026-11-04-hypervel-phpstan-checklist.md | 20 ++++++++----------- docs/plans/2026-11-04-hypervel-phpstan.md | 14 +++++++------ src/phpstan/README.md | 6 ++++++ 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index e060c89f6..388b6cc51 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -20,20 +20,16 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## src -- copy: `ClosureTypes/RelationshipQueryCallbackExtension.php` - copy with the queue rules: `Concerns/InspectsQueuedJobs.php` - omit: `Internal/ComposerHelper.php` (`Support\Composer::vendorPath()` resolves the actual install) -- copy: `Methods/{BuilderHelper,ModelFactoryMethodsClassReflectionExtension}.php` -- merge into moved forwarding extensions: `Methods/{EloquentBuilderForwardsCallsExtension,ModelForwardsCallsExtension,RelationForwardsCallsExtension}.php` +- copy: `Methods/ModelFactoryMethodsClassReflectionExtension.php` - omit: `Properties/ReflectionTypeContainer.php` (unused since upstream removed container ArrayAccess support) -- copy: `Reflection/{AnnotationScopeMethodParameterReflection,AnnotationScopeMethodReflection,DynamicWhereParameterReflection,EloquentBuilderMethodReflection}.php` -- merge into moved scope reflections: `Reflection/ModelScopeMethodReflection.php` -- copy: `ReturnTypes/{EloquentBuilderExtension,FactoryDynamicMethodReturnTypeExtension,ModelDynamicStaticMethodReturnTypeExtension,ModelFactoryDynamicStaticMethodReturnTypeExtension,ModelOnlyDynamicMethodReturnTypeExtension,RelationCollectionExtension}.php` -- copy with the Eloquent collection port: `ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php`, moving `common/EloquentCollection.stub`'s `map()`/`mapWithKeys()` conditionals into the Eloquent `Collection` source +- copy: `ReturnTypes/{FactoryDynamicMethodReturnTypeExtension,ModelFactoryDynamicStaticMethodReturnTypeExtension}.php` +- copy with the Eloquent collection port: `ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php`, moving `common/EloquentCollection.stub`'s `map()`/`mapWithKeys()` conditionals into the Eloquent `Collection` source; also `toQuery()`/`load()`/`loadMissing()` typing with `Type/data/eager-loading-callbacks.php` intact (investigate the supported PHPStan hooks before choosing a mechanism) - copy: `Rules/*` except below and the ported `ArrKeyExistenceRule`, auth request-scope, config, console, `NoEnvCallsOutsideOfConfigRule`, `NoMissingTranslationsRule`, `NoUnnecessaryEnumerableToArrayCallsRule`, `UnusedViewsRule` and `UselessConstructs/` rules (including `Queue/`) - omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` - omit: `SQL/*` (`hypervel/schema-parser` replaces the parser boundary and its iamcal/phpMyAdmin adapters) -- copy: `Types/*` except the ported `AbortIfFunctionTypeSpecifyingExtension`, `ArrPullParameterOutTypeExtension`, `ModelProperty/` and `ViewString*` files +- copy: `Types/Factory/` with the factory flow; `Types/GenericEloquentCollectionTypeNodeResolverExtension.php` with the Eloquent collection port ## New Hypervel source @@ -43,7 +39,8 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## stubs -- copy: `common/*` except below, the ported view stubs, `Foundation/Console/Kernel.stub` (its `command()` closure type moved to source; the stub keeps only the empty declaration the `Artisan` mixin needs for stub validation) and the stubs whose types are now in source: `Collection` (except below), `Conditionable`, `Enumerable`, `EnumeratesValues`, `HigherOrderProxies` and `Support/{Arr,Benchmark,Reflector,Str,Tappable}` (60 files) +- copy with their topic flows: `common/{Attribute,Console/{Command,HasParameters},Contracts/{Cache,Container,Kernel,Mail,Queue,UrlGenerator},Cookie/SymfonyCookie,Database/Eloquent/Concerns/{HasAttributes,HasEvents,HasRelationships},Database/{ManagesTransactions,Migrations/Migrator},EloquentCollection,Events/Dispatcher,Factory,Http/RedirectResponse,Macroable,Notifications/Notification,Routing/{Redirector,UrlGenerator},Session/SessionManager,Support/{HtmlString,Manager,Optional},ThirdParty,Validation/Validator}.stub`, except below; `MorphTo`, `MorphToMany`, `Pagination` and `Contracts/Pagination` need none, because the source declares their types +- stubs repeat the source's native signatures except returns that name a collection or paginator, whose stubs fail stub validation (variance); `ModelPropertyColumnRule` checks the pagination and `pluck()` columns that upstream's stubs type - merge into `common/Helpers.stub` with their topics: the `cookie()`, `logger()`, `logs()`, `redirect()`, `request()`, `session()` and `url()` helper entries, whose conditional returns the source already declares (only `view()` is ported; `optional()` is typed in source and `OptionalExtension`; the stub records the omitted helpers) - merge with their topic flows: the upstream typing for the declaration-only stubs that `common/Facades.stub`'s mixins need for stub validation: Events `dispatch()`/`until()` conditionals, `Connection::getQueryLog()` shape, Schema `getColumnListing()`, and the Cache `Repository` `ArrayAccess` implements (check Hypervel's enum keys) - add to source with the routing stubs: upstream `common/Route.stub`'s `methods()` and `middleware()` element types as `Route` PHPDoc in `src/routing`, with a `types/` fixture (the stub keeps only the empty declaration stub validation needs) @@ -51,14 +48,13 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - omit: `common/Redis/*.stub` and `common/Contracts/Redis.stub` (the source tags, `MultiExec` and mixins type the pooled surface; `@mixin \Redis` would drop the cluster unions and expose connection-bound methods), `common/Gate.stub` (Hypervel's `Gate::forUser()` and its contract already return `static`) - add to source: upstream `common/MailMessage.stub`'s property shapes (`$view`, `$from`, `$replyTo`, `$attachments`, `$rawAttachments`) as `MailMessage` PHPDoc in `src/notifications`, with a `types/` fixture - omit: `common/Contracts/Translation.stub` (only upstream's omitted `trans()` helper stub references it) -- merge into `common/`: `13.3.0/{EloquentBuilder,Model}.stub`, `13.35.0/{BelongsToMany,HasOneOrMany}.stub`, `13.37.0/HasOneOrManyThrough.stub` ## tests - omit: `laravel-test.sh` -- copy with the Eloquent builder/relation ports: `application/app/{BareRelations/,ChildUser.php,PostComment.php}`; with the Eloquent collection port: `application/app/UserCollection.php`; with the factory flow: `application/app/{Domain/,Shapes/}` and `application/database/factories/` +- copy with the Eloquent collection port: `application/app/UserCollection.php`; with the factory flow: `application/app/{Domain/,Shapes/}` and `application/database/factories/` - omit: `application/app/{ModelWithConflictingInitializer.php,UserResource.php,Providers/}` (no upstream test or source references them) -- merge into `Integration/IntegrationTest.php`: the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties +- next checkpoint, with the source corrections it exposes: merge into `Integration/IntegrationTest.php` the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties - copy: `Rules/*Test.php` except below and the ported `ArrKeyExistence`, auth, config, console, environment, enumerable `toArray()`, view, translation and useless-construct rule tests, the remaining `Rules/data/*`, `Rules/Queue/data/*` - omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` - copy: `Type/*Test.php` except below, the ported collection, config and model-property tests, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php`, with the remaining `Type/data/*` diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 4fea72173..4eaab38f5 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -24,7 +24,8 @@ Remaining work: - Also completed: the request, environment and storage flow, Redis command casing and the connection-bound Redis rule, with their source fixes and tests (see Requests, environment and storage). - Also completed and reviewed: the model-properties/schema flow, including schema-parser impact, casts/accessors, fetched types, schema and forwarding dependencies, the schema-dump diagnostic, the routing and SQLite JSON fixes, Attribute variance, inherited method-template declarations, tests and documentation. - Also completed: the approved `final` cleanup in the PHPStan and schema-parser packages and branch-added fixtures. `readonly` is preserved; the `Comment`/`Comments` type fixtures are unchanged. -- Next: port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules, and `ClosureTypes/` with the Eloquent builder/relation forwarding. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension` and the model-dependent collection fixtures listed in the checklist. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. +- Also completed: the Eloquent builder and relation forwarding flow (`BuilderHelper`, the `builder-of`, `collection-of` and `relation-of` types, `ClosureTypes/`, the forwarding return types, the 13.x stub merges, dynamic wheres and stub-validation compliance), with its type and integration fixtures. +- Next: merge the remaining upstream Integration entries (checklist), with the source corrections they expose. Then port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension`, the `toQuery()`/`load()`/`loadMissing()` typing with `eager-loading-callbacks.php`, and the model-dependent collection fixtures listed in the checklist. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -237,12 +238,13 @@ Port every applicable upstream inference feature, not just the rules in the next | Area | Required adaptation and coverage | |---|---| | Models | Native declarations and explicit PHPDoc take precedence appropriately; infer schema properties, relations, accessors, casts, separate read/write types, nullability, custom collections, factories and serialization shapes via `ModelSerializationDynamicMethodReturnTypeExtension`. Honor Hypervel attributes, visibility/appends and inheritance; Hypervel instantiates `CollectedBy`, so test a named `collectionClass:` argument and a parent's attribute. Unknown schema knowledge must not erase declared properties or known relationships. | -| Builder/relation forwarding | Preserve model generics, custom builders, relation-specific fluent returns, attributed scopes, SoftDeletes/Scout and macro callbacks. Merge existing Hypervel capabilities with applicable upstream cases. | +| Builder/relation forwarding | Preserve model generics, custom builders, relation-specific fluent returns, attributed scopes, SoftDeletes/Scout and macro callbacks; merge existing Hypervel capabilities with applicable upstream cases. **Builder selection** (`BuilderHelper`): when the model's `newQuery()` comes from `HasBuilder`, its `@use` type, even when another trait's `newEloquentBuilder()` wins the conflict (Nested Set's `HasNode`); otherwise `Model::newEloquentBuilder()`'s order: an override's declared return, a `#[UseEloquentBuilder]` `Builder` subclass (class, its traits, then parents; the argument read as instantiation evaluates it, including class constants), then the `$builder` default, which upstream ignores. A missing model class has no builder. The called model is bound through the builder's `setModel()` return, rebinding only the template mapped to `Builder`'s `TModel`. The Model stub types the `query()`/`newQuery()` family as `builder-of`; subclass overrides and other declared returns keep their declarations, so a callback's result, such as `withoutEvents()`'s, is not the called model's builder. **Dispatch** follows `Builder::__call()`: native methods; `macro()` registration, which returns null; local macros; global macros; named scopes (a real method only: a tag-only `@method scopeX` creates none); passthru; then query forwarding to native query methods, query macros, and any other `where…` name as a dynamic where. Eloquent discards forwarded results, except passthru, which returns the raw `toBase()` query with its declared rows. Forwarded methods are instance-only, because `Builder::__callStatic()` runs only the builder's global macros. It also accepts `macro()` and `mixin()`, which register global macros and return null, so `MacroRegistrationMethodReflection` describes both as static, without the query's closure binding: a local macro keeps its own `$this`. `MacroRegistrationExtension` binds a static `macro()` callback to the builder and returns the builder from an instance `mixin()`, which reaches the query's. Method existence must not depend on the model, or PHPStan reports a generic subclass's call as private, so `ModelPropertyColumnRule` reports a dynamic where's unknown column, with PHPStan's undefined-method message and identifier. Forwarded signatures name the model by the receiver's template, keeping late-static models. A non-union intersection resolves the model's scopes, then interface `scopeX` declarations. Through a relation, a builder method declared to return `static` or `$this` returns the relation, except `applyScopes()`, `clone()`, `cloneWithout()` and `cloneWithoutBindings()`; relations reach global builder macros through their `@mixin`, and `QueriesRelationships` drops Laravel's circular `@mixin Builder`. Ordinary forwarding uses `getQuery()`'s return, intersected with `toBase()`'s while `toBase()` is `Builder`'s own; passthru uses a native `toBase()` override's return, else the ordinary type. Dependencies cover the model hierarchy's files for `$builder`, both query classes and a class constant a builder or collection attribute reads. **Uncertainty**: `builder-of`/`relation-of` give the declaring model's builder only without a path. A path, or a model alternative at any path segment, without a resolvable relation is unknown, since a subclass or `resolveRelationUsing()` can supply it, and is kept beside resolved alternatives. An unknown model has `Builder<*>`, `Relation<*, *, *>` or the base model's collection rather than upstream's `never`. `HasOneOrManyThrough::getRelationExistenceQuery()` types its parent query as the far parent's builder. **Collections**: only framework builder and relation implementations get the model's collection class; custom overrides keep their declarations. Paginators carry the stored collection as a third, defaulted `TCollection` template that `getCollection()` returns, `setCollection()` updates and `through()` widens. Builder and relation `paginate()`-family returns name `EloquentCollection` in source, replaced with the model's collection; hand-built paginators keep the base collection, and upstream's model-value `getCollection()` conditional is omitted. `CursorPaginator` takes `array-key`-keyed input (`setItems()` renumbers) and keeps `TKey`/`TValue` required; a direct `new CursorPaginator(...)` is `CursorPaginator>`. `only()` keeps one key shape per first-argument alternative, a later multi-key argument makes keys optional, and an unknown key reads `mixed`. | | Helpers and collections | Preserve conditional argument behavior, `Arr` shapes, `filter()` narrowing, higher-order proxy callbacks and generic collections. Compare inherited annotations before adding a stub. Keep Laravel's covariant collection `TValue` rather than upstream's invariant stubs: invariance rejects native `Collection` callback hints, `Collection` parameters given narrower collections and fresh-producer returns, and call-site `covariant` projections only cover read-only inputs. PHPStan 2.3.5's bleeding edge does not widen an already inferred covariant `TValue`, so upstream's bleeding-edge value-widening cases are recorded as `REMOVED:`; the docs give the `@var` remedy. | | Authentication | One shared guard-type helper serves `Auth::guard()`, `Factory::guard()` and `auth($guard)`, following `AuthManager::resolve()`: a booted custom creator's declared return (`ClosureTypeFactory`, never invoked), else the booted manager's `create{Driver}Driver()` declaration, else the declared return. `viaRequest()`'s creator declares `RequestGuard`, so request guards are typed; an untyped creator keeps the declared return. Never claim `StatefulGuard` for an unmodelled guard: only `SessionGuard` is stateful, and token/request/Sanctum/JWT/Passport guards are not. An omitted, null or empty name is the union of every configured guard's type, because `shouldUse()` changes the default per coroutine; so is an unknown or unpacked name, because resolving an unconfigured name throws. A known set of string or enum names gives the union of those guards. `auth()` and `auth(null)` type as the booted factory concrete; unlike `guard(null)`, a maybe-null or unpacked `auth()` argument can return the factory, so it keeps the declared conditional. Guard names come only from `guard()` on the factory or facade, the global `auth()` (resolved function identity) and `Request::user()`; enum cases map like `enum_value()` where the signature accepts enums. User models are provider-based inference: an Eloquent provider gives its model for any guard driver; a database provider gives `GenericUser` only for the stock session/token drivers (Sanctum accepts session-guard users when its provider has no `getModel()`). A guard without a modelled provider, or one whose provider driver has a custom creator, keeps the call's declaration. Preserve narrower declarations; manager and facade `user()` calls are typed only when they forward the `Guard` contract's method. Omit `AuthsMethodsExtension`: `AuthManager`'s mixins already forward guard methods, and contracts do not gain implementation methods. | | Console commands | Argument/option types and the undefined-input rule read the booted kernel's commands (`$kernel->all()`: `withCommands()` discovery, `command()` closures and providers' `starting()` registrations; a standalone application is the fallback when the kernel cannot list them). Look up the call's receiver, not the enclosing class; exactly `Command` and `ClosureCommand` receivers keep the declaration, because they name every command or unrelated per-instance signatures. Merge each matched command's application definition once at lookup, as `run()` does, so bulk shapes include `command` and the global options regardless of analysis order. A name lookup is the union over matching commands that define it (missing ones throw), else the declaration; option names follow `Input`, so negations resolve to their option and shortcuts are not names. Aliases list a command once. A definitely-null key is a bulk lookup; overridden methods keep their declarations for typing and diagnostics. The rule skips a call whose same-receiver `hasArgument()`/`hasOption()` guard is definitely true or unreachable in scope, because the call never reaches the commands without the name; Components' `GeneratorCommand` and `CreatesMatchingTest` guard `--force` this way. Scope keys the guard as written, so check its positional and `name:` forms built from the getter's value, never the getter's own argument nodes. A receiver union with an alternative that has no known definition (e.g. `FooCommand|ClosureCommand`) keeps the declaration. `@param-closure-this` for `command()` lives on the Kernel contract and Foundation Kernel. | | Views and translations | Retain `view-string` and live view-finder namespaces. Keep translation typing and the adapted missing-key rule. The optional unused-view rule reports absent statically discovered usage, not proof that a view can be deleted. | -| Custom PHPDoc types | Port `model-property`, `relation-of`, `builder-of`, `collection-of`, `view-string`, their parsers, templates and applicable tests. Keep these analyzer-only annotations in stubs where adding them to framework source would require this dev package. | +| Custom PHPDoc types | Port `model-property`, `relation-of`, `builder-of`, `collection-of`, `view-string`, their parsers, templates and applicable tests. Keep these analyzer-only annotations in stubs where adding them to framework source would require this dev package. Unlike upstream, the late-resolvable types compose their position variance with invariant, so a `*` argument in a parameter resolves as it does in the generic class it describes instead of to `never`. `model-property` may be a supertype of an arbitrary string, as `accepts()` allows, because PHPStan rechecks an accepted intersection through `isSuperTypeOf()`. | +| Stub validation | On a full analysis PHPStan validates stubs outside the project's vendor directory (this repo, and symlinked path-repository installs such as the owner's apps) against the stubs alone, and its errors cannot be ignored. Stubs therefore declare every class they reference: an empty declaration for a non-generic one (throwables with their real parent chain), and for a generic one its verbatim source class docblock, matching imports and native `extends`/`implements`, with no methods unless an analyzer override or validation needs them (the Query `Builder`, `ModelNotFoundException`, `Arrayable`). A class declared in a stub takes the nearest ancestor stub's PHPDoc for a method its own stub omits, including PHPStan's `ArrayAccess`/`IteratorAggregate`/`Countable` stubs (`PhpClassReflectionExtension::findMethodPhpDocIncludingAncestors()`), so a stub also declares, verbatim, each such method whose own source docblock that would replace (the Config and Cache `Repository` `offset*()` methods, with a diagnostic fixture for their key types). The support and Eloquent collections and the paginators cannot be declared: their verbatim docblocks fail validation (covariant `TValue` in `ArrayAccess`/`ArrayIterator`, accepted in source only through the `generics.variance` ignore), and editing them would replace the source declarations. Stubs therefore do not reference them: `BelongsToMany`'s `TResult` is `collection-of` (its results come from the related model's `newCollection()`), and the `paginate()` family and `pluck()` stay unstubbed, their returns coming from source through `EloquentBuilderExtension`/`RelationCollectionExtension`, while `ModelPropertyColumnRule` (enabled by `checkModelProperties`) checks their column arguments as the stubs' `model-property` types did, for builder, model-forwarded and relation-forwarded calls. A stub class docblock replaces the source's, and a stub method docblock replaces the source method's, so a stubbed method repeats the source's native signature and every source PHPDoc tag besides its analyzer-only refinements. `StubMirrorsTest` compares an explicit mapping of the verbatim class docblocks (the declaration mirrors and the Eloquent builder/relation stubs except `BelongsToMany`) and mirrored method docblocks with source; stubs whose class docblocks intentionally differ, such as the facade mixins and `BelongsToMany`, are not mapped. Extension code reached while validating stubs must not assume project reflection, such as methods the stubs omit; an unreflectable provider counts as unknown writes, which also covers anonymous providers. | | Dates and environment | Keep native/source `CarbonInterface` boundaries for configurable dates and exact immutable types for framework-owned values. Do not infer configurable dates from `get_class(now())`, or assume an `env()` default dictates the supplied environment value's type. `env()` and `Env::get()` declare `bool|string|TDefault|null` in source PHPDoc (`TDefault` from a value or `Closure` default; native signatures unchanged), matching `Env::getOption()` and `value()`; no stub or return-type extension. | Do not copy `ContractsMethodsExtension`, container ArrayAccess/dynamic-property support, Lumen paths, deferred-provider machinery, or unsupported Laravel integrations. Remove compatibility branches for old Laravel releases, but first merge applicable newer-version stub refinements into the single supported Hypervel surface. Deleting version directories without examining their overrides would lose features. @@ -326,7 +328,7 @@ Runtime fix: SQLite fetches a JSON number from a `json`/`jsonb` column as an int - A connection is incomplete when its dump cannot be read, throws `ParseException` (the PostgreSQL parser rejects `PGDMP` custom archives), has schema-level issues, or its driver has no parser. Schema-level issues also make that input's tables incomplete and its types uncertain, as for raw SQL. A relation that two dumps of one owner both define is unknown; a type they define differently is uncertain, and a later dump does not restore it. An owner with no schema source (no dump and no interpreted statement) is unknown, not proven empty. - A table is incomplete when it has issues, is a view (no column facts), is ambiguous, or is affected by the migration-side cases below. - Known columns keep their types and casts unless a migration-side case makes them uncertain. An incomplete dump table that the same dump alters (`Impact::$relations`) is unknown: the parser could not apply that alteration, so a kept column may be gone or retyped. An unknown column on an incomplete table or connection gets no missing-column claim, while complete tables still report one. - - With no established facts for the model's table, an undeclared name is maybe an attribute: the property extension defers to a declared relation (or its `_count`) before assuming one. A known cast key is an attribute without a column, as `HasAttributes::hasAttribute()` accepts it; undetermined method casts do not make every name one. + - With no established facts for the model's table, an undeclared name is maybe an attribute: the property extension defers to a declared relation (or its `_count`) before assuming one. A model runtime reflection cannot instantiate has no established facts either. Neither has an abstract model (including `Model` and template bounds), but the property extension does not claim its names: a model trait's `@mixin Model` (Scout's `Searchable`) would then give every model every property. `ModelPropertyHelper::acceptsPropertyName()` instead accepts any name for an abstract model, evaluating union alternatives separately (intersections whole) and keeping the concrete alternatives' results; `GenericModelPropertyType::accepts()`/`isSuperTypeOf()` and dynamic `where{Column}` use it. Only a complete known schema rejects a column. A known cast key is an attribute without a column, as `HasAttributes::hasAttribute()` accepts it; undetermined method casts do not make every name one. - **Uninterpretable migrations.** A construct the aggregator cannot interpret makes the facts it could affect unknown, types included: an unknown Blueprint method (macros), a helper or other escape of `$table`, a non-constant column name in a `change()`, loops and other control flow make the table unknown; an added column with a non-constant name makes the table incomplete, keeping its known columns; an unknown table argument makes the owner unknown; an unknown connection, or a migration file that does not parse (upstream skipped it), makes every owner unknown. Do not build a PHP interpreter. - Facts come only from code applied as executed: `up()`, the migration's own methods it calls (after their arguments, which are searched), Blueprint callbacks (including `after()`), `withoutForeignKeyConstraints()` and `whenTable(Doesnt)Has(Column|Index)()` (the column guard is decided like `hasColumn()`; index guards join both outcomes). Other code, including closures and helpers in nested positions, is searched for Schema and DB calls, which only make their targets unknown. A first-class callable created as a statement runs nothing; a kept one (assigned or passed) makes its targets unknown, since it can run later with any arguments. - `removeColumn()` only cancels the definitions its Blueprint queued, so its column becomes uncertain (never a drop). A non-literal `unsigned` makes only that MySQL column's type unknown. @@ -428,7 +430,7 @@ Copy retained rule files first, then adapt them. Retain their relevant toggles a | `Queue/JobWithModelPropertyDeclaresSerializesModelsRule` | Include protected/private/nullable model state and supported model collections. Recognize inherited serialization and valid custom `__serialize()`/`__unserialize()` behavior. | | `Queue/NoBatchedUniqueJobRule` | Cover the verified batch/bulk paths that bypass uniqueness acquisition, with an accurate explanation. Do not generalize to dispatch paths that acquire locks normally. | | `Queue/UniqueJobDeclaresUniqueIdRule` | Require explicit identity for parameterized unique jobs; permit intentional class-wide identity through an explicit constant-returning method. Do not invent an identity or include secrets in diagnostics. | -| `RelationExistenceRule` | Preserve relation existence checking for loading/query APIs, including nested paths, selected columns, polymorphism and dynamic uncertainty. | +| `RelationExistenceRule` | Preserve relation existence checking for loading/query APIs, including nested paths, selected columns, polymorphism and dynamic uncertainty. A name with a booted dynamic relation (`$model->relationResolver($class, $name)` on the model instance, inherited resolvers included) exists; read the registry only, never invoke the resolver. | | `UselessConstructs/NoUselessValueFunctionCallsRule` | Resolve parameters correctly and preserve all argument evaluation effects; spread arguments are not ordinary positional values. | | `UselessConstructs/NoUselessWithFunctionCallsRule` | Same argument/effect requirements; reordered named callback/value arguments are valid. | | `DeferrableServiceProviderMissingProvidesRule` | Omit: no deferred-provider surface. | @@ -589,7 +591,7 @@ For e2e baselines, preserve known intentional analyzer limitations with reasons - Retain the deprecated validation contracts and audit their necessary compatibility usages as specified below. - Convert declarations flagged by the default authoring rules in Components source, skeletons, generator stubs, private packages, apps and documentation to the recommended forms, preserving runtime behavior and inherited precedence. Complete the chain/fake dispatch fixes before converting queue settings. This includes Scout's fixed job options and unique expiry, framework model defaults and the queued-listener `$uniqueFor` example in `src/docs/events.md`, which should use `#[UniqueFor(3600)]`. Keep compatibility type/rule fixtures where they test supported APIs; use a reasoned local ignore only for a necessary compatibility case, never a baseline or blanket policy disablement. -- Audit `packages/hypervel`, `packages/hypervel-dev` and affected `apps/` under `/home/binaryfire/workspace/monorepo`. Both private package configs currently override unmatched-ignore reporting to false. Add the analyzer as a dev dependency with Composer, update source/type/generated analysis configs where applicable, supply package Testbench configuration, and remove obsolete direct-extension includes/scans. +- Audit `packages/hypervel`, `packages/hypervel-dev` and affected `apps/` under `/home/binaryfire/workspace/monorepo`. Both private package configs currently override unmatched-ignore reporting to false. Add the analyzer as a dev dependency with Composer, update source/type/generated analysis configs where applicable, supply package Testbench configuration, and remove obsolete direct-extension includes/scans. Narrow `packages/hypervel/tests/Workflow/WorkflowPartitionTest.php`'s `incrementQuietlyForTest()`/`incrementEachQuietlyForTest()` returns to `int`, matching `Model`'s quiet operations. - Private Composer repositories currently point at the main Components `src/*`. Validate against this worktree through a task-local Composer setup with its own installed dependencies, without permanently committing a worktree-specific path or disrupting another workload's vendor directory. Final private manifests reference the intended durable package source/version. - Audit every suppression affected by improved inference or the new ignore policy in Components, both private packages, skeletons and affected apps. Convert every reason-less identifier ignore and every blanket line/next-line ignore in their analyzed paths to a source fix or an accurate identifier-specific ignore with a parenthesized reason. Do not limit this to newly ported files or errors that became unmatched. Remove obsolete inline ignores/global patterns and correct stale explanations such as claiming a boot-registered macro is unknowable. Retain narrowly justified limitations; new broad ignores require the normal owner approval. - Audit deprecated-API usage in those same consumers, including calls to PHP, Symfony, Carbon and other vendor APIs, not just Hypervel declarations. Use the supported replacement when it preserves the contract. Keep a reasoned identifier-specific ignore when deprecated usage remains necessary, including compatibility with a still-supported public API. Do not remove supported APIs, strip truthful deprecation tags, mark unrelated code deprecated to silence errors, or add runtime workarounds merely to satisfy analysis. Dependency defects follow the normal owner decision workflow. diff --git a/src/phpstan/README.md b/src/phpstan/README.md index eed0a4c54..03ee8c1d0 100644 --- a/src/phpstan/README.md +++ b/src/phpstan/README.md @@ -18,5 +18,11 @@ Documentation: https://hypervel.org/docs/static-analysis - Request input getters return the types the input can hold. A keyed `query()`, `post()` or `cookie()` value can be a string, number, boolean, array or `Stringable`, a keyed `server()` or `route()` value is `mixed`, and `header()` can return null even with a default. Whole query, post, cookie and file arrays have `array-key` keys, because a query string or JSON list body can use integer keys. Upstream's narrower types do not cover these values. `FormRequest::validated()` and `safe($keys)` return `array` for the same reason. - `Storage::disk()` and `FilesystemManager::disk()` are typed per disk from the booted configuration, such as `ClientPooledFilesystem` for a pooled S3 disk, instead of always `FilesystemAdapter`. Calls the `Storage` facade forwards to the default disk use that disk's methods. `Storage::cloud()` is not supported. - Connection-bound Redis methods such as `client()` and `auth()` are reported when called through `Redis::connection()` or the manager, because the pooled proxy rejects them. Call them on a held connection from `Redis::withConnection()`. +- A builder method called through a relation returns the relation only when it is declared to return `static` or `$this`, as a relation returns itself when the builder returns itself. Upstream does so for any builder result. `static` is treated as the fluent convention, not proof of identity, so a custom `static` method that returns a clone is still typed as the relation; declare `$this` where identity matters. Builder's own `clone()`, `cloneWithout()`, `cloneWithoutBindings()` and `applyScopes()` return the builder. +- A `@method scopeActive()` tag does not add an `active()` scope, because Eloquent only calls scope methods that exist. Upstream adds one. +- A dynamic where, such as `whereEmail()`, that names an unknown column is still reported as an undefined method, with the column in a tip. Unlike upstream, the call keeps the builder or relation type rather than becoming an error type. +- Calls an Eloquent builder forwards to its query follow `Builder::__call()`. A query macro returns the builder, or the relation it was called through, instead of the macro's result. A passthru method such as `dumpRawSql()` returns the raw query, whose rows are not models. `macro()` and a static `mixin()` return null, and a forwarded query method cannot be called statically on a builder class. Upstream keeps the query macro's and passthru results, returns the builder from both registration methods, and allows those static calls. A local macro keeps its own `$this`, while `Builder::macro()` binds a global macro to the builder. Upstream binds both as the query's `macro()` declares. +- A relation path is unknown when any model in a union lacks the relation, because a subclass or `resolveRelationUsing()` can supply it. `builder-of` and `collection-of` of an unknown model give `Builder<*>` and the base model collection. Upstream drops that model, or gives `never`. +- A custom builder's or relation's override of a method such as `get()` keeps its declared collection type. Upstream replaces it with the model's collection class. Ported from: https://github.com/larastan/larastan From d5f29fa520d624e06d9065f92bc3eb581d9c5b17 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:48:14 +0000 Subject: [PATCH 123/303] Restore fluent query builder identity annotations Restore Laravel return-this annotations on the query and Eloquent builders, relationship query concern and join clause. Keep native static return types while expressing that fluent calls preserve the receiving object, including subclass parent calls. Review covered each affected return path. The combined checkpoint passes formatting, all three PHPStan configurations and the analyzer test suite. --- src/database/src/Eloquent/Builder.php | 37 ++- .../Concerns/QueriesRelationships.php | 54 ++++ src/database/src/Query/Builder.php | 291 ++++++++++++++++++ src/database/src/Query/JoinClause.php | 2 + 4 files changed, 383 insertions(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 3e9abd44c..f3b1544d7 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -198,6 +198,8 @@ public function make(array $attributes = []): Model /** * Register a new global scope. + * + * @return $this */ public function withGlobalScope(int|string $identifier, Closure|Scope $scope): static { @@ -212,6 +214,8 @@ public function withGlobalScope(int|string $identifier, Closure|Scope $scope): s /** * Remove a registered global scope. + * + * @return $this */ public function withoutGlobalScope(Scope|int|string $scope): static { @@ -228,6 +232,8 @@ public function withoutGlobalScope(Scope|int|string $scope): static /** * Remove all or passed registered global scopes. + * + * @return $this */ public function withoutGlobalScopes(?array $scopes = null): static { @@ -244,6 +250,8 @@ public function withoutGlobalScopes(?array $scopes = null): static /** * Remove all global scopes except the given scopes. + * + * @return $this */ public function withoutGlobalScopesExcept(array $scopes = []): static { @@ -381,6 +389,7 @@ public function firstWhere(array|Closure|self|QueryBuilder|Relation|Expression|s * makes a callback a subquery, so the callback type cannot follow the operator here. * * @param array|(Closure(__benevolent): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @return $this */ public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static { @@ -397,6 +406,7 @@ public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|stri * Add a basic "where not" clause to the query. * * @param array|(Closure(($operator is null ? static : QueryBuilder)): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @return $this */ public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -413,6 +423,7 @@ public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|str * Add an "or where not" clause to the query. * * @param array|(Closure(($operator is null ? static : QueryBuilder)): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @return $this */ public function orWhereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static { @@ -421,6 +432,8 @@ public function orWhereNot(array|Closure|self|QueryBuilder|Relation|Expression|s /** * Add an "order by" clause for a timestamp to the query. + * + * @return $this */ public function latest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static { @@ -439,6 +452,8 @@ public function latest(Closure|self|QueryBuilder|Relation|Expression|string|null /** * Add an "order by" clause for a timestamp to the query. + * + * @return $this */ public function oldest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static { @@ -770,7 +785,7 @@ public function incrementOrCreate(array $attributes, string $column = 'count', f { return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function ($instance) use ($column, $step, $extra) { if (! $instance->wasRecentlyCreated) { - $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + $instance->increment($column, $step, $extra); } }); } @@ -1015,6 +1030,8 @@ protected function isNestedUnder(string $relation, string $name): bool /** * Register a closure to be invoked after the query is executed. + * + * @return $this */ public function afterQuery(Closure $callback): static { @@ -1726,6 +1743,7 @@ protected function createNestedWhere(array $whereSlice, string $boolean = 'and') * * @param array): mixed)|string>|string $relations * @param (Closure(Relation<*,*,*>): mixed)|string|null $callback + * @return $this */ public function with(array|string $relations, Closure|string|null $callback = null): static { @@ -1742,6 +1760,8 @@ public function with(array|string $relations, Closure|string|null $callback = nu /** * Prevent the specified relations from being eager loaded. + * + * @return $this */ public function without(mixed $relations): static { @@ -1756,6 +1776,7 @@ public function without(mixed $relations): static * Set the relationships that should be eager loaded while removing any previously added eager loading specifications. * * @param array): mixed)|string>|string $relations + * @return $this */ public function withOnly(array|string $relations): static { @@ -1916,6 +1937,8 @@ protected function addNestedWiths(string $name, array $results): array * Specify attributes that should be added to any new models created by this builder. * * The given key / value pairs will also be added as where conditions to the query. + * + * @return $this */ public function withAttributes(Expression|array|string $attributes, mixed $value = null, bool $asConditions = true): static { @@ -1936,6 +1959,8 @@ public function withAttributes(Expression|array|string $attributes, mixed $value /** * Apply query-time casts to the model instance. + * + * @return $this */ public function withCasts(array $casts): static { @@ -1979,6 +2004,8 @@ public function getQuery(): QueryBuilder /** * Set the underlying query builder instance. + * + * @return $this */ public function setQuery(QueryBuilder $query): static { @@ -2005,6 +2032,8 @@ public function getEagerLoads(): array /** * Set the relationships being eagerly loaded. + * + * @return $this */ public function setEagerLoads(array $eagerLoad): static { @@ -2015,6 +2044,8 @@ public function setEagerLoads(array $eagerLoad): static /** * Indicate that the given relationships should not be eagerly loaded. + * + * @return $this */ public function withoutEagerLoad(array $relations): static { @@ -2025,6 +2056,8 @@ public function withoutEagerLoad(array $relations): static /** * Flush the relationships being eagerly loaded. + * + * @return $this */ public function withoutEagerLoads(): static { @@ -2315,6 +2348,8 @@ public function cloneWithoutBindings(array $except): static /** * Register a closure to be invoked on a clone. + * + * @return $this */ public function onClone(Closure $callback): static { diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index 125f27793..c3006ea9a 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -39,6 +39,7 @@ trait QueriesRelationships * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this * * @throws RuntimeException */ @@ -92,6 +93,7 @@ public function has(Relation|string $relation, string $operator = '>=', Expressi * Set up recursive calls to has until we finish the nested relation. * * @param (Closure(Builder<*>): mixed)|null $callback + * @return $this */ protected function hasNested(string $relations, string $operator = '>=', ExpressionContract|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static { @@ -114,6 +116,7 @@ protected function hasNested(string $relations, string $operator = '>=', Express * Add a relationship count / exists condition to the query with an "or". * * @param Relation<*, *, *>|string $relation + * @return $this */ public function orHas(Relation|string $relation, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -127,6 +130,7 @@ public function orHas(Relation|string $relation, string $operator = '>=', Expres * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this */ public function doesntHave(Relation|string $relation, string $boolean = 'and', ?Closure $callback = null): static { @@ -137,6 +141,7 @@ public function doesntHave(Relation|string $relation, string $boolean = 'and', ? * Add a relationship count / exists condition to the query with an "or". * * @param Relation<*, *, *>|string $relation + * @return $this */ public function orDoesntHave(Relation|string $relation): static { @@ -150,6 +155,7 @@ public function orDoesntHave(Relation|string $relation): static * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this */ public function whereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -162,6 +168,7 @@ public function whereHas(Relation|string $relation, ?Closure $callback = null, s * Also load the relationship with the same condition. * * @param (Closure(Builder<*>|Relation<*, *, *>): mixed)|null $callback + * @return $this */ public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -182,6 +189,7 @@ public function withWhereHas(string $relation, ?Closure $callback = null, string * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this */ public function orWhereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -195,6 +203,7 @@ public function orWhereHas(Relation|string $relation, ?Closure $callback = null, * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this */ public function whereDoesntHave(Relation|string $relation, ?Closure $callback = null): static { @@ -208,6 +217,7 @@ public function whereDoesntHave(Relation|string $relation, ?Closure $callback = * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this */ public function orWhereDoesntHave(Relation|string $relation, ?Closure $callback = null): static { @@ -222,6 +232,7 @@ public function orWhereDoesntHave(Relation|string $relation, ?Closure $callback * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function hasMorph(MorphTo|string $relation, string|array $types, string $operator = '>=', ExpressionContract|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static { @@ -321,6 +332,7 @@ protected function getBelongsToRelation(MorphTo $relation, string $type): Belong * * @param MorphTo<*, *>|string $relation * @param array|string $types + * @return $this */ public function orHasMorph(MorphTo|string $relation, string|array $types, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -335,6 +347,7 @@ public function orHasMorph(MorphTo|string $relation, string|array $types, string * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function doesntHaveMorph(MorphTo|string $relation, string|array $types, string $boolean = 'and', ?Closure $callback = null): static { @@ -346,6 +359,7 @@ public function doesntHaveMorph(MorphTo|string $relation, string|array $types, s * * @param MorphTo<*, *>|string $relation * @param array|string $types + * @return $this */ public function orDoesntHaveMorph(MorphTo|string $relation, string|array $types): static { @@ -360,6 +374,7 @@ public function orDoesntHaveMorph(MorphTo|string $relation, string|array $types) * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function whereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -374,6 +389,7 @@ public function whereHasMorph(MorphTo|string $relation, string|array $types, ?Cl * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function orWhereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -388,6 +404,7 @@ public function orWhereHasMorph(MorphTo|string $relation, string|array $types, ? * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function whereDoesntHaveMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null): static { @@ -402,6 +419,7 @@ public function whereDoesntHaveMorph(MorphTo|string $relation, string|array $typ * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function orWhereDoesntHaveMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null): static { @@ -415,6 +433,7 @@ public function orWhereDoesntHaveMorph(MorphTo|string $relation, string|array $t * * @param Relation|string $relation * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function whereRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -429,6 +448,8 @@ public function whereRelation(Relation|string $relation, Closure|string|array|Ex /** * Add a basic where clause to a relationship query and eager-load the relationship with the same conditions. + * + * @return $this */ public function withWhereRelation(string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -447,6 +468,7 @@ public function withWhereRelation(string $relation, Closure|string|array|Express * * @param Relation|string $relation * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function orWhereRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -466,6 +488,7 @@ public function orWhereRelation(Relation|string $relation, Closure|string|array| * * @param Relation|string $relation * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function whereDoesntHaveRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -485,6 +508,7 @@ public function whereDoesntHaveRelation(Relation|string $relation, Closure|strin * * @param Relation|string $relation * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function orWhereDoesntHaveRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -505,6 +529,7 @@ public function orWhereDoesntHaveRelation(Relation|string $relation, Closure|str * @param MorphTo|string $relation * @param array|string $types * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function whereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -521,6 +546,7 @@ public function whereMorphRelation(MorphTo|string $relation, string|array $types * @param MorphTo|string $relation * @param array|string $types * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function orWhereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -537,6 +563,7 @@ public function orWhereMorphRelation(MorphTo|string $relation, string|array $typ * @param MorphTo|string $relation * @param array|string $types * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function whereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -553,6 +580,7 @@ public function whereMorphDoesntHaveRelation(MorphTo|string $relation, string|ar * @param MorphTo|string $relation * @param array|string $types * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function orWhereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -566,6 +594,7 @@ public function orWhereMorphDoesntHaveRelation(MorphTo|string $relation, string| * * @param MorphTo<*, *>|string $relation * @param null|iterable|Model|string $model + * @return $this * * @throws ClassMorphViolationException * @throws InvalidArgumentException @@ -616,6 +645,7 @@ public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $b * * @param MorphTo<*, *>|string $relation * @param null|iterable|Model|string $model + * @return $this * * @throws ClassMorphViolationException * @throws InvalidArgumentException @@ -668,6 +698,7 @@ public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string * * @param MorphTo<*, *>|string $relation * @param null|iterable|Model|string $model + * @return $this */ public function orWhereMorphedTo(MorphTo|string $relation, mixed $model): static { @@ -679,6 +710,7 @@ public function orWhereMorphedTo(MorphTo|string $relation, mixed $model): static * * @param MorphTo<*, *>|string $relation * @param null|iterable|Model|string $model + * @return $this */ public function orWhereNotMorphedTo(MorphTo|string $relation, mixed $model): static { @@ -711,6 +743,7 @@ protected function getMorphTypeForQuery(string $model): string * Add a "belongs to" relationship where clause to the query. * * @param EloquentCollection|Model $related + * @return $this * * @throws InvalidArgumentException * @throws RelationNotFoundException @@ -756,6 +789,7 @@ public function whereBelongsTo(mixed $related, ?string $relationshipName = null, * Add a "BelongsTo" relationship with an "or where" clause to the query. * * @param EloquentCollection|Model $related + * @return $this */ public function orWhereBelongsTo(mixed $related, ?string $relationshipName = null): static { @@ -766,6 +800,7 @@ public function orWhereBelongsTo(mixed $related, ?string $relationshipName = nul * Add a "belongs to many" relationship where clause to the query. * * @param EloquentCollection|Model $related + * @return $this * * @throws InvalidArgumentException * @throws RelationNotFoundException @@ -807,6 +842,7 @@ public function whereAttachedTo(mixed $related, ?string $relationshipName = null * Add a "belongs to many" relationship with an "or where" clause to the query. * * @param EloquentCollection|Model $related + * @return $this * * @throws RuntimeException */ @@ -817,6 +853,8 @@ public function orWhereAttachedTo(mixed $related, ?string $relationshipName = nu /** * Add subselect queries to include an aggregate value for a relationship. + * + * @return $this */ public function withAggregate(mixed $relations, ExpressionContract|string $column, ?string $function = null): static { @@ -933,6 +971,8 @@ protected function getRelationHashedColumn(string $column, Relation $relation): /** * Add subselect queries to count the relations. + * + * @return $this */ public function withCount(mixed $relations): static { @@ -941,6 +981,8 @@ public function withCount(mixed $relations): static /** * Add subselect queries to include the max of the relation's column. + * + * @return $this */ public function withMax(string|array $relation, ExpressionContract|string $column): static { @@ -949,6 +991,8 @@ public function withMax(string|array $relation, ExpressionContract|string $colum /** * Add subselect queries to include the min of the relation's column. + * + * @return $this */ public function withMin(string|array $relation, ExpressionContract|string $column): static { @@ -957,6 +1001,8 @@ public function withMin(string|array $relation, ExpressionContract|string $colum /** * Add subselect queries to include the sum of the relation's column. + * + * @return $this */ public function withSum(string|array $relation, ExpressionContract|string $column): static { @@ -965,6 +1011,8 @@ public function withSum(string|array $relation, ExpressionContract|string $colum /** * Add subselect queries to include the average of the relation's column. + * + * @return $this */ public function withAvg(string|array $relation, ExpressionContract|string $column): static { @@ -973,6 +1021,8 @@ public function withAvg(string|array $relation, ExpressionContract|string $colum /** * Add subselect queries to include the existence of related models. + * + * @return $this */ public function withExists(string|array $relation): static { @@ -984,6 +1034,7 @@ public function withExists(string|array $relation): static * * @param Builder<*> $hasQuery * @param Relation<*, *, *> $relation + * @return $this */ protected function addHasWhere(Builder $hasQuery, Relation $relation, string $operator, ExpressionContract|int $count, string $boolean): static { @@ -1001,6 +1052,7 @@ protected function addHasWhere(Builder $hasQuery, Relation $relation, string $op * Merge the where constraints from another query to the current query. * * @param Builder<*> $from + * @return $this */ public function mergeConstraintsFrom(Builder $from): static { @@ -1042,6 +1094,8 @@ protected function requalifyWhereTables(array $wheres, string $from, string $to) /** * Add a sub-query count clause to this query. + * + * @return $this */ protected function addWhereCountQuery(QueryBuilder $query, string $operator = '>=', ExpressionContract|int $count = 1, string $boolean = 'and'): static { diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 41f05fb50..40b7df610 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -290,6 +290,8 @@ public function __construct( /** * Set the columns to be selected. + * + * @return $this */ public function select(mixed $columns = ['*']): static { @@ -313,6 +315,7 @@ public function select(mixed $columns = ['*']): static * Add a subselect expression to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this * * @throws InvalidArgumentException */ @@ -328,6 +331,8 @@ public function selectSub(Closure|self|EloquentBuilder|Relation|string $query, s /** * Add a select expression to the query. + * + * @return $this */ public function selectExpression(ExpressionContract|string $expression, string $as): static { @@ -338,6 +343,8 @@ public function selectExpression(ExpressionContract|string $expression, string $ /** * Add a new "raw" select expression to the query. + * + * @return $this */ public function selectRaw(string $expression, array $bindings = []): static { @@ -354,6 +361,7 @@ public function selectRaw(string $expression, array $bindings = []): static * Makes "from" fetch from a subquery. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this * * @throws InvalidArgumentException */ @@ -370,6 +378,8 @@ public function fromSub(Closure|self|EloquentBuilder|Relation|string $query, str /** * Add a raw "from" clause to the query. + * + * @return $this */ public function fromRaw(ExpressionContract|string $expression, mixed $bindings = []): static { @@ -454,6 +464,8 @@ protected function prependDatabaseNameIfCrossDatabaseQuery(self $query): self /** * Add a new select column to the query. + * + * @return $this */ public function addSelect(mixed $column): static { @@ -500,6 +512,7 @@ public function getDefaultSelectColumn(): string * Add a vector-similarity selection to the query. * * @param array|Arrayable>|Collection|string $vector + * @return $this * * @throws JsonException */ @@ -533,6 +546,8 @@ public function selectVectorDistance(ExpressionContract|string $column, Collecti /** * Force the query to only return distinct results. + * + * @return $this */ public function distinct(): static { @@ -551,6 +566,7 @@ public function distinct(): static * Set the table which the query is targeting. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $table + * @return $this */ public function from(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $table, ?string $as = null): static { @@ -581,6 +597,8 @@ public function getFromAlias(): ?string /** * Add an index hint to suggest a query index. + * + * @return $this */ public function useIndex(string $index): static { @@ -591,6 +609,8 @@ public function useIndex(string $index): static /** * Add an index hint to force a query index. + * + * @return $this */ public function forceIndex(string $index): static { @@ -601,6 +621,8 @@ public function forceIndex(string $index): static /** * Add an index hint to ignore a query index. + * + * @return $this */ public function ignoreIndex(string $index): static { @@ -611,6 +633,8 @@ public function ignoreIndex(string $index): static /** * Add a "join" clause to the query. + * + * @return $this */ public function join(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, mixed $second = null, string $type = 'inner', bool $where = false): static { @@ -643,6 +667,8 @@ public function join(ExpressionContract|string $table, Closure|ExpressionContrac /** * Add a "join where" clause to the query. + * + * @return $this */ public function joinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second, string $type = 'inner'): static { @@ -653,6 +679,7 @@ public function joinWhere(ExpressionContract|string $table, Closure|ExpressionCo * Add a "subquery join" clause to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this * * @throws InvalidArgumentException */ @@ -676,6 +703,7 @@ public function joinSub(Closure|self|EloquentBuilder|Relation|string $query, str * Add a "lateral join" clause to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function joinLateral(Closure|self|EloquentBuilder|Relation|string $query, string $as, string $type = 'inner'): static { @@ -694,6 +722,7 @@ public function joinLateral(Closure|self|EloquentBuilder|Relation|string $query, * Add a lateral left join to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function leftJoinLateral(Closure|self|EloquentBuilder|Relation|string $query, string $as): static { @@ -702,6 +731,8 @@ public function leftJoinLateral(Closure|self|EloquentBuilder|Relation|string $qu /** * Add a left join to the query. + * + * @return $this */ public function leftJoin(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -710,6 +741,8 @@ public function leftJoin(ExpressionContract|string $table, Closure|ExpressionCon /** * Add a "join where" clause to the query. + * + * @return $this */ public function leftJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second): static { @@ -720,6 +753,7 @@ public function leftJoinWhere(ExpressionContract|string $table, Closure|Expressi * Add a subquery left join to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function leftJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -728,6 +762,8 @@ public function leftJoinSub(Closure|self|EloquentBuilder|Relation|string $query, /** * Add a right join to the query. + * + * @return $this */ public function rightJoin(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -736,6 +772,8 @@ public function rightJoin(ExpressionContract|string $table, Closure|ExpressionCo /** * Add a "right join where" clause to the query. + * + * @return $this */ public function rightJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second): static { @@ -746,6 +784,7 @@ public function rightJoinWhere(ExpressionContract|string $table, Closure|Express * Add a subquery right join to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function rightJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -754,6 +793,8 @@ public function rightJoinSub(Closure|self|EloquentBuilder|Relation|string $query /** * Add a "cross join" clause to the query. + * + * @return $this */ public function crossJoin(ExpressionContract|string $table, Closure|ExpressionContract|string|null $first = null, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -770,6 +811,7 @@ public function crossJoin(ExpressionContract|string $table, Closure|ExpressionCo * Add a subquery cross join to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function crossJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as): static { @@ -786,6 +828,8 @@ public function crossJoinSub(Closure|self|EloquentBuilder|Relation|string $query /** * Add a straight join to the query. + * + * @return $this */ public function straightJoin(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -794,6 +838,8 @@ public function straightJoin(ExpressionContract|string $table, Closure|Expressio /** * Add a straight join where clause to the query. + * + * @return $this */ public function straightJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second): static { @@ -804,6 +850,7 @@ public function straightJoinWhere(ExpressionContract|string $table, Closure|Expr * Add a subquery straight join to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function straightJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -828,6 +875,8 @@ protected function newJoinLateralClause(self $parentQuery, string $type, Express /** * Merge an array of "where" clauses and bindings. + * + * @return $this */ public function mergeWheres(array $wheres, array $bindings): static { @@ -842,6 +891,8 @@ public function mergeWheres(array $wheres, array $bindings): static /** * Add a basic "where" clause to the query. + * + * @return $this */ public function where(Closure|self|EloquentBuilder|Relation|ExpressionContract|array|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -949,6 +1000,8 @@ public function where(Closure|self|EloquentBuilder|Relation|ExpressionContract|a /** * Add an array of "where" clauses to the query. + * + * @return $this */ protected function addArrayOfWheres(array $column, string $boolean, string $method = 'where'): static { @@ -1011,6 +1064,8 @@ protected function isBitwiseOperator(string $operator): bool /** * Add an "or where" clause to the query. + * + * @return $this */ public function orWhere(Closure|self|EloquentBuilder|Relation|ExpressionContract|array|string $column, mixed $operator = null, mixed $value = null): static { @@ -1025,6 +1080,8 @@ public function orWhere(Closure|self|EloquentBuilder|Relation|ExpressionContract /** * Add a basic "where not" clause to the query. + * + * @return $this */ public function whereNot(Closure|self|EloquentBuilder|Relation|ExpressionContract|array|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -1039,6 +1096,8 @@ public function whereNot(Closure|self|EloquentBuilder|Relation|ExpressionContrac /** * Add an "or where not" clause to the query. + * + * @return $this */ public function orWhereNot(Closure|self|EloquentBuilder|Relation|ExpressionContract|array|string $column, mixed $operator = null, mixed $value = null): static { @@ -1047,6 +1106,8 @@ public function orWhereNot(Closure|self|EloquentBuilder|Relation|ExpressionContr /** * Add a "where" clause comparing two columns to the query. + * + * @return $this */ public function whereColumn(ExpressionContract|string|array $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null, string $boolean = 'and'): static { @@ -1078,6 +1139,8 @@ public function whereColumn(ExpressionContract|string|array $first, ExpressionCo /** * Add an "or where" clause comparing two columns to the query. + * + * @return $this */ public function orWhereColumn(ExpressionContract|string|array $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -1089,6 +1152,7 @@ public function orWhereColumn(ExpressionContract|string|array $first, Expression * * @param array|Arrayable>|Collection|string $vector * @param float $minSimilarity A value between 0.0 and 1.0, where 1.0 is identical. + * @return $this * * @throws JsonException */ @@ -1113,6 +1177,7 @@ public function whereVectorSimilarTo(ExpressionContract|string $column, Collecti * Add a vector distance "where" clause to the query. * * @param array|Arrayable>|Collection|string $vector + * @return $this * * @throws JsonException */ @@ -1143,6 +1208,7 @@ public function whereVectorDistanceLessThan(ExpressionContract|string $column, C * Add a vector distance "or where" clause to the query. * * @param array|Arrayable>|Collection|string $vector + * @return $this * * @throws JsonException */ @@ -1153,6 +1219,8 @@ public function orWhereVectorDistanceLessThan(ExpressionContract|string $column, /** * Add a raw "where" clause to the query. + * + * @return $this */ public function whereRaw(ExpressionContract|string $sql, mixed $bindings = [], string $boolean = 'and'): static { @@ -1165,6 +1233,8 @@ public function whereRaw(ExpressionContract|string $sql, mixed $bindings = [], s /** * Add a raw "or where" clause to the query. + * + * @return $this */ public function orWhereRaw(string $sql, mixed $bindings = []): static { @@ -1173,6 +1243,8 @@ public function orWhereRaw(string $sql, mixed $bindings = []): static /** * Add a "where binary" clause to the query. + * + * @return $this */ public function whereBinary(ExpressionContract|string $column, string $value, string $boolean = 'and', bool $not = false): static { @@ -1187,6 +1259,8 @@ public function whereBinary(ExpressionContract|string $column, string $value, st /** * Add an "or where binary" clause to the query. + * + * @return $this */ public function orWhereBinary(ExpressionContract|string $column, string $value): static { @@ -1195,6 +1269,8 @@ public function orWhereBinary(ExpressionContract|string $column, string $value): /** * Add a "where not binary" clause to the query. + * + * @return $this */ public function whereNotBinary(ExpressionContract|string $column, string $value, string $boolean = 'and'): static { @@ -1203,6 +1279,8 @@ public function whereNotBinary(ExpressionContract|string $column, string $value, /** * Add an "or where not binary" clause to the query. + * + * @return $this */ public function orWhereNotBinary(ExpressionContract|string $column, string $value): static { @@ -1211,6 +1289,8 @@ public function orWhereNotBinary(ExpressionContract|string $column, string $valu /** * Add a "where like" clause to the query. + * + * @return $this */ public function whereLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false, string $boolean = 'and', bool $not = false): static { @@ -1229,6 +1309,8 @@ public function whereLike(ExpressionContract|string $column, string $value, bool /** * Add an "or where like" clause to the query. + * + * @return $this */ public function orWhereLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false): static { @@ -1237,6 +1319,8 @@ public function orWhereLike(ExpressionContract|string $column, string $value, bo /** * Add a "where not like" clause to the query. + * + * @return $this */ public function whereNotLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false, string $boolean = 'and'): static { @@ -1245,6 +1329,8 @@ public function whereNotLike(ExpressionContract|string $column, string $value, b /** * Add an "or where not like" clause to the query. + * + * @return $this */ public function orWhereNotLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false): static { @@ -1253,6 +1339,8 @@ public function orWhereNotLike(ExpressionContract|string $column, string $value, /** * Add a null-safe equality clause to the query. + * + * @return $this */ public function whereNullSafeEquals(ExpressionContract|string $column, mixed $value, string $boolean = 'and'): static { @@ -1273,6 +1361,8 @@ public function whereNullSafeEquals(ExpressionContract|string $column, mixed $va /** * Add an "or" null-safe equality clause to the query. + * + * @return $this */ public function orWhereNullSafeEquals(ExpressionContract|string $column, mixed $value): static { @@ -1282,6 +1372,8 @@ public function orWhereNullSafeEquals(ExpressionContract|string $column, mixed $ /** * Add a "where in" clause to the query. * + * @return $this + * * @throws InvalidArgumentException */ public function whereIn(ExpressionContract|string $column, mixed $values, string $boolean = 'and', bool $not = false): static @@ -1322,6 +1414,8 @@ public function whereIn(ExpressionContract|string $column, mixed $values, string /** * Add an "or where in" clause to the query. + * + * @return $this */ public function orWhereIn(ExpressionContract|string $column, mixed $values): static { @@ -1330,6 +1424,8 @@ public function orWhereIn(ExpressionContract|string $column, mixed $values): sta /** * Add a "where not in" clause to the query. + * + * @return $this */ public function whereNotIn(ExpressionContract|string $column, mixed $values, string $boolean = 'and'): static { @@ -1338,6 +1434,8 @@ public function whereNotIn(ExpressionContract|string $column, mixed $values, str /** * Add an "or where not in" clause to the query. + * + * @return $this */ public function orWhereNotIn(ExpressionContract|string $column, mixed $values): static { @@ -1346,6 +1444,8 @@ public function orWhereNotIn(ExpressionContract|string $column, mixed $values): /** * Add a "where in raw" clause for integer values to the query. + * + * @return $this */ public function whereIntegerInRaw(string $column, Arrayable|array $values, string $boolean = 'and', bool $not = false): static { @@ -1368,6 +1468,8 @@ public function whereIntegerInRaw(string $column, Arrayable|array $values, strin /** * Add an "or where in raw" clause for integer values to the query. + * + * @return $this */ public function orWhereIntegerInRaw(string $column, Arrayable|array $values): static { @@ -1376,6 +1478,8 @@ public function orWhereIntegerInRaw(string $column, Arrayable|array $values): st /** * Add a "where not in raw" clause for integer values to the query. + * + * @return $this */ public function whereIntegerNotInRaw(string $column, Arrayable|array $values, string $boolean = 'and'): static { @@ -1384,6 +1488,8 @@ public function whereIntegerNotInRaw(string $column, Arrayable|array $values, st /** * Add an "or where not in raw" clause for integer values to the query. + * + * @return $this */ public function orWhereIntegerNotInRaw(string $column, Arrayable|array $values): static { @@ -1392,6 +1498,8 @@ public function orWhereIntegerNotInRaw(string $column, Arrayable|array $values): /** * Add a "where null" clause to the query. + * + * @return $this */ public function whereNull(string|array|ExpressionContract $columns, string $boolean = 'and', bool $not = false): static { @@ -1406,6 +1514,8 @@ public function whereNull(string|array|ExpressionContract $columns, string $bool /** * Add an "or where null" clause to the query. + * + * @return $this */ public function orWhereNull(string|array|ExpressionContract $column): static { @@ -1414,6 +1524,8 @@ public function orWhereNull(string|array|ExpressionContract $column): static /** * Add a "where not null" clause to the query. + * + * @return $this */ public function whereNotNull(string|array|ExpressionContract $columns, string $boolean = 'and'): static { @@ -1424,6 +1536,7 @@ public function whereNotNull(string|array|ExpressionContract $columns, string $b * Add a "where between" statement to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function whereBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and', bool $not = false): static { @@ -1453,6 +1566,7 @@ public function whereBetween(Closure|self|EloquentBuilder|Relation|ExpressionCon * Add a "where between" statement using columns to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function whereBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and', bool $not = false): static { @@ -1474,6 +1588,7 @@ public function whereBetweenColumns(Closure|self|EloquentBuilder|Relation|Expres * Add an "or where between" statement to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function orWhereBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static { @@ -1482,6 +1597,8 @@ public function orWhereBetween(Closure|self|EloquentBuilder|Relation|ExpressionC /** * Add an "or where between" statement using columns to the query. + * + * @return $this */ public function orWhereBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static { @@ -1492,6 +1609,7 @@ public function orWhereBetweenColumns(Closure|self|EloquentBuilder|Relation|Expr * Add a "where not between" statement to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function whereNotBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and'): static { @@ -1500,6 +1618,8 @@ public function whereNotBetween(Closure|self|EloquentBuilder|Relation|Expression /** * Add a "where not between" statement using columns to the query. + * + * @return $this */ public function whereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and'): static { @@ -1510,6 +1630,7 @@ public function whereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|Exp * Add an "or where not between" statement to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function orWhereNotBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static { @@ -1518,6 +1639,8 @@ public function orWhereNotBetween(Closure|self|EloquentBuilder|Relation|Expressi /** * Add an "or where not between" statement using columns to the query. + * + * @return $this */ public function orWhereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static { @@ -1528,6 +1651,7 @@ public function orWhereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|E * Add a "where between columns" statement using a value to the query. * * @param array{ExpressionContract|string, ExpressionContract|string} $columns + * @return $this */ public function whereValueBetween(mixed $value, array $columns, string $boolean = 'and', bool $not = false): static { @@ -1547,6 +1671,7 @@ public function whereValueBetween(mixed $value, array $columns, string $boolean * Add an "or where between columns" statement using a value to the query. * * @param array{ExpressionContract|string, ExpressionContract|string} $columns + * @return $this */ public function orWhereValueBetween(mixed $value, array $columns): static { @@ -1557,6 +1682,7 @@ public function orWhereValueBetween(mixed $value, array $columns): static * Add a "where not between columns" statement using a value to the query. * * @param array{ExpressionContract|string, ExpressionContract|string} $columns + * @return $this */ public function whereValueNotBetween(mixed $value, array $columns, string $boolean = 'and'): static { @@ -1567,6 +1693,7 @@ public function whereValueNotBetween(mixed $value, array $columns, string $boole * Add an "or where not between columns" statement using a value to the query. * * @param array{ExpressionContract|string, ExpressionContract|string} $columns + * @return $this */ public function orWhereValueNotBetween(mixed $value, array $columns): static { @@ -1575,6 +1702,8 @@ public function orWhereValueNotBetween(mixed $value, array $columns): static /** * Add an "or where not null" clause to the query. + * + * @return $this */ public function orWhereNotNull(array|ExpressionContract|string $column): static { @@ -1583,6 +1712,8 @@ public function orWhereNotNull(array|ExpressionContract|string $column): static /** * Add a "where date" statement to the query. + * + * @return $this */ public function whereDate(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -1610,6 +1741,8 @@ public function whereDate(ExpressionContract|BaseStringable|string $column, mixe /** * Add an "or where date" statement to the query. + * + * @return $this */ public function orWhereDate(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null): static { @@ -1624,6 +1757,8 @@ public function orWhereDate(ExpressionContract|BaseStringable|string $column, mi /** * Add a "where time" statement to the query. + * + * @return $this */ public function whereTime(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -1651,6 +1786,8 @@ public function whereTime(ExpressionContract|BaseStringable|string $column, mixe /** * Add an "or where time" statement to the query. + * + * @return $this */ public function orWhereTime(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null): static { @@ -1665,6 +1802,8 @@ public function orWhereTime(ExpressionContract|BaseStringable|string $column, mi /** * Add a "where day" statement to the query. + * + * @return $this */ public function whereDay(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -1697,6 +1836,8 @@ public function whereDay(ExpressionContract|BaseStringable|string $column, mixed /** * Add an "or where day" statement to the query. + * + * @return $this */ public function orWhereDay(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null): static { @@ -1711,6 +1852,8 @@ public function orWhereDay(ExpressionContract|BaseStringable|string $column, mix /** * Add a "where month" statement to the query. + * + * @return $this */ public function whereMonth(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -1743,6 +1886,8 @@ public function whereMonth(ExpressionContract|BaseStringable|string $column, mix /** * Add an "or where month" statement to the query. + * + * @return $this */ public function orWhereMonth(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null): static { @@ -1757,6 +1902,8 @@ public function orWhereMonth(ExpressionContract|BaseStringable|string $column, m /** * Add a "where year" statement to the query. + * + * @return $this */ public function whereYear(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -1784,6 +1931,8 @@ public function whereYear(ExpressionContract|BaseStringable|string $column, mixe /** * Add an "or where year" statement to the query. + * + * @return $this */ public function orWhereYear(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null): static { @@ -1798,6 +1947,8 @@ public function orWhereYear(ExpressionContract|BaseStringable|string $column, mi /** * Add a date based (year, month, day, time) statement to the query. + * + * @return $this */ protected function addDateBasedWhere(string $type, ExpressionContract|BaseStringable|string $column, string $operator, mixed $value, string $boolean = 'and'): static { @@ -1815,6 +1966,8 @@ protected function addDateBasedWhere(string $type, ExpressionContract|BaseString /** * Add a nested "where" statement to the query. + * + * @return $this */ public function whereNested(Closure $callback, string $boolean = 'and'): static { @@ -1839,6 +1992,8 @@ public function forNestedWhere(): static /** * Add another query builder as a nested where to the query builder. + * + * @return $this */ public function addNestedWhereQuery(self $query, string $boolean = 'and'): static { @@ -1857,6 +2012,7 @@ public function addNestedWhereQuery(self $query, string $boolean = 'and'): stati * Add a full sub-select to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *> $callback + * @return $this * * @throws InvalidArgumentException */ @@ -1888,6 +2044,7 @@ protected function whereSub(ExpressionContract|string $column, string $operator, * Add an "exists" clause to the query. * * @param Closure|self|EloquentBuilder<*> $callback + * @return $this * * @throws InvalidArgumentException */ @@ -1911,6 +2068,7 @@ public function whereExists(Closure|self|EloquentBuilder $callback, string $bool * Add an "or where exists" clause to the query. * * @param Closure|self|EloquentBuilder<*> $callback + * @return $this * * @throws InvalidArgumentException */ @@ -1923,6 +2081,7 @@ public function orWhereExists(Closure|self|EloquentBuilder $callback, bool $not * Add a "where not exists" clause to the query. * * @param Closure|self|EloquentBuilder<*> $callback + * @return $this * * @throws InvalidArgumentException */ @@ -1935,6 +2094,7 @@ public function whereNotExists(Closure|self|EloquentBuilder $callback, string $b * Add an "or where not exists" clause to the query. * * @param Closure|self|EloquentBuilder<*> $callback + * @return $this * * @throws InvalidArgumentException */ @@ -1946,6 +2106,8 @@ public function orWhereNotExists(Closure|self|EloquentBuilder $callback): static /** * Add an "exists" clause to the query. * + * @return $this + * * @throws InvalidArgumentException */ public function addWhereExistsQuery(self $query, string $boolean = 'and', bool $not = false): static @@ -1964,6 +2126,8 @@ public function addWhereExistsQuery(self $query, string $boolean = 'and', bool $ /** * Add a where condition using row values. * + * @return $this + * * @throws InvalidArgumentException */ public function whereRowValues(array $columns, string $operator, array $values, string $boolean = 'and'): static @@ -1987,6 +2151,8 @@ public function whereRowValues(array $columns, string $operator, array $values, /** * Adds an or where condition using row values. + * + * @return $this */ public function orWhereRowValues(array $columns, string $operator, array $values): static { @@ -1995,6 +2161,8 @@ public function orWhereRowValues(array $columns, string $operator, array $values /** * Add a "where JSON contains" clause to the query. + * + * @return $this */ public function whereJsonContains(string $column, mixed $value, string $boolean = 'and', bool $not = false): static { @@ -2011,6 +2179,8 @@ public function whereJsonContains(string $column, mixed $value, string $boolean /** * Add an "or where JSON contains" clause to the query. + * + * @return $this */ public function orWhereJsonContains(string $column, mixed $value): static { @@ -2019,6 +2189,8 @@ public function orWhereJsonContains(string $column, mixed $value): static /** * Add a "where JSON not contains" clause to the query. + * + * @return $this */ public function whereJsonDoesntContain(string $column, mixed $value, string $boolean = 'and'): static { @@ -2027,6 +2199,8 @@ public function whereJsonDoesntContain(string $column, mixed $value, string $boo /** * Add an "or where JSON not contains" clause to the query. + * + * @return $this */ public function orWhereJsonDoesntContain(string $column, mixed $value): static { @@ -2035,6 +2209,8 @@ public function orWhereJsonDoesntContain(string $column, mixed $value): static /** * Add a "where JSON overlaps" clause to the query. + * + * @return $this */ public function whereJsonOverlaps(string $column, mixed $value, string $boolean = 'and', bool $not = false): static { @@ -2051,6 +2227,8 @@ public function whereJsonOverlaps(string $column, mixed $value, string $boolean /** * Add an "or where JSON overlaps" clause to the query. + * + * @return $this */ public function orWhereJsonOverlaps(string $column, mixed $value): static { @@ -2059,6 +2237,8 @@ public function orWhereJsonOverlaps(string $column, mixed $value): static /** * Add a "where JSON not overlap" clause to the query. + * + * @return $this */ public function whereJsonDoesntOverlap(string $column, mixed $value, string $boolean = 'and'): static { @@ -2067,6 +2247,8 @@ public function whereJsonDoesntOverlap(string $column, mixed $value, string $boo /** * Add an "or where JSON not overlap" clause to the query. + * + * @return $this */ public function orWhereJsonDoesntOverlap(string $column, mixed $value): static { @@ -2075,6 +2257,8 @@ public function orWhereJsonDoesntOverlap(string $column, mixed $value): static /** * Add a clause that determines if a JSON path exists to the query. + * + * @return $this */ public function whereJsonContainsKey(string $column, string $boolean = 'and', bool $not = false): static { @@ -2087,6 +2271,8 @@ public function whereJsonContainsKey(string $column, string $boolean = 'and', bo /** * Add an "or" clause that determines if a JSON path exists to the query. + * + * @return $this */ public function orWhereJsonContainsKey(string $column): static { @@ -2095,6 +2281,8 @@ public function orWhereJsonContainsKey(string $column): static /** * Add a clause that determines if a JSON path does not exist to the query. + * + * @return $this */ public function whereJsonDoesntContainKey(string $column, string $boolean = 'and'): static { @@ -2103,6 +2291,8 @@ public function whereJsonDoesntContainKey(string $column, string $boolean = 'and /** * Add an "or" clause that determines if a JSON path does not exist to the query. + * + * @return $this */ public function orWhereJsonDoesntContainKey(string $column): static { @@ -2111,6 +2301,8 @@ public function orWhereJsonDoesntContainKey(string $column): static /** * Add a "where JSON length" clause to the query. + * + * @return $this */ public function whereJsonLength(string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -2140,6 +2332,8 @@ public function whereJsonLength(string $column, mixed $operator, mixed $value = /** * Add an "or where JSON length" clause to the query. + * + * @return $this */ public function orWhereJsonLength(string $column, mixed $operator, mixed $value = null): static { @@ -2154,6 +2348,8 @@ public function orWhereJsonLength(string $column, mixed $operator, mixed $value /** * Handles dynamic "where" clauses to the query. + * + * @return $this */ public function dynamicWhere(string $method, array $parameters): static { @@ -2209,6 +2405,8 @@ protected function addDynamic(string $segment, string $connector, array $paramet /** * Add a "where fulltext" clause to the query. + * + * @return $this */ public function whereFullText(string|array $columns, string $value, array $options = [], string $boolean = 'and'): static { @@ -2225,6 +2423,8 @@ public function whereFullText(string|array $columns, string $value, array $optio /** * Add an "or where fulltext" clause to the query. + * + * @return $this */ public function orWhereFullText(string|array $columns, string $value, array $options = []): static { @@ -2235,6 +2435,7 @@ public function orWhereFullText(string|array $columns, string $value, array $opt * Add a "where" clause to the query for multiple columns with "and" conditions between them. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function whereAll(array $columns, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -2257,6 +2458,7 @@ public function whereAll(array $columns, mixed $operator = null, mixed $value = * Add an "or where" clause to the query for multiple columns with "and" conditions between them. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function orWhereAll(array $columns, mixed $operator = null, mixed $value = null): static { @@ -2267,6 +2469,7 @@ public function orWhereAll(array $columns, mixed $operator = null, mixed $value * Add a "where" clause to the query for multiple columns with "or" conditions between them. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function whereAny(array $columns, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -2289,6 +2492,7 @@ public function whereAny(array $columns, mixed $operator = null, mixed $value = * Add an "or where" clause to the query for multiple columns with "or" conditions between them. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function orWhereAny(array $columns, mixed $operator = null, mixed $value = null): static { @@ -2299,6 +2503,7 @@ public function orWhereAny(array $columns, mixed $operator = null, mixed $value * Add a "where not" clause to the query for multiple columns where none of the conditions should be true. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function whereNone(array $columns, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -2309,6 +2514,7 @@ public function whereNone(array $columns, mixed $operator = null, mixed $value = * Add an "or where not" clause to the query for multiple columns where none of the conditions should be true. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function orWhereNone(array $columns, mixed $operator = null, mixed $value = null): static { @@ -2317,6 +2523,8 @@ public function orWhereNone(array $columns, mixed $operator = null, mixed $value /** * Add a "group by" clause to the query. + * + * @return $this */ public function groupBy(array|ExpressionContract|string ...$groups): static { @@ -2332,6 +2540,8 @@ public function groupBy(array|ExpressionContract|string ...$groups): static /** * Add a raw "groupBy" clause to the query. + * + * @return $this */ public function groupByRaw(string $sql, array $bindings = []): static { @@ -2344,6 +2554,8 @@ public function groupByRaw(string $sql, array $bindings = []): static /** * Add a "having" clause to the query. + * + * @return $this */ public function having( ExpressionContract|Closure|string $column, @@ -2396,6 +2608,8 @@ public function having( /** * Add an "or having" clause to the query. + * + * @return $this */ public function orHaving( ExpressionContract|Closure|string $column, @@ -2413,6 +2627,8 @@ public function orHaving( /** * Add a nested "having" statement to the query. + * + * @return $this */ public function havingNested(Closure $callback, string $boolean = 'and'): static { @@ -2423,6 +2639,8 @@ public function havingNested(Closure $callback, string $boolean = 'and'): static /** * Add another query builder as a nested having to the query builder. + * + * @return $this */ public function addNestedHavingQuery(self $query, string $boolean = 'and'): static { @@ -2439,6 +2657,8 @@ public function addNestedHavingQuery(self $query, string $boolean = 'and'): stat /** * Add a "having null" clause to the query. + * + * @return $this */ public function havingNull(array|string $columns, string $boolean = 'and', bool $not = false): static { @@ -2453,6 +2673,8 @@ public function havingNull(array|string $columns, string $boolean = 'and', bool /** * Add an "or having null" clause to the query. + * + * @return $this */ public function orHavingNull(string $column): static { @@ -2461,6 +2683,8 @@ public function orHavingNull(string $column): static /** * Add a "having not null" clause to the query. + * + * @return $this */ public function havingNotNull(array|string $columns, string $boolean = 'and'): static { @@ -2469,6 +2693,8 @@ public function havingNotNull(array|string $columns, string $boolean = 'and'): s /** * Add an "or having not null" clause to the query. + * + * @return $this */ public function orHavingNotNull(string $column): static { @@ -2477,6 +2703,8 @@ public function orHavingNotNull(string $column): static /** * Add a "having between" clause to the query. + * + * @return $this */ public function havingBetween(string $column, iterable $values, string $boolean = 'and', bool $not = false): static { @@ -2497,6 +2725,8 @@ public function havingBetween(string $column, iterable $values, string $boolean /** * Add a "having not between" clause to the query. + * + * @return $this */ public function havingNotBetween(string $column, iterable $values, string $boolean = 'and'): static { @@ -2505,6 +2735,8 @@ public function havingNotBetween(string $column, iterable $values, string $boole /** * Add an "or having between" clause to the query. + * + * @return $this */ public function orHavingBetween(string $column, iterable $values): static { @@ -2513,6 +2745,8 @@ public function orHavingBetween(string $column, iterable $values): static /** * Add an "or having not between" clause to the query. + * + * @return $this */ public function orHavingNotBetween(string $column, iterable $values): static { @@ -2542,6 +2776,8 @@ protected function resolveDatePeriodBounds(DatePeriod $period): array /** * Add a raw "having" clause to the query. + * + * @return $this */ public function havingRaw(string $sql, array $bindings = [], string $boolean = 'and'): static { @@ -2556,6 +2792,8 @@ public function havingRaw(string $sql, array $bindings = [], string $boolean = ' /** * Add a raw "or having" clause to the query. + * + * @return $this */ public function orHavingRaw(string $sql, array $bindings = []): static { @@ -2567,6 +2805,7 @@ public function orHavingRaw(string $sql, array $bindings = []): static * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column * @param 'asc'|'desc'|SortDirection $direction + * @return $this * * @throws InvalidArgumentException */ @@ -2602,6 +2841,7 @@ public function orderBy(Closure|self|EloquentBuilder|Relation|ExpressionContract * Add a descending "order by" clause to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function orderByDesc(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column): static { @@ -2610,6 +2850,8 @@ public function orderByDesc(Closure|self|EloquentBuilder|Relation|ExpressionCont /** * Add an "order by" clause for a timestamp to the query. + * + * @return $this */ public function latest(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column = 'created_at'): static { @@ -2618,6 +2860,8 @@ public function latest(Closure|self|EloquentBuilder|Relation|ExpressionContract| /** * Add an "order by" clause for a timestamp to the query. + * + * @return $this */ public function oldest(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column = 'created_at'): static { @@ -2628,6 +2872,7 @@ public function oldest(Closure|self|EloquentBuilder|Relation|ExpressionContract| * Add a vector-distance "order by" clause to the query. * * @param array|Arrayable>|Collection|string $vector + * @return $this * * @throws JsonException */ @@ -2659,6 +2904,8 @@ public function orderByVectorDistance(ExpressionContract|string $column, Collect /** * Put the query's results in random order. + * + * @return $this */ public function inRandomOrder(string|int $seed = ''): static { @@ -2669,6 +2916,7 @@ public function inRandomOrder(string|int $seed = ''): static * Add an "order by" clause to order results by a given sequence of values. * * @param array|Arrayable $values + * @return $this */ public function inOrderOf(ExpressionContract|string $column, Arrayable|array $values): static { @@ -2697,6 +2945,8 @@ public function inOrderOf(ExpressionContract|string $column, Arrayable|array $va /** * Add a raw "order by" clause to the query. + * + * @return $this */ public function orderByRaw(string $sql, mixed $bindings = []): static { @@ -2731,6 +2981,8 @@ protected function addOrderBindings(array $bindings): void /** * Alias to set the "offset" value of the query. + * + * @return $this */ public function skip(?int $value): static { @@ -2739,6 +2991,8 @@ public function skip(?int $value): static /** * Set the "offset" value of the query. + * + * @return $this */ public function offset(?int $value): static { @@ -2751,6 +3005,8 @@ public function offset(?int $value): static /** * Alias to set the "limit" value of the query. + * + * @return $this */ public function take(?int $value): static { @@ -2759,6 +3015,8 @@ public function take(?int $value): static /** * Set the "limit" value of the query. + * + * @return $this */ public function limit(?int $value): static { @@ -2773,6 +3031,8 @@ public function limit(?int $value): static /** * Add a "group limit" clause to the query. + * + * @return $this */ public function groupLimit(int $value, string $column): static { @@ -2785,6 +3045,8 @@ public function groupLimit(int $value, string $column): static /** * Set the limit and offset for a given page. + * + * @return $this */ public function forPage(int $page, int $perPage = 15): static { @@ -2795,6 +3057,7 @@ public function forPage(int $page, int $perPage = 15): static * Constrain the query to the previous "page" of results before a given ID. * * @param null|BinaryParameter|int|resource|string $lastId + * @return $this */ public function forPageBeforeId(int $perPage = 15, mixed $lastId = 0, string $column = 'id'): static { @@ -2828,6 +3091,7 @@ public function forPageBeforeId(int $perPage = 15, mixed $lastId = 0, string $co * Constrain the query to the next "page" of results after a given ID. * * @param null|BinaryParameter|int|resource|string $lastId + * @return $this */ public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $column = 'id'): static { @@ -2861,6 +3125,7 @@ public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $col * Remove all existing orders and optionally add a new order. * * @param 'asc'|'desc'|SortDirection $direction + * @return $this */ public function reorder(Closure|self|EloquentBuilder|Relation|ExpressionContract|string|null $column = null, SortDirection|string $direction = SortDirection::Ascending): static { @@ -2879,6 +3144,8 @@ public function reorder(Closure|self|EloquentBuilder|Relation|ExpressionContract /** * Add descending "reorder" clause to the query. + * + * @return $this */ public function reorderDesc(Closure|self|EloquentBuilder|Relation|ExpressionContract|string|null $column): static { @@ -2941,6 +3208,7 @@ protected function removeOrdersFor(array $orders, array &$bindings, string $colu * Add a "union" statement to the query. * * @param Closure|self|EloquentBuilder<*> $query + * @return $this * * @throws InvalidArgumentException */ @@ -2967,6 +3235,7 @@ public function union(Closure|self|EloquentBuilder $query, bool $all = false): s * Add a "union all" statement to the query. * * @param Closure|self|EloquentBuilder<*> $query + * @return $this * * @throws InvalidArgumentException */ @@ -2977,6 +3246,8 @@ public function unionAll(Closure|self|EloquentBuilder $query): static /** * Lock the selected rows in the table. + * + * @return $this */ public function lock(string|bool $value = true): static { @@ -2992,6 +3263,8 @@ public function lock(string|bool $value = true): static /** * Lock the selected rows in the table for updating. + * + * @return $this */ public function lockForUpdate(): static { @@ -3000,6 +3273,8 @@ public function lockForUpdate(): static /** * Share lock the selected rows in the table. + * + * @return $this */ public function sharedLock(): static { @@ -3023,6 +3298,8 @@ public function skipLocked(): static /** * Set a query execution timeout in seconds. * + * @return $this + * * @throws InvalidArgumentException */ public function timeout(?int $seconds): static @@ -3038,6 +3315,8 @@ public function timeout(?int $seconds): static /** * Register a closure to be invoked before the query is executed. + * + * @return $this */ public function beforeQuery(callable $callback): static { @@ -3062,6 +3341,7 @@ public function applyBeforeQueryCallbacks(): void * Register a closure to be invoked after the query is executed. * * @param Closure(Collection): (Collection|void) $callback + * @return $this */ public function afterQuery(Closure $callback): static { @@ -4449,6 +4729,7 @@ public function numericAggregate(string $function, array $columns = ['*']): floa * Set the aggregate property without running the query. * * @param array $columns + * @return $this */ protected function setAggregate(string $function, array $columns): static { @@ -5026,6 +5307,7 @@ public function getRawBindings(): array * * @param list $bindings * @param TBindingType $type + * @return $this * * @throws InvalidArgumentException */ @@ -5044,6 +5326,7 @@ public function setBindings(array $bindings, string $type = 'where'): static * Add a binding to the query. * * @param TBindingType $type + * @return $this * * @throws InvalidArgumentException */ @@ -5079,6 +5362,8 @@ public function castBinding(mixed $value): mixed /** * Merge an array of bindings into our bindings. + * + * @return $this */ public function mergeBindings(self $query): static { @@ -5159,6 +5444,8 @@ public function getGrammar(): Grammar /** * Use the write connection when executing the query. + * + * @return $this */ public function useWritePdo(): static { @@ -5259,6 +5546,8 @@ public function cloneWithoutBindings(array $except): static /** * Dump the current SQL and bindings. + * + * @return $this */ public function dump(mixed ...$args): static { @@ -5273,6 +5562,8 @@ public function dump(mixed ...$args): static /** * Dump the raw current SQL with embedded bindings. + * + * @return $this */ public function dumpRawSql(): static { diff --git a/src/database/src/Query/JoinClause.php b/src/database/src/Query/JoinClause.php index 0bc5f78b7..0e109f66d 100644 --- a/src/database/src/Query/JoinClause.php +++ b/src/database/src/Query/JoinClause.php @@ -75,6 +75,8 @@ public function __construct(Builder $parentQuery, string $type, ExpressionContra * * on `contacts`.`user_id` = `users`.`id` and `contacts`.`info_id` = `info`.`id` * + * @return $this + * * @throws InvalidArgumentException */ public function on( From 5e97276195695507445c94f478bbed011ed7772f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:48:14 +0000 Subject: [PATCH 124/303] Preserve schema casts and nullable helper input contracts Remove auth and notification trait property declarations that override model schema and cast types. Retain the notification method contracts required by the foundation user. Describe save options as an open shape with a nullable boolean touch option, and allow nullable transform inputs and default callback arguments. Runtime implementations remain unchanged; integration fixtures cover the resulting contracts. --- src/auth/src/MustVerifyEmail.php | 4 ---- src/auth/src/Passwords/CanResetPassword.php | 2 -- src/database/src/Eloquent/Model.php | 16 ++++++++-------- src/notifications/src/RoutesNotifications.php | 3 --- src/support/src/helpers.php | 4 ++-- 5 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/auth/src/MustVerifyEmail.php b/src/auth/src/MustVerifyEmail.php index 7f7663781..5c47547ca 100644 --- a/src/auth/src/MustVerifyEmail.php +++ b/src/auth/src/MustVerifyEmail.php @@ -4,13 +4,9 @@ namespace Hypervel\Auth; -use Carbon\CarbonInterface; use Hypervel\Auth\Notifications\VerifyEmail; /** - * @property string $email - * @property null|CarbonInterface $email_verified_at - * * @method void notify(mixed $instance) */ trait MustVerifyEmail diff --git a/src/auth/src/Passwords/CanResetPassword.php b/src/auth/src/Passwords/CanResetPassword.php index 2397f9c1e..9d10f8e99 100644 --- a/src/auth/src/Passwords/CanResetPassword.php +++ b/src/auth/src/Passwords/CanResetPassword.php @@ -8,8 +8,6 @@ use SensitiveParameter; /** - * @property string $email - * * @method void notify(mixed $instance) */ trait CanResetPassword diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index b206599a7..3fb21f140 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1258,7 +1258,7 @@ protected function incrementOrDecrement(string $column, mixed $amount, array $ex * Update the model in the database. * * @param array $attributes - * @param array $options + * @param array{touch?: null|bool, ...} $options */ public function update(array $attributes = [], array $options = []): bool { @@ -1273,7 +1273,7 @@ public function update(array $attributes = [], array $options = []): bool * Update the model in the database within a transaction. * * @param array $attributes - * @param array $options + * @param array{touch?: null|bool, ...} $options * * @throws Throwable */ @@ -1290,7 +1290,7 @@ public function updateOrFail(array $attributes = [], array $options = []): bool * Update the model in the database without raising any events. * * @param array $attributes - * @param array $options + * @param array{touch?: null|bool, ...} $options */ public function updateQuietly(array $attributes = [], array $options = []): bool { @@ -1517,7 +1517,7 @@ public function pushQuietly(): bool /** * Save the model to the database without raising any events. * - * @param array $options + * @param array{touch?: null|bool, ...} $options */ public function saveQuietly(array $options = []): bool { @@ -1527,7 +1527,7 @@ public function saveQuietly(array $options = []): bool /** * Save the model to the database. * - * @param array $options + * @param array{touch?: null|bool, ...} $options */ public function save(array $options = []): bool { @@ -1574,7 +1574,7 @@ public function save(array $options = []): bool /** * Save the model to the database, ignoring specific unique constraint conflicts. * - * @param array $options + * @param array{touch?: null|bool, ...} $options * * @throws LogicException */ @@ -1608,7 +1608,7 @@ public function saveOrIgnore(array $options = [], array|string|null $uniqueBy = /** * Save the model to the database within a transaction. * - * @param array $options + * @param array{touch?: null|bool, ...} $options * * @throws Throwable */ @@ -1620,7 +1620,7 @@ public function saveOrFail(array $options = []): bool /** * Perform any actions that are necessary after the model is saved. * - * @param array $options + * @param array{touch?: null|bool, ...} $options */ protected function finishSave(array $options): void { diff --git a/src/notifications/src/RoutesNotifications.php b/src/notifications/src/RoutesNotifications.php index 603d8ef92..c32a1bb2e 100644 --- a/src/notifications/src/RoutesNotifications.php +++ b/src/notifications/src/RoutesNotifications.php @@ -8,9 +8,6 @@ use Hypervel\Contracts\Notifications\Dispatcher; use Hypervel\Support\Str; -/** - * @property string $email - */ trait RoutesNotifications { /** diff --git a/src/support/src/helpers.php b/src/support/src/helpers.php index 2ec0a3f20..ed216041c 100644 --- a/src/support/src/helpers.php +++ b/src/support/src/helpers.php @@ -517,9 +517,9 @@ function trait_uses_recursive($trait): array * @template TReturn * @template TDefault * - * @param TValue $value + * @param null|TValue $value * @param callable(TValue): TReturn $callback - * @param callable(TValue): TDefault|TDefault $default + * @param callable(null|TValue): TDefault|TDefault $default * @return ($value is empty ? TDefault : TReturn) */ function transform($value, callable $callback, $default = null) From f83f98320e4ddb5451e04c8481fd31dde0e96fa3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:48:14 +0000 Subject: [PATCH 125/303] Expose increment methods through model forwarding Reflect the increment and decrement family that Model exposes through its magic calls, including the quiet variants. Preserve the reflected method contract through the forwarding wrapper. Remove obsolete protected-method suppressions from relation incrementOrCreate implementations now that analysis recognizes their supported model calls. --- .../src/Eloquent/Relations/BelongsToMany.php | 2 +- .../src/Eloquent/Relations/HasOneOrMany.php | 2 +- .../Relations/HasOneOrManyThrough.php | 2 +- .../Methods/ForwardedModelMethodExtension.php | 19 +++++++++++++++++-- .../ForwardedModelMethodReflection.php | 2 +- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index ebce248c9..09e850c89 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -782,7 +782,7 @@ public function incrementOrCreate(array $attributes, string $column = 'count', f { return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void { if (! $instance->wasRecentlyCreated) { - $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + $instance->increment($column, $step, $extra); } }); } diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php index 1da23e3f8..a8c109b0d 100755 --- a/src/database/src/Eloquent/Relations/HasOneOrMany.php +++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php @@ -327,7 +327,7 @@ public function incrementOrCreate(array $attributes, string $column = 'count', f { return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void { if (! $instance->wasRecentlyCreated) { - $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + $instance->increment($column, $step, $extra); } }); } diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index d4d819fdd..05af32eea 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -342,7 +342,7 @@ public function incrementOrCreate(array $attributes, string $column = 'count', f { return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void { if (! $instance->wasRecentlyCreated) { - $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + $instance->increment($column, $step, $extra); } }); } diff --git a/src/phpstan/src/Methods/ForwardedModelMethodExtension.php b/src/phpstan/src/Methods/ForwardedModelMethodExtension.php index 22bedc4c3..59da60c2d 100644 --- a/src/phpstan/src/Methods/ForwardedModelMethodExtension.php +++ b/src/phpstan/src/Methods/ForwardedModelMethodExtension.php @@ -18,11 +18,23 @@ use PHPStan\Type\Type; /** - * Describe Eloquent builder methods forwarded through models. + * Describe the methods an Eloquent model's __call() and __callStatic() handle. */ #[AutowiredService] class ForwardedModelMethodExtension implements MethodsClassReflectionExtension { + // Model::__call() dispatches these protected methods; the list must stay in sync with it. + private const array MODEL_CALL_FORWARDED_METHODS = [ + 'increment', + 'decrement', + 'incrementQuietly', + 'decrementQuietly', + 'incrementEach', + 'decrementEach', + 'incrementEachQuietly', + 'decrementEachQuietly', + ]; + /** @var array */ private array $methods = []; @@ -83,7 +95,10 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN $method = null; - if (! $this->hasNativeOrDocumentedMethod($classReflection, $methodName)) { + // Unlike upstream, these are static too: __callStatic() calls them on a new model from the model's scope. + if (in_array($methodName, self::MODEL_CALL_FORWARDED_METHODS, true) && $classReflection->hasNativeMethod($methodName)) { + $method = new ForwardedModelMethodReflection($classReflection, $classReflection->getNativeMethod($methodName)); + } elseif (! $this->hasNativeOrDocumentedMethod($classReflection, $methodName)) { // Keep static, so PHPStan binds it to the called model. $builderType = $this->builderHelper->determineBuilderClass($classReflection->getName(), new StaticType($classReflection)); diff --git a/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php b/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php index a572968c6..3cb0f2574 100644 --- a/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php +++ b/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php @@ -14,7 +14,7 @@ use function array_map; /** - * Describe a builder method forwarded through an Eloquent model. + * Describe a method an Eloquent model's __call() or __callStatic() handles, which is callable both ways. */ class ForwardedModelMethodReflection extends ForwardedMethodReflection { From 18a0b1f65e6586e31e9bdc0f72b9376d4f0a2f79 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:48:15 +0000 Subject: [PATCH 126/303] Link morph query callbacks to their target models Add morph query stub contracts that derive callback builders from the supplied model class strings, including already typed callbacks. Mirror BelongsTo and MorphTo declarations for standalone stub validation and retain inline callback inference for unknown morph targets. Synchronize fluent return annotations, open model update options and resource-valued single-row upsert contracts in the affected stubs. Extend the existing source-to-stub mirror checks for the new declarations. --- .../common/Database/Eloquent/Relations.stub | 18 ++ src/phpstan/stubs/common/EloquentBuilder.stub | 158 +++++++++++++++++- src/phpstan/stubs/common/HasOneOrMany.stub | 2 +- src/phpstan/stubs/common/Model.stub | 2 +- tests/PHPStan/Unit/StubMirrorsTest.php | 4 + 5 files changed, 179 insertions(+), 5 deletions(-) diff --git a/src/phpstan/stubs/common/Database/Eloquent/Relations.stub b/src/phpstan/stubs/common/Database/Eloquent/Relations.stub index c202baa3e..1ff4caddf 100644 --- a/src/phpstan/stubs/common/Database/Eloquent/Relations.stub +++ b/src/phpstan/stubs/common/Database/Eloquent/Relations.stub @@ -6,3 +6,21 @@ use Hypervel\Database\Eloquent\Model; class Pivot extends Model {} + +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * + * @extends Relation + */ +class BelongsTo extends Relation +{} + +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * + * @extends BelongsTo + */ +class MorphTo extends BelongsTo +{} diff --git a/src/phpstan/stubs/common/EloquentBuilder.stub b/src/phpstan/stubs/common/EloquentBuilder.stub index 65fb0a30c..85d587218 100644 --- a/src/phpstan/stubs/common/EloquentBuilder.stub +++ b/src/phpstan/stubs/common/EloquentBuilder.stub @@ -5,6 +5,7 @@ namespace Hypervel\Database\Eloquent; use Closure; use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Database\Eloquent\Relations\MorphTo; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\MultipleRecordsFoundException; use Hypervel\Database\Query\Builder as QueryBuilder; @@ -19,8 +20,9 @@ use UnitEnum; * relation-of. Keep them in sync with the source. Native signatures repeat the source too, except * return types naming a collection, which stub validation cannot declare; their docblocks type them. * - * The polymorphic relationship queries need no stub: RelationshipQueryCallbackExtension types their - * callbacks from the given morph types. Upstream's SoftDeletes stub is replaced by SoftDeletingMacros. + * Unlike the source, the polymorphic relationship queries type their callbacks from the class strings + * in $types, so a callback that expects another model's query is reported. Upstream's SoftDeletes stub + * is replaced by SoftDeletingMacros. */ /** @@ -50,6 +52,7 @@ class Builder * * @param TRelation|Relation $relation * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + * @return $this * * @throws RuntimeException */ @@ -63,6 +66,7 @@ class Builder * * @param TRelation|Relation $relation * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + * @return $this */ public function doesntHave(Relation|string $relation, string $boolean = 'and', ?Closure $callback = null): static; @@ -74,6 +78,7 @@ class Builder * * @param TRelation|Relation $relation * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + * @return $this */ public function whereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; @@ -85,6 +90,7 @@ class Builder * * @param TRelation|Relation $relation * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + * @return $this */ public function orWhereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; @@ -96,6 +102,7 @@ class Builder * * @param TRelation|Relation $relation * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + * @return $this */ public function whereDoesntHave(Relation|string $relation, ?Closure $callback = null): static; @@ -107,9 +114,88 @@ class Builder * * @param TRelation|Relation $relation * @param null|(Closure(($relation is string ? builder-of : builder-of)): mixed) $callback + * @return $this */ public function orWhereDoesntHave(Relation|string $relation, ?Closure $callback = null): static; + /** + * Add a polymorphic relationship count / exists condition to the query. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(builder-of, string): mixed) $callback + * @return $this + */ + public function hasMorph(MorphTo|string $relation, string|array $types, string $operator = '>=', Expression|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static; + + /** + * Add a polymorphic relationship count / exists condition to the query. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(builder-of, string): mixed) $callback + * @return $this + */ + public function doesntHaveMorph(MorphTo|string $relation, string|array $types, string $boolean = 'and', ?Closure $callback = null): static; + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(builder-of, string): mixed) $callback + * @return $this + */ + public function whereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(builder-of, string): mixed) $callback + * @return $this + */ + public function orWhereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(builder-of, string): mixed) $callback + * @return $this + */ + public function whereDoesntHaveMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null): static; + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(builder-of, string): mixed) $callback + * @return $this + */ + public function orWhereDoesntHaveMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null): static; + /** * Add a basic where clause to a relationship query. * @@ -118,6 +204,7 @@ class Builder * * @param TRelation|Relation $relation * @param array|(Closure(($relation is string ? builder-of : builder-of)): mixed)|Expression|string $column + * @return $this */ public function whereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; @@ -129,6 +216,7 @@ class Builder * * @param TRelation|Relation $relation * @param array|(Closure(($relation is string ? builder-of : builder-of)): mixed)|Expression|string $column + * @return $this */ public function orWhereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; @@ -140,6 +228,7 @@ class Builder * * @param TRelation|Relation $relation * @param array|(Closure(($relation is string ? builder-of : builder-of)): mixed)|Expression|string $column + * @return $this */ public function whereDoesntHaveRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; @@ -151,9 +240,62 @@ class Builder * * @param TRelation|Relation $relation * @param array|(Closure(($relation is string ? builder-of : builder-of)): mixed)|Expression|string $column + * @return $this */ public function orWhereDoesntHaveRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + /** + * Add a polymorphic relationship condition to the query with a where clause. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param array|(Closure(builder-of): mixed)|Expression|string $column + * @return $this + */ + public function whereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a polymorphic relationship condition to the query with an "or where" clause. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param array|(Closure(builder-of): mixed)|Expression|string $column + * @return $this + */ + public function orWhereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a polymorphic relationship condition to the query with a doesn't have clause. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param array|(Closure(builder-of): mixed)|Expression|string $column + * @return $this + */ + public function whereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a polymorphic relationship condition to the query with an "or doesn't have" clause. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param array|(Closure(builder-of): mixed)|Expression|string $column + * @return $this + */ + public function orWhereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + /** * Add a relationship count / exists condition to the query with where clauses. * @@ -163,6 +305,7 @@ class Builder * * @param TRelation $relation * @param null|(Closure(($relation is literal-string ? builder-of : Builder<*>)|relation-of): mixed) $callback + * @return $this */ public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; @@ -173,6 +316,7 @@ class Builder * * @param TRelation $relation * @param array|(Closure(($relation is literal-string ? builder-of : Builder<*>)|relation-of): mixed)|Expression|string $column + * @return $this */ public function withWhereRelation(string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; @@ -183,6 +327,7 @@ class Builder * * @param TRelation|array|(callable(relation-of): mixed)|string> $relations * @param null|(Closure(relation-of): mixed)|string $callback + * @return $this */ public function with(array|string $relations, Closure|string|null $callback = null): static; @@ -192,6 +337,7 @@ class Builder * @template TRelation of string * * @param string|array|(callable(relation-of): mixed)|string> $relations + * @return $this */ public function withOnly(array|string $relations): static; @@ -401,7 +547,7 @@ class Builder /** * Insert new records or update the existing ones. * - * @param array, mixed>|array, mixed>> $values + * @param array, scalar|object|resource|null>|array, mixed>> $values * @param model-property|array> $uniqueBy * @param null|array, mixed> $update */ @@ -462,6 +608,7 @@ class Builder * makes a callback a subquery, so the callback type cannot follow the operator here. * * @param array, mixed>|(Closure(__benevolent): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this */ public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static; @@ -477,6 +624,7 @@ class Builder * Add a basic "where not" clause to the query. * * @param array, mixed>|(Closure(($operator is null ? static : QueryBuilder)): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this */ public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static; @@ -484,6 +632,7 @@ class Builder * Add an "or where not" clause to the query. * * @param array, mixed>|(Closure(($operator is null ? static : QueryBuilder)): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this */ public function orWhereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static; @@ -493,6 +642,7 @@ class Builder * The given key / value pairs will also be added as where conditions to the query. * * @param array, mixed>|Expression|model-property $attributes + * @return $this */ public function withAttributes(Expression|array|string $attributes, mixed $value = null, bool $asConditions = true): static; @@ -500,6 +650,7 @@ class Builder * Add an "order by" clause for a timestamp to the query. * * @param null|Closure|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this */ public function latest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static; @@ -507,6 +658,7 @@ class Builder * Add an "order by" clause for a timestamp to the query. * * @param null|Closure|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this */ public function oldest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static; diff --git a/src/phpstan/stubs/common/HasOneOrMany.stub b/src/phpstan/stubs/common/HasOneOrMany.stub index ddd825128..f95cf0e6d 100644 --- a/src/phpstan/stubs/common/HasOneOrMany.stub +++ b/src/phpstan/stubs/common/HasOneOrMany.stub @@ -119,7 +119,7 @@ abstract class HasOneOrMany extends Relation /** * Insert new records or update the existing ones. * - * @param array, mixed>|array, mixed>> $values + * @param array, scalar|object|resource|null>|array, mixed>> $values * @param model-property|array> $uniqueBy * @param null|array, mixed> $update */ diff --git a/src/phpstan/stubs/common/Model.stub b/src/phpstan/stubs/common/Model.stub index 429bf0ebe..e8439ec11 100644 --- a/src/phpstan/stubs/common/Model.stub +++ b/src/phpstan/stubs/common/Model.stub @@ -12,7 +12,7 @@ abstract class Model * Update the model in the database. * * @param array, mixed> $attributes - * @param array $options + * @param array{touch?: null|bool, ...} $options * @return bool */ public function update(array $attributes = [], array $options = []): bool; diff --git a/tests/PHPStan/Unit/StubMirrorsTest.php b/tests/PHPStan/Unit/StubMirrorsTest.php index d465011c2..6a4eb3cd2 100644 --- a/tests/PHPStan/Unit/StubMirrorsTest.php +++ b/tests/PHPStan/Unit/StubMirrorsTest.php @@ -9,8 +9,10 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\ModelNotFoundException; +use Hypervel\Database\Eloquent\Relations\BelongsTo; use Hypervel\Database\Eloquent\Relations\HasOneOrMany; use Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough; +use Hypervel\Database\Eloquent\Relations\MorphTo; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Query\Builder as QueryBuilder; use PHPUnit\Framework\Attributes\DataProvider; @@ -47,6 +49,8 @@ public static function classMirrors(): iterable { yield 'query builder' => ['Database/Query.stub', QueryBuilder::class]; yield 'model not found exception' => ['Database/Eloquent.stub', ModelNotFoundException::class]; + yield 'belongs to' => ['Database/Eloquent/Relations.stub', BelongsTo::class]; + yield 'morph to' => ['Database/Eloquent/Relations.stub', MorphTo::class]; yield 'arrayable' => ['Contracts/Support.stub', Arrayable::class]; yield 'eloquent builder' => ['EloquentBuilder.stub', EloquentBuilder::class]; yield 'has one or many' => ['HasOneOrMany.stub', HasOneOrMany::class]; From ecac1d59de45c1b02f0c3cd2c4162910da642131 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:48:15 +0000 Subject: [PATCH 127/303] Check relation names against their runtime query semantics Port relation existence diagnostics and share model receiver resolution with column checking. Follow the supported column-selection and dotted-path syntax for loading, existence, aggregate and morph queries; recognize booted dynamic relation registrations without invoking their resolvers. Preserve instantiated model generics during nested traversal. Keep wildcard column alternatives when simplifying model-property unions. Add focused rule fixtures for class-string and custom-builder receivers, dynamic relations, method-family syntax and generic nested paths. --- .../src/Rules/ModelPropertyColumnRule.php | 36 +- src/phpstan/src/Rules/ModelRuleHelper.php | 52 +++ .../src/Rules/RelationExistenceHelper.php | 183 ++++++++++ .../src/Rules/RelationExistenceRule.php | 161 +++++++++ .../GenericModelPropertyType.php | 5 +- .../Rules/RelationExistenceRuleTest.php | 342 ++++++++++++++++++ .../relation-existence-bare-relations.php | 23 ++ .../relation-existence-column-selection.php | 56 +++ .../data/relation-existence-hypervel.php | 79 ++++ .../Rules/data/relation-existence-loading.php | 127 +++++++ .../Rules/data/relation-existence-rule.php | 64 ++++ tests/PHPStan/phpunit-bootstrap.php | 6 + 12 files changed, 1101 insertions(+), 33 deletions(-) create mode 100644 src/phpstan/src/Rules/ModelRuleHelper.php create mode 100644 src/phpstan/src/Rules/RelationExistenceHelper.php create mode 100644 src/phpstan/src/Rules/RelationExistenceRule.php create mode 100644 tests/PHPStan/Rules/RelationExistenceRuleTest.php create mode 100644 tests/PHPStan/Rules/data/relation-existence-bare-relations.php create mode 100644 tests/PHPStan/Rules/data/relation-existence-column-selection.php create mode 100644 tests/PHPStan/Rules/data/relation-existence-hypervel.php create mode 100644 tests/PHPStan/Rules/data/relation-existence-loading.php create mode 100644 tests/PHPStan/Rules/data/relation-existence-rule.php diff --git a/src/phpstan/src/Rules/ModelPropertyColumnRule.php b/src/phpstan/src/Rules/ModelPropertyColumnRule.php index 6ff67406b..517bff23c 100644 --- a/src/phpstan/src/Rules/ModelPropertyColumnRule.php +++ b/src/phpstan/src/Rules/ModelPropertyColumnRule.php @@ -9,7 +9,6 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough; -use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\PHPStan\Methods\BuilderHelper; use Hypervel\PHPStan\Properties\ModelPropertyHelper; use Hypervel\PHPStan\Reflection\EloquentBuilderMethodReflection; @@ -21,7 +20,6 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Identifier; -use PhpParser\Node\Name; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Conditional; @@ -78,6 +76,7 @@ class ModelPropertyColumnRule implements Rule public function __construct( private BuilderHelper $builderHelper, private ModelPropertyHelper $modelPropertyHelper, + private ModelRuleHelper $modelRuleHelper, private RuleLevelHelper $ruleLevelHelper, ) { } @@ -120,7 +119,7 @@ public function processNode(Node $node, Scope $scope): array return []; } - $callerType = $this->callerType($node, $scope); + $callerType = $this->modelRuleHelper->callerType($node, $scope); $callerClasses = $callerType->getObjectClassReflections(); if (count($callerClasses) !== 1) { @@ -175,7 +174,7 @@ public function processNode(Node $node, Scope $scope): array */ private function checkDynamicWhere(MethodCall|StaticCall $node, Scope $scope, string $methodName): array { - $callerType = $this->callerType($node, $scope); + $callerType = $this->modelRuleHelper->callerType($node, $scope); $callerClasses = $callerType->getObjectClassReflections(); // A native method, such as the builder's where(), is not a dynamic where. @@ -183,7 +182,7 @@ private function checkDynamicWhere(MethodCall|StaticCall $node, Scope $scope, st return []; } - $modelType = $this->queriedModel($callerClasses[0], $callerType); + $modelType = $this->modelRuleHelper->queriedModel($callerClasses[0], $callerType); // An unknown model may have any column. if ($modelType === null || $modelType->getObjectClassReflections() === []) { @@ -227,31 +226,6 @@ private function checkDynamicWhere(MethodCall|StaticCall $node, Scope $scope, st return []; } - /** - * Return the type the method is called on. - */ - private function callerType(MethodCall|StaticCall $node, Scope $scope): Type - { - return match (true) { - $node instanceof MethodCall => $scope->getType($node->var), - $node->class instanceof Name => $scope->resolveTypeByName($node->class), - default => $scope->getType($node->class)->getObjectTypeOrClassStringObjectType(), - }; - } - - /** - * Return the model a builder, relation or model queries. - */ - private function queriedModel(ClassReflection $callerClass, Type $callerType): ?Type - { - return match (true) { - $callerClass->is(Relation::class) => $callerType->getTemplateType(Relation::class, 'TRelatedModel'), - $callerClass->is(EloquentBuilder::class) => $callerType->getTemplateType(EloquentBuilder::class, 'TModel'), - $callerClass->is(Model::class) => $callerType, - default => null, - }; - } - /** * Resolve the queried model and the class whose method runs, following Relation::__call() and * Model::__call() to the model's builder. @@ -260,7 +234,7 @@ private function queriedModel(ClassReflection $callerClass, Type $callerType): ? */ private function resolveCall(ClassReflection $callerClass, Type $callerType, string $methodName): array { - $modelType = $this->queriedModel($callerClass, $callerType); + $modelType = $this->modelRuleHelper->queriedModel($callerClass, $callerType); // A model's own method of that name is not forwarded. if ($modelType === null || ($callerClass->is(Model::class) && $callerClass->hasNativeMethod($methodName))) { diff --git a/src/phpstan/src/Rules/ModelRuleHelper.php b/src/phpstan/src/Rules/ModelRuleHelper.php new file mode 100644 index 000000000..ed33a8d1d --- /dev/null +++ b/src/phpstan/src/Rules/ModelRuleHelper.php @@ -0,0 +1,52 @@ + $scope->getType($node->var), + $node->class instanceof Name => $scope->resolveTypeByName($node->class), + default => $scope->getType($node->class)->getObjectTypeOrClassStringObjectType(), + }; + } + + /** + * Return the model a builder, relation or model queries. + */ + public function queriedModel(ClassReflection $callerClass, Type $callerType): ?Type + { + return match (true) { + $callerClass->is(Relation::class) => $callerType->getTemplateType(Relation::class, 'TRelatedModel'), + $callerClass->is(EloquentBuilder::class) => $callerType->getTemplateType(EloquentBuilder::class, 'TModel'), + $callerClass->is(Model::class) => $callerType, + default => null, + }; + } +} diff --git a/src/phpstan/src/Rules/RelationExistenceHelper.php b/src/phpstan/src/Rules/RelationExistenceHelper.php new file mode 100644 index 000000000..f7c3fa259 --- /dev/null +++ b/src/phpstan/src/Rules/RelationExistenceHelper.php @@ -0,0 +1,183 @@ + + */ + public function check(Type $relations, Type $modelType, Node $node, Scope $scope, bool $aggregate = false, bool $selectsColumns = true, bool $followsPaths = true): array + { + $errors = []; + + foreach (array_unique($this->relationNames($relations, $selectsColumns)) as $name) { + if ($aggregate) { + $alias = explode(' ', $name); + + if (count($alias) === 3 && strtolower($alias[1]) === 'as') { + $name = $alias[0]; + } + } + + $calledOnType = $modelType; + + // Aggregates call the relation method directly; they do not traverse dotted paths. + foreach ($aggregate || ! $followsPaths ? [$name] : explode('.', $name) as $relationName) { + $concreteModel = $this->concreteModel($calledOnType); + + if ($concreteModel === null) { + break; + } + + $modelReflection = $concreteModel->getObjectClassReflections()[0]; + + if ( + $modelReflection->hasMethod($relationName) + && (new ObjectType(Relation::class))->isSuperTypeOf( + ParametersAcceptorSelector::selectFromArgs($scope, [], $modelReflection->getMethod($relationName, $scope)->getVariants())->getReturnType(), + )->yes() + ) { + // Simulate a call so dynamic return type extensions can resolve the related model. Unlike upstream, + // the call is on the model's type, keeping a generic model's type arguments. + $calledOnType = $scope->getType(new MethodCall(new TypeExpr($concreteModel), $relationName)); + + continue; + } + + // Unlike upstream, a relation resolveRelationUsing() registers exists, though its related model is unknown. + if (! $this->hasDynamicRelation($modelReflection, $relationName)) { + $errors[$modelReflection->getName() . '::' . $relationName] = RuleErrorBuilder::message(sprintf( + "Relation '%s' is not found in %s model.", + $relationName, + $modelReflection->getName(), + ))->identifier('hypervel.relationExistence')->line($node->getStartLine())->build(); + } + + break; + } + } + + return array_values($errors); + } + + /** + * Return the single concrete model a model, builder or relation type queries, or null when it is not known. + */ + private function concreteModel(Type $type): ?Type + { + $callerClasses = $type->getObjectClassReflections(); + + if (count($callerClasses) !== 1) { + return null; + } + + $modelType = $this->modelRuleHelper->queriedModel($callerClasses[0], $type); + + if ($modelType === null) { + return null; + } + + $modelType = TypeCombinator::removeNull($modelType); + $modelClasses = $modelType->getObjectClassReflections(); + + // An abstract model or a union stands for models whose relations differ. + return count($modelClasses) === 1 && ! $modelClasses[0]->isAbstract() ? $modelType : null; + } + + /** + * Determine whether resolveRelationUsing() registered the relation, reading the registry without running it. + */ + private function hasDynamicRelation(ClassReflection $modelReflection, string $relationName): bool + { + try { + $model = $this->modelHelper->getInstance($modelReflection); + } catch (ReflectionException) { + // A model that cannot be instantiated has unknown relations. + return true; + } + + return $model->relationResolver($modelReflection->getName(), $relationName) !== null; + } + + /** + * Return the relation names an argument lists, with nested names prefixed by their parent's path. + * + * Eager loading parses column selections such as `posts:id` only for names without a callback. A name paired + * with a callback is used verbatim, so it is returned unchanged. + * + * @return list + */ + private function relationNames(Type $type, bool $selectsColumns, string $prefix = ''): array + { + $names = []; + + foreach ($type->getConstantStrings() as $name) { + $names[] = $prefix . ($selectsColumns ? explode(':', $name->getValue())[0] : $name->getValue()); + } + + foreach ($type->getConstantArrays() as $array) { + foreach ($array->getKeyTypes() as $index => $key) { + $value = $array->getValueTypes()[$index]; + + if ($key->isString()->yes()) { + $name = $prefix . $key->getValue(); + $names[] = $value->isArray()->no() ? $name : explode(':', $name)[0]; + + if ($value->isArray()->yes()) { + $names = array_merge($names, $this->relationNames($value, true, explode(':', $name)[0] . '.')); + } + } else { + foreach ($value->getConstantStrings() as $name) { + $names[] = $prefix . explode(':', $name->getValue())[0]; + } + } + } + } + + return $names; + } +} diff --git a/src/phpstan/src/Rules/RelationExistenceRule.php b/src/phpstan/src/Rules/RelationExistenceRule.php new file mode 100644 index 000000000..6b29e4b14 --- /dev/null +++ b/src/phpstan/src/Rules/RelationExistenceRule.php @@ -0,0 +1,161 @@ + + */ +#[AutowiredService] +class RelationExistenceRule implements Rule +{ + // These parse a column selection, such as `posts:id`, from each relation name. + private const array LOADING_METHODS = ['with', 'withonly', 'withwherehas', 'load', 'loadmissing']; + + // These pass the relation name to the existence query as written. + private const array QUERY_METHODS = [ + 'has', + 'orhas', + 'doesnthave', + 'ordoesnthave', + 'wherehas', + 'orwherehas', + 'wheredoesnthave', + 'orwheredoesnthave', + 'whererelation', + 'orwhererelation', + 'withwhererelation', + 'wheredoesnthaverelation', + 'orwheredoesnthaverelation', + ]; + + // These also get the relation by its name directly, so a dotted name is not a path. + private const array MORPH_METHODS = [ + 'hasmorph', + 'orhasmorph', + 'doesnthavemorph', + 'ordoesnthavemorph', + 'wherehasmorph', + 'orwherehasmorph', + 'wheredoesnthavemorph', + 'orwheredoesnthavemorph', + 'wheremorphrelation', + 'orwheremorphrelation', + 'wheremorphdoesnthaverelation', + 'orwheremorphdoesnthaverelation', + ]; + + /** + * Create the rule. + */ + public function __construct( + private ModelRuleHelper $modelRuleHelper, + private RelationExistenceHelper $relationExistenceHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report the relations the call names that the model does not define. + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! ($node instanceof MethodCall || $node instanceof StaticCall) || ! $node->name instanceof Identifier || $node->isFirstClassCallable()) { + return []; + } + + $method = $node->name->toLowerString(); + $aggregate = preg_match('/^(with|load)(aggregate|count|max|min|sum|avg|exists)$/', $method) === 1; + + $morph = in_array($method, self::MORPH_METHODS, true); + $verbatim = $morph || in_array($method, self::QUERY_METHODS, true); + + if (! $aggregate && ! $verbatim && ! in_array($method, self::LOADING_METHODS, true)) { + return []; + } + + $args = $node->getArgs(); + + if ($args === [] || $args[0]->unpack) { + return []; + } + + // with() keeps a name paired with a callback as written. Unlike upstream, withWhereHas() is not + // included: Hypervel's parses the column selection with a callback too. + $selectsColumns = ! $verbatim; + + if ($method === 'with') { + foreach ($args as $position => $arg) { + if ($arg->name === null ? $position !== 1 : $arg->name->toString() !== 'callback') { + continue; + } + + $selectsColumns = ! (new ObjectType(Closure::class))->isSuperTypeOf($scope->getType($arg->value))->yes(); + } + } + + foreach ($args as $arg) { + if ($arg->name !== null && in_array($arg->name->toString(), ['relation', 'relations'], true)) { + $args = [$arg]; + + break; + } + } + + // Unlike upstream, a class-string receiver, as in $modelClass::with(), is checked as its model. + $type = $this->modelRuleHelper->callerType($node, $scope); + $collection = (new ObjectType(Collection::class))->isSuperTypeOf($type)->yes(); + + if ($collection && str_starts_with($method, 'load')) { + $type = $type->getTemplateType(Collection::class, 'TModel'); + } elseif ($collection) { + return []; + } + + $variadic = in_array($method, ['with', 'load', 'loadmissing', 'withcount'], true) + || ($method === 'loadcount' && ! $collection && (new ObjectType(Model::class))->isSuperTypeOf($type)->yes()); + $args = $variadic && $scope->getType($args[0]->value)->isString()->yes() ? $args : [$args[0]]; + $errors = []; + + foreach ($args as $arg) { + if ($arg->unpack) { + continue; + } + + $errors = array_merge($errors, $this->relationExistenceHelper->check($scope->getType($arg->value), $type, $node, $scope, $aggregate, $selectsColumns, ! $morph)); + } + + return $errors; + } +} diff --git a/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php b/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php index 0ad325cff..13c108b11 100644 --- a/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php +++ b/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php @@ -150,8 +150,9 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $constantStrings = $type->getConstantStrings(); if (count($constantStrings) === 1) { - // Unlike upstream, an unknown property stays maybe, as accepts() keeps it. - return new IsSuperTypeOfResult($this->modelPropertyHelper->acceptsPropertyName($this->getGenericType(), $constantStrings[0]->getValue()), []); + // Unlike upstream, an unknown property stays maybe. Unlike accepts(), an abstract model is + // not yes either: union simplification would drop alternatives such as '*' from stub types. + return new IsSuperTypeOfResult($this->getGenericType()->hasInstanceProperty($constantStrings[0]->getValue()), []); } if ($type instanceof self) { diff --git a/tests/PHPStan/Rules/RelationExistenceRuleTest.php b/tests/PHPStan/Rules/RelationExistenceRuleTest.php new file mode 100644 index 000000000..eea50ed44 --- /dev/null +++ b/tests/PHPStan/Rules/RelationExistenceRuleTest.php @@ -0,0 +1,342 @@ + */ +class RelationExistenceRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(RelationExistenceRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/relation-existence-rule.php'], [ + [ + 'Relation \'foo\' is not found in App\User model.', + 5, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 6, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 7, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 8, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 9, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 10, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 11, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 12, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 13, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 15, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 16, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 17, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 18, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 19, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 20, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 21, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 22, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 23, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 25, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 26, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 27, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 28, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 29, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 30, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 31, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 32, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 33, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 35, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 36, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 37, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 38, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 39, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 40, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 41, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 42, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 43, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 45, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 46, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 47, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 48, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 49, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 49, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 50, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 53, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 54, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 55, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 56, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 59, + ], + [ + 'Relation \'bar\' is not found in App\User model.', + 59, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 61, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 62, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 63, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 64, + ], + ]); + } + + public function testBareRelationPaths(): void + { + $this->analyse([__DIR__ . '/data/relation-existence-bare-relations.php'], [ + ["Relation 'missing' is not found in App\\Comment model.", 22], + ]); + } + + public function testLoading(): void + { + $this->analyse([__DIR__ . '/data/relation-existence-loading.php'], [ + ["Relation 'missing' is not found in App\\User model.", 14], + ["Relation 'missing' is not found in App\\Account model.", 15], + ["Relation 'missing' is not found in App\\User model.", 16], + ["Relation 'missing' is not found in App\\Account model.", 17], + ["Relation 'missing' is not found in App\\User model.", 18], + ["Relation 'missing' is not found in App\\User model.", 19], + ["Relation 'missing' is not found in App\\User model.", 20], + ["Relation 'missing' is not found in App\\User model.", 21], + ["Relation 'missing' is not found in App\\User model.", 22], + ["Relation 'missing' is not found in App\\User model.", 23], + ["Relation 'missing' is not found in App\\User model.", 24], + ["Relation 'missing' is not found in App\\User model.", 25], + ["Relation 'missing' is not found in App\\User model.", 26], + ["Relation 'missing' is not found in App\\User model.", 27], + ["Relation 'missing' is not found in App\\User model.", 28], + ["Relation 'missing' is not found in App\\User model.", 29], + ["Relation 'missing' is not found in App\\Account model.", 30], + ["Relation 'missing' is not found in App\\User model.", 31], + ["Relation 'missing' is not found in App\\User model.", 32], + ["Relation 'missing' is not found in App\\User model.", 33], + ["Relation 'missing' is not found in App\\User model.", 34], + ["Relation 'missing' is not found in App\\User model.", 35], + ["Relation 'accounts.transactions' is not found in App\\User model.", 36], + ["Relation 'accounts as total' is not found in App\\User model.", 37], + ["Relation 'accounts as total' is not found in App\\User model.", 38], + ["Relation 'missing' is not found in App\\User model.", 64], + ["Relation 'missing' is not found in App\\User model.", 65], + ["Relation 'missing' is not found in App\\User model.", 66], + ["Relation 'missing' is not found in App\\User model.", 67], + ["Relation 'missing' is not found in App\\User model.", 68], + ["Relation 'missing' is not found in App\\User model.", 69], + ["Relation 'missing' is not found in App\\User model.", 70], + ["Relation 'missing' is not found in App\\User model.", 71], + ["Relation 'missing' is not found in App\\User model.", 72], + ["Relation 'missing' is not found in App\\User model.", 73], + ["Relation 'missing' is not found in App\\User model.", 74], + ["Relation 'missing' is not found in App\\User model.", 75], + ["Relation 'missing' is not found in App\\User model.", 76], + ["Relation 'missing' is not found in App\\User model.", 77], + ["Relation 'missing' is not found in App\\User model.", 78], + ["Relation 'missing' is not found in App\\User model.", 79], + ["Relation 'missing' is not found in App\\User model.", 80], + ["Relation 'missing' is not found in App\\User model.", 81], + ["Relation 'missing' is not found in App\\User model.", 82], + ["Relation 'missing' is not found in App\\User model.", 83], + ["Relation 'missing' is not found in App\\User model.", 84], + ["Relation 'missing' is not found in App\\User model.", 85], + ["Relation 'missing' is not found in App\\User model.", 86], + ["Relation 'save' is not found in App\\User model.", 87], + ["Relation 'missing' is not found in App\\User model.", 112], + ["Relation 'missing' is not found in App\\Comment model.", 119], + ]); + } + + public function testColumnSelection(): void + { + $this->analyse([__DIR__ . '/data/relation-existence-column-selection.php'], [ + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 18], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 19], + ["Relation 'transactions:id' is not found in App\\Account model.", 20], + ["Relation 'accounts:id' is not found in App\\User model.", 21], + ["Relation 'transactions:id' is not found in App\\Account model.", 22], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 23], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 24], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 25], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 26], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 27], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 28], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 29], + ["Relation 'transactions:id' is not found in App\\Account model.", 30], + // Hypervel: withWhereHas() parses the column selection with a callback too, so lines 31 and 32 are valid. + ["Relation 'accounts:id' is not found in App\\User model.", 33], + ]); + } + + public function testHypervelReceiversAndNameForms(): void + { + $this->analyse([__DIR__ . '/data/relation-existence-hypervel.php'], [ + ["Relation 'missing' is not found in App\\User model.", 47], + ["Relation 'missing' is not found in App\\User model.", 48], + ["Relation 'missing' is not found in App\\Team model.", 49], + ["Relation 'accounts:id' is not found in App\\User model.", 62], + ["Relation 'accounts:id' is not found in App\\User model.", 63], + ["Relation 'commentable.user' is not found in App\\Comment model.", 69], + ["Relation 'missing' is not found in App\\Post model.", 78], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/phpstan-rules.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/data/relation-existence-bare-relations.php b/tests/PHPStan/Rules/data/relation-existence-bare-relations.php new file mode 100644 index 000000000..e1eea6c73 --- /dev/null +++ b/tests/PHPStan/Rules/data/relation-existence-bare-relations.php @@ -0,0 +1,23 @@ +whereHas('items.category', static function (Builder $query): void { + $query->whereHas('labels', static function (Builder $query): void { + $query->whereKey(1); + }); +}); + +Owner::query()->whereHas('items', static function (Builder $query): void { + $query->whereHas('category'); +}); + +User::query()->whereHas('posts.comments', static function (Builder $query): void { + $query->whereHas('missing'); +}); diff --git a/tests/PHPStan/Rules/data/relation-existence-column-selection.php b/tests/PHPStan/Rules/data/relation-existence-column-selection.php new file mode 100644 index 000000000..1b527f387 --- /dev/null +++ b/tests/PHPStan/Rules/data/relation-existence-column-selection.php @@ -0,0 +1,56 @@ + $builder + * @param Collection $users + */ +function constrained(User $user, Builder $builder, Collection $users): void +{ + // Laravel keeps a relation name verbatim when it is paired with a callback. + User::with(['accounts:id,user_id' => static function () {}]); + $builder->with(['accounts:id,user_id' => static fn ($query) => $query]); + $builder->with(['accounts.transactions:id' => static function () {}]); + $builder->with(['accounts:id.transactions' => static function () {}]); + $builder->with(['accounts' => ['transactions:id' => static function () {}]]); + $builder->with('accounts:id,user_id', static function () {}); + $builder->with(callback: static function () {}, relations: 'accounts:id,user_id'); + $builder->withOnly(['accounts:id,user_id' => static function () {}]); + $user->load(['accounts:id,user_id' => static function () {}]); + $user->loadMissing(['accounts:id,user_id' => static function () {}]); + $users->load(['accounts:id,user_id' => static function () {}]); + $users->loadMissing(['accounts:id,user_id' => static function () {}]); + $user->accounts()->with(['transactions:id' => static function () {}]); + $builder->withWhereHas('accounts:id,user_id', static function () {}); + $builder->withWhereHas(callback: static function () {}, relation: 'accounts:id,user_id'); + $builder->withCount(['accounts:id' => static function () {}]); +} + +/** + * @param Builder $builder + * @param Collection $users + */ +function parsed(User $user, Builder $builder, Collection $users, ?\Closure $callback): void +{ + User::with('accounts:id,user_id'); + User::with(['accounts:id,user_id', 'group:id']); + $builder->with('accounts:id,user_id', 'group:id'); + $builder->with(['accounts:id' => ['transactions:id']]); + $builder->with(['accounts:id' => ['transactions' => static function () {}]]); + $builder->with(['accounts' => static function () {}, 'group:id']); + $builder->withOnly(['accounts.transactions:id']); + $user->load('accounts:id,user_id', 'group:id'); + $user->loadMissing(['accounts.transactions:id']); + $users->load(['accounts:id,user_id']); + $builder->withWhereHas('accounts:id,user_id'); + $builder->withWhereHas('accounts:id,user_id', null); + $builder->withWhereHas('accounts:id,user_id', $callback); + $builder->withCount('accounts:id'); +} diff --git a/tests/PHPStan/Rules/data/relation-existence-hypervel.php b/tests/PHPStan/Rules/data/relation-existence-hypervel.php new file mode 100644 index 000000000..461891a28 --- /dev/null +++ b/tests/PHPStan/Rules/data/relation-existence-hypervel.php @@ -0,0 +1,79 @@ + + */ +class ScopedBuilder extends Builder +{ +} + +/** + * @template TRelated of Model + */ +class GenericModel extends Model +{ + /** + * @return HasMany + */ + public function children(): HasMany + { + throw new LogicException; + } +} + +/** + * @param class-string $modelClass + * @param ScopedBuilder $scopedBuilder + */ +function receivers(string $modelClass, ScopedBuilder $scopedBuilder): void +{ + $modelClass::with('missing'); + $scopedBuilder->has('missing'); + Team::query()->whereHas('missing'); +} + +function dynamicRelations(): void +{ + User::with('dynamicAccounts'); + ChildUser::with('dynamicAccounts'); + User::with('dynamicAccounts.anything'); +} + +function columnSelections(): void +{ + User::withWhereHas('accounts:id', static function (): void {}); + User::whereHas('accounts:id'); + User::withWhereRelation('accounts:id', 'active', true); +} + +function morphPaths(): void +{ + Comment::hasMorph('commentable', Post::class); + Comment::hasMorph('commentable.user', Post::class); +} + +/** + * @param GenericModel $model + */ +function genericPaths(GenericModel $model): void +{ + $model->load('children.users'); + $model->load('children.missing'); +} diff --git a/tests/PHPStan/Rules/data/relation-existence-loading.php b/tests/PHPStan/Rules/data/relation-existence-loading.php new file mode 100644 index 000000000..74da3cd3b --- /dev/null +++ b/tests/PHPStan/Rules/data/relation-existence-loading.php @@ -0,0 +1,127 @@ + $users */ +function loading(User $user, Collection $users, string $name): void +{ + $user->load('missing'); + $user->loadMissing('accounts.missing'); + $user->load(['accounts.transactions', 'missing']); + $user->load(['accounts' => ['missing' => static function () {}]]); + $user->load(['missing' => static function () {}, 'accounts']); + $user->load('accounts:id,user_id', 'missing:id'); + $user->loadMissing('accounts', 'missing'); + $user->loadCount('missing as total'); + $user->loadCount('accounts', 'missing'); + $user->loadAggregate('missing AS total', 'id', 'sum'); + $user->loadMax('missing', 'id'); + $user->loadMin('missing', 'id'); + $user->loadSum(['missing as total' => static function () {}], 'id'); + $user->loadAvg('missing', 'id'); + $user->loadExists('missing'); + $users->load('missing'); + $users->loadMissing('accounts.missing'); + $users->loadCount('missing as total'); + User::withCount('missing as total'); + User::withSum('missing', 'id'); + User::withOnly('missing'); + User::orWhereRelation('missing', 'id', 1); + $user->loadCount('accounts.transactions'); + $user->loadCount('accounts as total'); + $user->load('accounts as total'); + + $user->load(['accounts:id' => ['transactions:id']]); + $user->loadMissing(['accounts.transactions:id', 'group:id']); + $user->load(['accounts' => static function () {}, 'group']); + $user->loadCount(['accounts AS total' => static function () {}]); + $user->loadSum('accounts as total', 'id'); + $user->loadExists('accounts'); + $users->load('accounts.transactions'); + $users->loadCount('accounts as total'); + $users->has('not_a_relation'); + $user->load($name); + $user->loadMissing([$name]); + $user->loadCount([$name => static function () {}]); + $user->loadSum($name, 'id'); + User::with(['accounts' => $name]); + User::with('accounts', static function () {}); +} + +function unknownModel(Model $model): void +{ + $model->load('unknown'); +} + +function otherApis(User $user): void +{ + User::withAggregate('missing', 'id', 'max'); + User::withMax('missing', 'id'); + User::withMin('missing', 'id'); + User::withAvg('missing', 'id'); + User::withExists('missing'); + User::withWhereRelation('missing', 'id', 1); + User::whereDoesntHaveRelation('missing', 'id', 1); + User::orWhereDoesntHaveRelation('missing', 'id', 1); + User::hasMorph('missing', [User::class]); + User::orHasMorph('missing', [User::class]); + User::doesntHaveMorph('missing', [User::class]); + User::orDoesntHaveMorph('missing', [User::class]); + User::whereHasMorph('missing', [User::class]); + User::orWhereHasMorph('missing', [User::class]); + User::whereDoesntHaveMorph('missing', [User::class]); + User::orWhereDoesntHaveMorph('missing', [User::class]); + User::whereMorphRelation('missing', [User::class], 'id', 1); + User::orWhereMorphRelation('missing', [User::class], 'id', 1); + User::whereMorphDoesntHaveRelation('missing', [User::class], 'id', 1); + User::orWhereMorphDoesntHaveRelation('missing', [User::class], 'id', 1); + $user->loadSum(column: 'id', relations: 'missing as total'); + $user->LOAD('missing'); + $user->load(['missing' => ['nested']]); + $user->load('save'); + + $user->loadSum(column: 'id', relations: 'accounts AS total'); + User::withSum(column: 'id', relation: 'accounts'); + $user->loadCount('accounts:id'); + $user->load(...); + User::withCount('accounts', static function () {}); +} + +/** @param User|\App\Post $model */ +function ambiguousModel($model): void +{ + $model->load('unknown'); +} + +function unrelatedReceiver(): void +{ + $receiver = new class { + public function load(string $name): void {} + }; + $receiver->load('unknown'); +} + +function nullableModel(?User $user): void +{ + $user?->load('missing'); + $user?->load('accounts'); +} + +function unknownRelatedModel(\App\Comment $comment): void +{ + $comment->load('commentable.anything'); + $comment->load(['commentable.anything', 'missing']); +} + +/** @param array $names */ +function dynamicList(User $user, array $names): void +{ + $user->load($names); + $user->load(...$names); +} diff --git a/tests/PHPStan/Rules/data/relation-existence-rule.php b/tests/PHPStan/Rules/data/relation-existence-rule.php new file mode 100644 index 000000000..68a1b3b6b --- /dev/null +++ b/tests/PHPStan/Rules/data/relation-existence-rule.php @@ -0,0 +1,64 @@ +has('foo'); +\App\User::query()->orHas('foo'); +\App\User::query()->doesntHave('foo'); +\App\User::query()->orDoesntHave('foo'); +\App\User::query()->whereHas('foo'); +\App\User::query()->withWhereHas('foo'); +\App\User::query()->orWhereHas('foo'); +\App\User::query()->whereDoesntHave('foo'); +\App\User::query()->orWhereDoesntHave('foo'); + +\App\User::first()->group()->has('foo'); +\App\User::first()->group()->orHas('foo'); +\App\User::first()->group()->doesntHave('foo'); +\App\User::first()->group()->orDoesntHave('foo'); +\App\User::first()->group()->whereHas('foo'); +\App\User::first()->group()->withWhereHas('foo'); +\App\User::first()->group()->orWhereHas('foo'); +\App\User::first()->group()->whereDoesntHave('foo'); +\App\User::first()->group()->orWhereDoesntHave('foo'); + +\App\User::query()->has('accounts.foo'); +\App\User::query()->orHas('accounts.foo'); +\App\User::query()->doesntHave('accounts.foo'); +\App\User::query()->orDoesntHave('accounts.foo'); +\App\User::query()->whereHas('accounts.foo'); +\App\User::query()->withWhereHas('accounts.foo'); +\App\User::query()->orWhereHas('accounts.foo'); +\App\User::query()->whereDoesntHave('accounts.foo'); +\App\User::query()->orWhereDoesntHave('accounts.foo'); + +\App\Post::query()->has('users.transactions.foo'); +\App\Post::query()->orHas('users.transactions.foo'); +\App\Post::query()->doesntHave('users.transactions.foo'); +\App\Post::query()->orDoesntHave('users.transactions.foo'); +\App\Post::query()->whereHas('users.transactions.foo'); +\App\Post::query()->withWhereHas('users.transactions.foo'); +\App\Post::query()->orWhereHas('users.transactions.foo'); +\App\Post::query()->whereDoesntHave('users.transactions.foo'); +\App\Post::query()->orWhereDoesntHave('users.transactions.foo'); + +\App\User::with('foo'); +\App\User::query()->with('foo'); +\App\User::with(['foo', 'accounts']); +\App\User::query()->with(['foo', 'accounts']); +\App\User::query()->with(['foo', 'accounts.foo']); +\App\User::query()->with(['foo' => static function () { +}, 'accounts']); + +\App\User::with('foo:id,name'); +\App\User::query()->with('foo:id,name'); +\App\User::with(['foo:id', 'accounts']); +\App\User::query()->with(['foo:id', 'accounts']); + +/** @var array{foo:string}|array{0: 'bar'} $a */ +\App\User::with($a); + +\App\User::query()->whereRelation('foo', 'id'); +\App\User::first()->group()->whereRelation('foo', 'id'); +\App\User::query()->whereRelation('accounts.foo', 'id'); +\App\Post::query()->whereRelation('users.transactions.foo', 'id'); diff --git a/tests/PHPStan/phpunit-bootstrap.php b/tests/PHPStan/phpunit-bootstrap.php index 47f225830..d4ebd2824 100644 --- a/tests/PHPStan/phpunit-bootstrap.php +++ b/tests/PHPStan/phpunit-bootstrap.php @@ -2,9 +2,12 @@ declare(strict_types=1); +use App\Account; +use App\User; use Composer\Autoload\ClassLoader; use Hypervel\Console\Application; use Hypervel\Container\Container; +use Hypervel\Database\Eloquent\Relations\HasMany; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Console\ClosureCommand; use Hypervel\Testbench\Bootstrapper; @@ -52,6 +55,9 @@ // stay out of PHPUnit's per-test handler checks. require_once __DIR__ . '/../../src/phpstan/bootstrap.php'; +// A dynamic relation, registered at boot as a provider would. +User::resolveRelationUsing('dynamicAccounts', static fn (User $user): HasMany => $user->hasMany(Account::class)); + // Package configuration sources, which the config analysis reads from the loaded providers. Container::getInstance()->register(ConfigFixtureServiceProvider::class); Container::getInstance()->register(OverlappingConfigServiceProvider::class); From 247246f2842949ecc72b35e6aedbecd8205e5135 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:48:15 +0000 Subject: [PATCH 128/303] Port model and query integration diagnostics Merge the remaining applicable Larastan integration cases for model properties, builders, relations, callbacks, helpers and custom PHPDoc types. Adapt expected diagnostics to Hypervel contracts and retain meaningful differences beside their fixtures. Cover wrong-model morph callbacks, resource-valued upserts, invalid save options and fluent subclass returns. Give the basic enum fixture a usable case and remove unused blanket suppressions. Factory and eager-loading collection cases remain assigned to their implementation flows. Validation of the combined checkpoint: formatting clean, three PHPStan configurations clean, and 2,468 analyzer tests passing, including 52 integration cases. --- tests/PHPStan/Integration/IntegrationTest.php | 394 +++++++++++++++++- tests/PHPStan/Integration/data/bug-1744.php | 21 + tests/PHPStan/Integration/data/bug-2074.php | 13 + tests/PHPStan/Integration/data/bug-2426.php | 14 + tests/PHPStan/Integration/data/bug-2431.php | 26 ++ tests/PHPStan/Integration/data/bug-2516.php | 54 +++ tests/PHPStan/Integration/data/bug-2545.php | 13 + tests/PHPStan/Integration/data/bug-2582.php | 24 ++ .../data/bug-final_model_query.php | 15 + .../Integration/data/builder-of-type.php | 117 ++++++ .../Integration/data/collection-of-type.php | 71 ++++ .../data/custom-builder-return-this.php | 44 ++ .../data/eloquent-builder-paginate.php | 36 ++ .../Integration/data/eloquent-where.php | 54 +++ tests/PHPStan/Integration/data/facades.php | 15 + .../data/framework-docblock-types.php | 34 ++ tests/PHPStan/Integration/data/helpers.php | 25 ++ .../Integration/data/model-builder.php | 49 +++ .../Integration/data/model-properties.php | 119 ++++++ .../data/model-property-builder.php | 64 +++ .../model-property-fill-and-insert-l12-6.php | 35 ++ .../Integration/data/model-property-fill.php | 32 ++ .../data/model-property-find-sole-find-or.php | 21 + ...model-property-force-create-many-l12-8.php | 24 ++ .../model-property-force-create-quietly.php | 11 + .../model-property-increment-each-l13-3.php | 16 + .../model-property-increment-or-create.php | 12 + .../Integration/data/model-property-model.php | 110 +++++ .../model-property-mutator-and-casting.php | 33 ++ ...odel-property-relation-create-variants.php | 14 + ...ty-relation-increment-or-create-l13-35.php | 18 + ...ty-relation-increment-or-create-l13-37.php | 8 + .../data/model-property-relation.php | 17 + .../data/model-property-static-call.php | 39 ++ .../data/model-property-upsert.php | 44 ++ .../model-property-with-attributes-l12-4.php | 9 + .../data/model-property-with-attributes.php | 26 ++ .../Integration/data/model-save-options.php | 7 + .../Integration/data/model-with-relations.php | 40 ++ .../data/relation-cursor-paginate.php | 33 ++ .../data/relationship-query-callbacks.php | 144 +++++++ .../app/Casts/BasicEnumeration.php | 2 + 42 files changed, 1893 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Integration/data/bug-1744.php create mode 100644 tests/PHPStan/Integration/data/bug-2074.php create mode 100644 tests/PHPStan/Integration/data/bug-2426.php create mode 100644 tests/PHPStan/Integration/data/bug-2431.php create mode 100644 tests/PHPStan/Integration/data/bug-2516.php create mode 100644 tests/PHPStan/Integration/data/bug-2545.php create mode 100644 tests/PHPStan/Integration/data/bug-2582.php create mode 100644 tests/PHPStan/Integration/data/bug-final_model_query.php create mode 100644 tests/PHPStan/Integration/data/builder-of-type.php create mode 100644 tests/PHPStan/Integration/data/collection-of-type.php create mode 100644 tests/PHPStan/Integration/data/custom-builder-return-this.php create mode 100644 tests/PHPStan/Integration/data/eloquent-builder-paginate.php create mode 100644 tests/PHPStan/Integration/data/eloquent-where.php create mode 100644 tests/PHPStan/Integration/data/facades.php create mode 100644 tests/PHPStan/Integration/data/framework-docblock-types.php create mode 100644 tests/PHPStan/Integration/data/helpers.php create mode 100644 tests/PHPStan/Integration/data/model-builder.php create mode 100644 tests/PHPStan/Integration/data/model-properties.php create mode 100644 tests/PHPStan/Integration/data/model-property-builder.php create mode 100644 tests/PHPStan/Integration/data/model-property-fill-and-insert-l12-6.php create mode 100644 tests/PHPStan/Integration/data/model-property-fill.php create mode 100644 tests/PHPStan/Integration/data/model-property-find-sole-find-or.php create mode 100644 tests/PHPStan/Integration/data/model-property-force-create-many-l12-8.php create mode 100644 tests/PHPStan/Integration/data/model-property-force-create-quietly.php create mode 100644 tests/PHPStan/Integration/data/model-property-increment-each-l13-3.php create mode 100644 tests/PHPStan/Integration/data/model-property-increment-or-create.php create mode 100644 tests/PHPStan/Integration/data/model-property-model.php create mode 100644 tests/PHPStan/Integration/data/model-property-mutator-and-casting.php create mode 100644 tests/PHPStan/Integration/data/model-property-relation-create-variants.php create mode 100644 tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-35.php create mode 100644 tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-37.php create mode 100644 tests/PHPStan/Integration/data/model-property-relation.php create mode 100644 tests/PHPStan/Integration/data/model-property-static-call.php create mode 100644 tests/PHPStan/Integration/data/model-property-upsert.php create mode 100644 tests/PHPStan/Integration/data/model-property-with-attributes-l12-4.php create mode 100644 tests/PHPStan/Integration/data/model-property-with-attributes.php create mode 100644 tests/PHPStan/Integration/data/model-save-options.php create mode 100644 tests/PHPStan/Integration/data/model-with-relations.php create mode 100644 tests/PHPStan/Integration/data/relation-cursor-paginate.php create mode 100644 tests/PHPStan/Integration/data/relationship-query-callbacks.php diff --git a/tests/PHPStan/Integration/IntegrationTest.php b/tests/PHPStan/Integration/IntegrationTest.php index e6ef342ca..61b0a003e 100644 --- a/tests/PHPStan/Integration/IntegrationTest.php +++ b/tests/PHPStan/Integration/IntegrationTest.php @@ -40,8 +40,137 @@ public static function dataIntegrationTests(): iterable { self::getContainer(); - yield [__DIR__ . '/data/blade-view.php']; + yield [__DIR__ . '/data/bug-2074.php']; + + // Hypervel: the request body parameters are natively typed as Laravel documents them, so null and other + // objects throw a TypeError instead of reaching Guzzle. + yield [ + __DIR__ . '/data/bug-2426.php', + [ + 9 => ['Parameter #2 $data of static method Hypervel\Http\Client\PendingRequest::post() expects array|Hypervel\Contracts\Support\Arrayable|JsonSerializable, null given.'], + 10 => ['Parameter #2 $data of static method Hypervel\Http\Client\PendingRequest::post() expects array|Hypervel\Contracts\Support\Arrayable|JsonSerializable, object{}&stdClass given.'], + 11 => ['Parameter #2 $data of static method Hypervel\Http\Client\PendingRequest::patch() expects array|Hypervel\Contracts\Support\Arrayable|JsonSerializable, null given.'], + 12 => ['Parameter #2 $data of static method Hypervel\Http\Client\PendingRequest::put() expects array|Hypervel\Contracts\Support\Arrayable|JsonSerializable, null given.'], + 13 => ['Parameter #2 $data of static method Hypervel\Http\Client\PendingRequest::delete() expects array|Hypervel\Contracts\Support\Arrayable|JsonSerializable, null given.'], + ], + ]; + + yield [__DIR__ . '/data/bug-2431.php']; + + // Hypervel: User's public email() accessor, whose Attribute has no generic types, reads as mixed, because + // Eloquent calls it. Upstream ignores it. + yield 'bug-2516' => [ + __DIR__ . '/data/bug-2516.php', + [ + 14 => ['Method Bug2516\Stations::all() should return Hypervel\Support\Collection but returns Hypervel\Support\Collection.'], + ], + ]; + + // Hypervel: a model that cannot be instantiated has no known schema, so its properties are not reported missing. + yield 'bug-2545' => [ + __DIR__ . '/data/bug-2545.php', + [ + 7 => ['Class Bug2545\CalendarEvent implements unknown interface Bug2545\MissingInterface.'], + ], + ]; + + yield 'bug-2582' => [__DIR__ . '/data/bug-2582.php']; + + // Hypervel: a date attribute accepts what HasAttributes::fromDateTime() converts. + yield 'bug-1744' => [ + __DIR__ . '/data/bug-1744.php', + [ + 13 => ['Property App\ModelWithDateColumnCasts::$created_at (DateTimeInterface|float|int|string|null) does not accept array.'], + ], + ]; + + yield [__DIR__ . '/data/bug-final_model_query.php']; yield [__DIR__ . '/data/test-case-extension.php']; + yield [__DIR__ . '/data/model-builder.php']; + + yield 'eloquent-builder-paginate' => [ + __DIR__ . '/data/eloquent-builder-paginate.php', + [ + 34 => ['Parameter #1 $perPage of method Hypervel\Database\Eloquent\Builder::paginate() expects (Closure(int): (int|null))|int|null, Closure(string): 15 given.'], + 35 => ['Parameter #1 $perPage of method Hypervel\Database\Eloquent\Builder::paginate() expects (Closure(int): (int|null))|int|null, Closure(int): \'15\' given.'], + ], + ]; + + yield 'relation-cursor-paginate' => [__DIR__ . '/data/relation-cursor-paginate.php']; + + // Hypervel: Model::newEloquentBuilder() types $query natively, so an untyped override has no PHPDoc type to inherit. + yield [ + __DIR__ . '/data/custom-builder-return-this.php', + [ + 14 => ['Method CustomBuilderReturnThis\Post::newEloquentBuilder() has parameter $query with no type specified.'], + ], + ]; + + yield 'framework-docblock-types' => [__DIR__ . '/data/framework-docblock-types.php']; + + // Hypervel: eager-loading constraints accept any callable, as Builder::combineConstraints() calls them. + yield 'model-with-relations' => [ + __DIR__ . '/data/model-with-relations.php', + [ + 27 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array|(callable(Hypervel\Database\Eloquent\Relations\Relation<*, *, *>): mixed)|string>|string, non-empty-array given.'], + 28 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array<\'accounts\'|int, array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string>|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void} given.'], + 29 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array<\'accounts\'|int, array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string>|string, array{accounts: Closure(int): void} given.'], + 30 => ['Relation \'missing\' is not found in App\User model.'], + 31 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array|(callable(Hypervel\Database\Eloquent\Relations\Relation<*, *, *>): mixed)|string>|string, non-empty-array given.'], + 34 => ['Parameter #1 $relation of function ModelWithRelationsIntegration\acceptsAccounts expects Hypervel\Database\Eloquent\Relations\HasMany, Hypervel\Database\Eloquent\Relations\BelongsTo given.'], + ], + ]; + + // Hypervel: a where column may also be a builder or relation subquery, as the query builder runs it. + yield 'eloquent-where' => [ + __DIR__ . '/data/eloquent-where.php', + [ + 44 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Eloquent\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, Closure(Hypervel\Database\Query\Builder): Hypervel\Database\Query\Builder given.'], + 45 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::firstWhere() expects array|(Closure(Hypervel\Database\Eloquent\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, Closure(Hypervel\Database\Query\Builder): Hypervel\Database\Query\Builder given.'], + 46 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::whereNot() expects array|(Closure(Hypervel\Database\Eloquent\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, Closure(Hypervel\Database\Query\Builder): Hypervel\Database\Query\Builder given.'], + 47 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhereNot() expects array|(Closure(Hypervel\Database\Eloquent\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, Closure(Hypervel\Database\Query\Builder): Hypervel\Database\Query\Builder given.'], + 49 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, static-Closure(Hypervel\Database\Eloquent\Builder): Hypervel\Database\Eloquent\Builder given.'], + 50 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::firstWhere() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, static-Closure(Hypervel\Database\Eloquent\Builder): Hypervel\Database\Eloquent\Builder given.'], + 51 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::whereNot() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, static-Closure(Hypervel\Database\Eloquent\Builder): Hypervel\Database\Eloquent\Builder given.'], + 52 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhereNot() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, static-Closure(Hypervel\Database\Eloquent\Builder): Hypervel\Database\Eloquent\Builder given.'], + 53 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhere() expects array|(Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, Closure(int): void given.'], + ], + ]; + + // Hypervel: forwarded calls name the model. + yield [ + __DIR__ . '/data/relationship-query-callbacks.php', + [ + 69 => ['Parameter #3 $callback of static method App\Comment::whereHasMorph() expects (Closure(App\PostBuilder, string): mixed)|null, Closure(int): void given.'], + 70 => ['Parameter #3 $callback of static method App\Comment::whereHasMorph() expects (Closure(App\PostBuilder, string): mixed)|null, Closure(Hypervel\Database\Eloquent\Builder, int): void given.'], + 71 => ['Parameter #3 $callback of static method App\Comment::whereHasMorph() expects (Closure(Hypervel\Database\Eloquent\Builder, string): mixed)|null, Closure(App\PostBuilder): void given.'], + 91 => ['Parameter #2 $callback of static method App\User::withWhereHas() expects (Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany): mixed)|null, Closure(Hypervel\Database\Eloquent\Builder): void given.'], + 92 => ['Parameter #2 $callback of method Hypervel\Database\Eloquent\Builder::withWhereHas() expects (Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany): mixed)|null, Closure(Hypervel\Database\Eloquent\Relations\Relation): void given.'], + 126 => ['Parameter #2 $column of static method App\User::whereRelation() expects array|(Closure(Hypervel\Database\Eloquent\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|string, Closure(App\PostBuilder): void given.'], + 127 => ['Parameter #3 $column of static method App\Comment::whereMorphRelation() expects array|(Closure(App\PostBuilder): mixed)|Hypervel\Contracts\Database\Query\Expression|string, Closure(int): void given.'], + 128 => ['Parameter #3 $column of static method App\Comment::whereMorphRelation() expects array|(Closure(App\PostBuilder): mixed)|Hypervel\Contracts\Database\Query\Expression|string, Closure(App\PostBuilder, string): void given.'], + 129 => ['Parameter #2 $column of static method App\User::withWhereRelation() expects array|(Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany): mixed)|Hypervel\Contracts\Database\Query\Expression|string, Closure(Hypervel\Database\Eloquent\Builder): void given.'], + 130 => ['Parameter #2 $callback of method Hypervel\Database\Eloquent\Builder::with() expects (Closure(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string|null, Closure(Hypervel\Database\Eloquent\Builder): void given.'], + 142 => ['Parameter #3 $callback of static method App\Comment::whereHasMorph() expects (Closure(App\PostBuilder, string): mixed)|null, Closure(Hypervel\Database\Eloquent\Builder): void given.'], + ], + ]; + + // Hypervel: User's public email() accessor reads as mixed; date attributes read as CarbonInterface; SQLite + // has no unsigned columns; AsEnumCollection also stores the enum's backing values. + yield [ + __DIR__ . '/data/model-properties.php', + [ + 71 => ['Expected type string, actual: mixed'], + 74 => ['Function testDateCast() should return Carbon\Carbon|null but returns Carbon\CarbonInterface|null.'], + 100 => ['Property App\User::$decimal (float|int|numeric-string) does not accept \'foo\'.'], + 101 => ['Property App\User::$decimal (float|int|numeric-string) does not accept array{}.'], + 110 => ['Property App\User::$nullable_decimal (float|int|numeric-string|null) does not accept \'foo\'.'], + 111 => ['Property App\User::$nullable_decimal (float|int|numeric-string|null) does not accept array{}.'], + 118 => ['Property App\User::$enum_collection (iterable|null) does not accept array.'], + ], + ]; + + yield [__DIR__ . '/data/blade-view.php']; yield [ __DIR__ . '/data/view-string-content.php', @@ -84,6 +213,263 @@ public static function dataIntegrationTests(): iterable ], ]; + // Hypervel: retry() passes its callbacks any Throwable, as Laravel's does. + yield [ + __DIR__ . '/data/helpers.php', + [ + 14 => ['Parameter #3 $sleepMilliseconds of function retry expects Carbon\CarbonInterval|(Closure(int, Throwable): (Carbon\CarbonInterval|float|int))|float|int, Closure(int, Exception): 0 given.'], + 16 => ['Parameter #4 $when of function retry expects (callable(Throwable): bool)|null, Closure(Exception): true given.'], + ], + ]; + yield [__DIR__ . '/data/facades.php']; + + // Hypervel: in the model-property entries, calls forwarded through a model or relation name it as the + // declaring class. Here, a where column may also be a subquery, a key that is not a literal may name a + // property, and get() takes the array or string Laravel documents. + yield [ + __DIR__ . '/data/model-property-builder.php', + [ + 15 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::firstWhere() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 16 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::firstWhere() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'id\'|\'unionNotExisting\' given.'], + 17 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 19 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 20 => ['Parameter #1 $column of method App\User::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 25 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhere() expects array|(Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 26 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhere() expects array|(Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 27 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhere() expects array|(Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, array{foo: \'foo\'} given.'], + 30 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::value() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.'], + 35 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Builder::first() expects \'*\'|array|model property of App\User, array{\'foo\', \'bar\'} given.'], + 36 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Builder::first() expects \'*\'|array|model property of App\User, \'foo\' given.'], + 39 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'roles.foo\' given.'], + 41 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Builder::get() expects \'*\'|array|model property of App\User, Hypervel\Contracts\Database\Query\Expression given.'], + 44 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::createQuietly() expects array, array given.'], + 50 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\Account, \'foo\' given.'], + 54 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\Account, \'foo\' given.'], + ], + ]; + + // Hypervel: findSole() and findOr() take the column array or string Laravel documents. + yield 'find-sole-find-or' => [ + __DIR__ . '/data/model-property-find-sole-find-or.php', + [ + 6 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Builder::findSole() expects \'*\'|array|model property of App\User, array{\'foo\'} given.'], + 7 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Builder::findSole() expects \'*\'|array|model property of App\User, \'foo\' given.'], + 10 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Builder::findSole() expects \'*\'|array|model property of App\User, Hypervel\Contracts\Database\Query\Expression given.'], + 11 => ['Parameter #2 $columns of static method App\User::findSole() expects \'*\'|array|model property of App\User, array{\'foo\'} given.'], + 12 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Relations\HasMany::findSole() expects \'*\'|array|model property of App\Account, array{\'foo\'} given.'], + 14 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Builder::findOr() expects \'*\'|array|(Closure(): null)|model property of App\User, array{\'foo\'} given.'], + 15 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Builder::findOr() expects \'*\'|array|(Closure(): null)|model property of App\User, \'foo\' given.'], + 20 => ['Parameter #2 $columns of static method App\User::findOr() expects \'*\'|array|(Closure(): null)|model property of App\User, array{\'foo\'} given.'], + 21 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Relations\HasMany::findOr() expects \'*\'|array|(Closure(): null)|model property of App\Account, array{\'foo\'} given.'], + ], + ]; + + yield 'force-create-quietly' => [ + __DIR__ . '/data/model-property-force-create-quietly.php', + [ + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::forceCreateQuietly() expects array, array given.'], + 9 => ['Parameter #1 $attributes of static method App\User::forceCreateQuietly() expects array, array given.'], + ], + ]; + + yield 'increment-or-create' => [ + __DIR__ . '/data/model-property-increment-or-create.php', + [ + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::incrementOrCreate() expects array, array given.'], + 7 => ['Parameter #2 $column of method Hypervel\Database\Eloquent\Builder::incrementOrCreate() expects model property of App\User, string given.'], + 9 => ['Parameter #5 $extra of method Hypervel\Database\Eloquent\Builder::incrementOrCreate() expects array, array given.'], + 12 => ['Parameter #1 $attributes of static method App\User::incrementOrCreate() expects array, array given.'], + ], + ]; + + // REMOVED: model-property-increment-or-create-relation.php; it asserts Laravel releases before 13.35. + + // Hypervel: a single row may also bind resources. + yield 'upsert' => [ + __DIR__ . '/data/model-property-upsert.php', + [ + 6 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::upsert() expects array|bool|float|int|object|resource|string|null>, array given.'], + 7 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::upsert() expects array|bool|float|int|object|resource|string|null>, array> given.'], + 8 => ['Parameter #2 $uniqueBy of method Hypervel\Database\Eloquent\Builder::upsert() expects array|model property of App\User, string given.'], + 9 => ['Parameter #2 $uniqueBy of method Hypervel\Database\Eloquent\Builder::upsert() expects array|model property of App\User, array given.'], + 10 => ['Parameter #3 $update of method Hypervel\Database\Eloquent\Builder::upsert() expects array|null, array given.'], + 18 => ['Parameter #1 $values of static method App\User::upsert() expects array|bool|float|int|object|resource|string|null>, array given.'], + 22 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::upsert() expects array|bool|float|int|object|resource|string|null>, array given.'], + 23 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::upsert() expects array|bool|float|int|object|resource|string|null>, array> given.'], + 24 => ['Parameter #2 $uniqueBy of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::upsert() expects array|model property of App\Account, string given.'], + 36 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::upsert() expects array|bool|float|int|object|resource|string|null>, array> given.'], + ], + ]; + + yield 'with-attributes' => [ + __DIR__ . '/data/model-property-with-attributes.php', + [ + 6 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\User, array given.'], + 7 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.'], + 14 => ['Parameter #1 $attributes of static method App\User::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\User, array given.'], + 18 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasMany::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\Account, array given.'], + 19 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasMany::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\Account, string given.'], + ], + ]; + + yield 'with-attributes-as-conditions' => [ + __DIR__ . '/data/model-property-with-attributes-l12-4.php', + [ + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\User, array given.'], + 6 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.'], + ], + ]; + + yield 'fill-and-insert' => [ + __DIR__ . '/data/model-property-fill-and-insert-l12-6.php', + [ + 5 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::fillAndInsert() expects array>, array> given.'], + 6 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::fillAndInsertOrIgnore() expects array>, array> given.'], + 7 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::fillAndInsertGetId() expects array, array given.'], + 8 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::fillForInsert() expects array>, array> given.'], + 16 => ['Parameter #1 $values of static method App\User::fillAndInsert() expects array>, array> given.'], + 29 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::fillAndInsert() expects array>, array> given.'], + 34 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Relations\HasMany::fillAndInsert() expects array>, array> given.'], + ], + ]; + + yield 'increment-each' => [ + __DIR__ . '/data/model-property-increment-each-l13-3.php', + [ + 5 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Builder::incrementEach() expects array, array{foo: 1} given.'], + 7 => ['Parameter #2 $extra of method Hypervel\Database\Eloquent\Builder::incrementEach() expects array, array given.'], + 10 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Builder::decrementEach() expects array, array{foo: 1} given.'], + 12 => ['Parameter #2 $extra of method Hypervel\Database\Eloquent\Builder::decrementEach() expects array, array given.'], + 15 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Relations\HasMany::incrementEach() expects array, array{foo: 1} given.'], + 16 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Relations\HasMany::decrementEach() expects array, array{foo: 1} given.'], + ], + ]; + + yield 'relation-increment-or-create' => [ + __DIR__ . '/data/model-property-relation-increment-or-create-l13-35.php', + [ + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::incrementOrCreate() expects array, array given.'], + 7 => ['Parameter #2 $column of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::incrementOrCreate() expects model property of App\Account, string given.'], + 9 => ['Parameter #5 $extra of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::incrementOrCreate() expects array, array given.'], + 12 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany::incrementOrCreate() expects array, array given.'], + 13 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::incrementOrCreate() expects array, array given.'], + 15 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\BelongsToMany::incrementOrCreate() expects array, array given.'], + 17 => ['Parameter #2 $column of method Hypervel\Database\Eloquent\Relations\BelongsToMany::incrementOrCreate() expects model property of App\Role, string given.'], + 18 => ['Parameter #5 $extra of method Hypervel\Database\Eloquent\Relations\BelongsToMany::incrementOrCreate() expects array, array given.'], + ], + ]; + + yield 'relation-increment-or-create-through' => [ + __DIR__ . '/data/model-property-relation-increment-or-create-l13-37.php', + [ + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough>::incrementOrCreate() expects array, array given.'], + 7 => ['Parameter #2 $column of method Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough::incrementOrCreate() expects model property of App\Car, string given.'], + 8 => [ + 'Parameter #2 $column of method Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough::incrementOrCreate() expects model property of App\Car, string given.', + 'Parameter #5 $extra of method Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough::incrementOrCreate() expects array, array given.', + ], + ], + ]; + + yield 'model-save-options' => [ + __DIR__ . '/data/model-save-options.php', + [ + 5 => ['Parameter #1 $options of method Hypervel\Database\Eloquent\Model::save() expects array{touch?: bool|null, ...}, array{touch: \'no\'} given.'], + 7 => ['Parameter #2 $options of method Hypervel\Database\Eloquent\Model::update() expects array{touch?: bool|null, ...}, array{touch: \'no\'} given.'], + ], + ]; + + // Hypervel: a model without a readable table has no known schema, and properties that only some union + // alternatives have may exist. + yield [ + __DIR__ . '/data/model-property-model.php', + [ + 18 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::update() expects array, array given.'], + 48 => ['Parameter #1 $property of method ModelPropertyModel\ModelPropertyCustomMethods::foo() expects model property of App\User, string given.'], + 66 => ['Parameter #1 $property of method ModelPropertyModel\ModelPropertyCustomMethodsInNormalClass::foo() expects model property of App\User, string given.'], + 92 => ['Parameter #1 $userModelProperty of function ModelPropertyModel\acceptsUserProperty expects model property of App\User, model property of App\Account given.'], + 105 => ['Parameter #1 $accountModelProperty of function ModelPropertyModel\acceptsUserOrAccountProperty expects model property of App\Account|App\User, string given.'], + ], + ]; + + // Hypervel: a model without a readable table has no known schema. + yield 'fill' => [ + __DIR__ . '/data/model-property-fill.php', + [ + 10 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::fill() expects array, array given.'], + 11 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::forceFill() expects array, array given.'], + ], + ]; + + yield [ + __DIR__ . '/data/model-property-relation.php', + [ + 4 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Relations\HasMany::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\Account, \'foo\' given.'], + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::create() expects array, array given.'], + 6 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::firstOrNew() expects array, array given.'], + 7 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::firstOrCreate() expects array, array given.'], + 8 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::updateOrCreate() expects array, array given.'], + 10 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Relations\BelongsToMany::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\Post, \'foo\' given.'], + 12 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::createOrFirst() expects array, array given.'], + ], + ]; + + yield 'relation-create-variants' => [ + __DIR__ . '/data/model-property-relation-create-variants.php', + [ + 4 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::createQuietly() expects array, array given.'], + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreate() expects array, array given.'], + 6 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreateQuietly() expects array, array given.'], + 7 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::createManyQuietly() expects iterable>, array> given.'], + ], + ]; + + yield 'relation-force-create-many' => [ + __DIR__ . '/data/model-property-force-create-many-l12-8.php', + [ + 6 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreateMany() expects iterable>, array> given.'], + 7 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreateManyQuietly() expects iterable>, array> given.'], + 8 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreateMany() expects iterable>, array> given.'], + 23 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreateMany() expects iterable>, array> given.'], + ], + ]; + + // Hypervel: a model without a readable table has no known schema. + yield [ + __DIR__ . '/data/model-property-static-call.php', + [ + 10 => ['Parameter #1 $attributes of static method App\User::create() expects array, array given.'], + 14 => ['Parameter #1 $attributes of static method App\User::create() expects array, array given.'], + ], + ]; + + yield [ + __DIR__ . '/data/collection-of-type.php', + [ + 36 => ['Parameter #1 $accounts of method CollectionOfType\CollectionOfTypeTest::acceptsAccountCollection() expects App\AccountCollection<(int|string), App\Account>, Hypervel\Database\Eloquent\Collection given.'], + 39 => ['Parameter #1 $users of method CollectionOfType\CollectionOfTypeTest::acceptsUserCollection() expects Hypervel\Database\Eloquent\Collection<(int|string), App\User>, App\AccountCollection given.'], + 69 => ['Parameter #1 $collection of method CollectionOfType\GenericTest::acceptsAccountCollection() expects App\AccountCollection<(int|string), App\Account>, Hypervel\Database\Eloquent\Collection<(int|string), App\User> given.'], + ], + ]; + + yield [ + __DIR__ . '/data/builder-of-type.php', + [ + 44 => ['Parameter #1 $accountQuery of method BuilderOfType\BuilderOfTypeTest::acceptsAccountBuilder() expects Hypervel\Database\Eloquent\Builder, Hypervel\Database\Eloquent\Builder given.'], + 47 => ['Parameter #1 $userQuery of method BuilderOfType\BuilderOfTypeTest::acceptsUserBuilder() expects Hypervel\Database\Eloquent\Builder, Hypervel\Database\Eloquent\Builder given.'], + 50 => ['Parameter #1 $userQuery of method BuilderOfType\BuilderOfTypeTest::acceptsUserBuilder() expects Hypervel\Database\Eloquent\Builder, App\ChildTeamBuilder given.'], + 89 => ['Parameter #1 $builder of method BuilderOfType\GenericTest::acceptsAccountBuilder() expects Hypervel\Database\Eloquent\Builder, Hypervel\Database\Eloquent\Builder given.'], + ], + ]; + + yield [ + __DIR__ . '/data/model-property-mutator-and-casting.php', + [ + 24 => ['Parameter #1 $lineOne of class ModelPropertyMutatorAndCasting\Address constructor expects string, mixed given.'], + 25 => ['Parameter #2 $lineTwo of class ModelPropertyMutatorAndCasting\Address constructor expects string, mixed given.'], + ], + ]; + yield [ __DIR__ . '/../Fixtures/Integration/view-helper.php', [ @@ -164,12 +550,12 @@ private function runAnalyse(string $file, ?array $allAnalysedFiles = null): arra $file = $this->getFileHelper()->normalizePath($file); /** @var Analyser $analyser */ - $analyser = self::getContainer()->getByType(Analyser::class); // @phpstan-ignore-line + $analyser = self::getContainer()->getByType(Analyser::class); /** @var FileHelper $fileHelper */ $fileHelper = self::getContainer()->getByType(FileHelper::class); - $errors = $analyser->analyse([$file], null, null, true, $allAnalysedFiles)->getErrors(); // @phpstan-ignore-line + $errors = $analyser->analyse([$file], null, null, true, $allAnalysedFiles)->getErrors(); foreach ($errors as $error) { $this->assertSame($fileHelper->normalizePath($file), $error->getFilePath()); @@ -212,7 +598,7 @@ private function assertSameErrorMessages(string $file, array $expectedErrors, ar public static function getAdditionalConfigFiles(): array { return [ - __DIR__ . '/../phpstan-tests.neon', + __DIR__ . '/../Type/data/config-check-model-properties.neon', ]; } } diff --git a/tests/PHPStan/Integration/data/bug-1744.php b/tests/PHPStan/Integration/data/bug-1744.php new file mode 100644 index 000000000..ea010c43a --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-1744.php @@ -0,0 +1,21 @@ +created_at = '2024-01-01 00:00:00'; + $model->created_at = Carbon::now(); + $model->created_at = null; + $model->created_at = ['raw' => '2024-01-01 00:00:00', 'formatted' => null]; + + $model->updated_at = Carbon::now(); + $model->updated_at = '2024-01-01 00:00:00'; + + $model->deleted_at = Carbon::now(); + $model->deleted_at = '2024-01-01 00:00:00'; + $model->deleted_at = null; +} diff --git a/tests/PHPStan/Integration/data/bug-2074.php b/tests/PHPStan/Integration/data/bug-2074.php new file mode 100644 index 000000000..612450836 --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-2074.php @@ -0,0 +1,13 @@ + $query */ +function test(Builder $query): void +{ + assertType('Hypervel\Database\Eloquent\Builder<*>', $query->where('foo', 'bar')); +} diff --git a/tests/PHPStan/Integration/data/bug-2426.php b/tests/PHPStan/Integration/data/bug-2426.php new file mode 100644 index 000000000..290be066c --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-2426.php @@ -0,0 +1,14 @@ +post('localhost', [ + [ + 'name' => 'a', + 'contents' => 'a', + ], + ]); + + Http::asMultipart()->post('localhost', [ + [ + 'name' => 'a', + 'contents' => 'a', + ], + [ + 'name' => 'b', + 'contents' => 'b', + ], + ]); +} diff --git a/tests/PHPStan/Integration/data/bug-2516.php b/tests/PHPStan/Integration/data/bug-2516.php new file mode 100644 index 000000000..cbd0205fb --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-2516.php @@ -0,0 +1,54 @@ + */ + public function all(): Collection + { + return User::query() + ->get() + ->map(fn ($user) => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ]); + } + + /** @return Collection */ + public function names(): Collection + { + return User::query()->get()->mapWithKeys(fn ($user) => [$user->id => $user->name]); + } + + /** @return Collection */ + public function labels(): Collection + { + $labels = User::query()->get()->map(fn ($user) => 'unknown'); + + $labels->push('other'); + + return $labels; + } + + /** + * @param EloquentCollection $users + * + * @return Collection + */ + public function sums(EloquentCollection $users): Collection + { + $sums = $users->mapWithKeys(fn ($user) => [$user->id => 0.]); + + foreach ($users as $user) { + $sums[$user->id] += (float) $user->id; + } + + return $sums; + } +} diff --git a/tests/PHPStan/Integration/data/bug-2545.php b/tests/PHPStan/Integration/data/bug-2545.php new file mode 100644 index 000000000..a38adb8f8 --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-2545.php @@ -0,0 +1,13 @@ +id; + } +} diff --git a/tests/PHPStan/Integration/data/bug-2582.php b/tests/PHPStan/Integration/data/bug-2582.php new file mode 100644 index 000000000..a683101c9 --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-2582.php @@ -0,0 +1,24 @@ + $builder + * @param Collection $users + */ +function test(Builder $builder, User $user, Collection $users): void +{ + User::with(['accounts' => ['transactions', 'group' => ['users']]]); + User::with(['accounts' => ['group' => fn (Relation $relation) => $relation->latest()]]); + $builder->with(['accounts' => ['group' => ['users']]]); + $builder->withOnly(['accounts' => ['group' => fn (Relation $relation) => $relation->latest()]]); + $user->load(['accounts' => ['group' => ['users']]]); + $user->loadMissing(['accounts' => ['group' => ['users']]]); + $users->load(['accounts' => ['group' => ['users']]]); + $users->loadMissing(['accounts' => ['group' => ['users']]]); +} diff --git a/tests/PHPStan/Integration/data/bug-final_model_query.php b/tests/PHPStan/Integration/data/bug-final_model_query.php new file mode 100644 index 000000000..a70455269 --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-final_model_query.php @@ -0,0 +1,15 @@ + */ + public function test(): Builder + { + return self::query(); + } +} diff --git a/tests/PHPStan/Integration/data/builder-of-type.php b/tests/PHPStan/Integration/data/builder-of-type.php new file mode 100644 index 000000000..6db35118f --- /dev/null +++ b/tests/PHPStan/Integration/data/builder-of-type.php @@ -0,0 +1,117 @@ + $userQuery + */ + public function acceptsUserBuilder(Builder $userQuery): void + { + } + + /** + * @phpstan-param builder-of $accountQuery + */ + public function acceptsAccountBuilder(Builder $accountQuery): void + { + } + + /** + * @phpstan-param builder-of $teamQuery + */ + public function acceptsTeamBuilder(ChildTeamBuilder $teamQuery): void + { + } + + public function testValidUsage(): void + { + $this->acceptsUserBuilder(User::query()); + $this->acceptsAccountBuilder(Account::query()); + $this->acceptsTeamBuilder(Team::query()); + } + + public function testInvalidUsage(): void + { + // passing a User builder to a method expecting Account builder + $this->acceptsAccountBuilder(User::query()); + + // passing an Account builder to the method expecting a User builder + $this->acceptsUserBuilder(Account::query()); + + // passing a Team builder to the method expecting a User builder + $this->acceptsUserBuilder(Team::query()); + } +} + +class GenericTest { + /** + * @template TModel of \Hypervel\Database\Eloquent\Model + * @param class-string $modelClass + * + * @return builder-of + */ + function getQueryBuilder(string $modelClass): Builder + { + return $modelClass::query(); + } + + /** + * @param Builder $builder + */ + function acceptsAccountBuilder(Builder $builder): void + { + + } + + /** + * @param ChildTeamBuilder $builder + */ + function acceptsTeamBuilder(ChildTeamBuilder $builder): void + { + + } +} + +function testGenericBuilderHandler(GenericTest $generic): void +{ + $userBuilder = $generic->getQueryBuilder(User::class); + $accountBuilder = $generic->getQueryBuilder(Account::class); + $teamBuilder = $generic->getQueryBuilder(Team::class); + + $generic->acceptsAccountBuilder($userBuilder); + $generic->acceptsAccountBuilder($accountBuilder); + $generic->acceptsTeamBuilder($teamBuilder); +} + +class StaticBuilderQueries extends \Hypervel\Database\Eloquent\Model +{ + /** @return builder-of */ + public function builder(): Builder + { + return $this->newQuery(); + } + + /** @return Builder */ + public function queryKeepingStatic(): Builder + { + return $this->builder(); + } +} + +/** + * @template TModel of \Hypervel\Database\Eloquent\Model + * @param TModel $model + * @return builder-of + */ +function queryFromModel($model): Builder +{ + return $model->newQuery(); +} diff --git a/tests/PHPStan/Integration/data/collection-of-type.php b/tests/PHPStan/Integration/data/collection-of-type.php new file mode 100644 index 000000000..3ecb0180a --- /dev/null +++ b/tests/PHPStan/Integration/data/collection-of-type.php @@ -0,0 +1,71 @@ + $users + */ + public function acceptsUserCollection(Collection $users): void + { + } + + /** + * @phpstan-param collection-of $accounts + */ + public function acceptsAccountCollection(Collection $accounts): void + { + } + + public function testValidUsage(): void + { + $this->acceptsUserCollection(User::all()); + $this->acceptsAccountCollection(Account::all()); + } + + public function testInvalidUsage(): void + { + //passing a User collection to a method-expecting Account collection + $this->acceptsAccountCollection(User::all()); + + //passing an Account collection to the method expecting a User collection + $this->acceptsUserCollection(Account::all()); + } +} + +class GenericTest { + /** + * @template TModel of \Hypervel\Database\Eloquent\Model + * @param class-string $modelClass + * + * @return collection-of + */ + function getAllModels(string $modelClass): Collection + { + return $modelClass::all(); + } + + /** + * @param AccountCollection $collection + */ + function acceptsAccountCollection(AccountCollection $collection): void + { + + } +} + +function testGenericCollectionHandler(GenericTest $generic): void +{ + $userCollection = $generic->getAllModels(User::class); + $accountCollection = $generic->getAllModels(Account::class); + + $generic->acceptsAccountCollection($userCollection); + $generic->acceptsAccountCollection($accountCollection); +} diff --git a/tests/PHPStan/Integration/data/custom-builder-return-this.php b/tests/PHPStan/Integration/data/custom-builder-return-this.php new file mode 100644 index 000000000..52cead711 --- /dev/null +++ b/tests/PHPStan/Integration/data/custom-builder-return-this.php @@ -0,0 +1,44 @@ + */ + public function newEloquentBuilder($query): PostBuilder + { + return new PostBuilder($query); + } +} + +/** + * @template TModel of Post + * + * @extends Builder + */ +class PostBuilder extends Builder +{ + /** @return $this */ + public function wherePublished(): static + { + return $this->where('status', 'published'); + } + + /** @return $this */ + public function withActiveScope(): static + { + return $this->withGlobalScope('active', static fn (Builder $query) => $query); + } + + /** @return $this */ + public function withoutActiveScope(): static + { + return $this->withoutGlobalScope('active'); + } +} diff --git a/tests/PHPStan/Integration/data/eloquent-builder-paginate.php b/tests/PHPStan/Integration/data/eloquent-builder-paginate.php new file mode 100644 index 000000000..4524fb32a --- /dev/null +++ b/tests/PHPStan/Integration/data/eloquent-builder-paginate.php @@ -0,0 +1,36 @@ + */ +function perPageCallback(): LengthAwarePaginator +{ + return User::query()->paginate(fn (int $total): int => $total > 1000 ? 50 : 15); +} + +/** @return LengthAwarePaginator */ +function namedPerPageCallbackWithTotal(): LengthAwarePaginator +{ + return User::query()->paginate(perPage: fn (int $total): ?int => $total > 1000 ? 50 : null, total: 1200); +} + +/** @return LengthAwarePaginator */ +function forwardedPerPageCallback(): LengthAwarePaginator +{ + return User::paginate(static fn ($total) => 15); +} + +/** @return LengthAwarePaginator */ +function relationPerPageCallback(User $user): LengthAwarePaginator +{ + return $user->accounts()->paginate(fn (int $total): int => 15); +} + +function invalidPerPageCallback(): void +{ + User::query()->paginate(fn (string $total): int => 15); + User::query()->paginate(fn (int $total): string => '15'); +} diff --git a/tests/PHPStan/Integration/data/eloquent-where.php b/tests/PHPStan/Integration/data/eloquent-where.php new file mode 100644 index 000000000..0cd64578d --- /dev/null +++ b/tests/PHPStan/Integration/data/eloquent-where.php @@ -0,0 +1,54 @@ +where(static function (QueryBuilder $query): void { + $query->selectRaw('COUNT(*)')->from('posts'); + }, '=', 2); + + $subquery = fn (QueryBuilder $query) => $query->selectRaw('COUNT(*)')->from('posts'); + $nested = static fn (EloquentBuilder $query) => $query->whereKey(1); + + User::where($subquery, 2); + $user->firstWhere($subquery, '=', 2); + User::query()->whereNot($subquery, '=', 2); + User::query()->orWhereNot($subquery, '=', 2); + $user->posts()->where($subquery, '=', 2); + Post::where($subquery, '=', 2); + + User::query()->where($nested); + User::firstWhere($nested); + User::query()->whereNot($nested, null, 2); + User::query()->orWhereNot($nested, null); + Post::where(static fn (PostBuilder $query) => $query->whereKey(1)); + + User::query()->where(column: $subquery, operator: '=', value: 2); + User::query()->where(column: $nested, value: 2); + + User::orWhere($nested); + User::query()->orWhere($subquery, null); + User::query()->orWhere($nested, null, null); + User::query()->orWhere($subquery, '=', 2); + Post::orWhere(static fn (PostBuilder $query) => $query->whereKey(1)); + + User::query()->where($subquery); + User::query()->firstWhere($subquery); + User::query()->whereNot($subquery, null); + User::query()->orWhereNot($subquery, null, null); + + User::query()->where($nested, '=', 2); + User::query()->firstWhere($nested, '=', 2); + User::query()->whereNot($nested, '=', 2); + User::query()->orWhereNot($nested, '=', 2); + User::query()->orWhere(function (int $query): void {}); +} diff --git a/tests/PHPStan/Integration/data/facades.php b/tests/PHPStan/Integration/data/facades.php new file mode 100644 index 000000000..a4b6d7785 --- /dev/null +++ b/tests/PHPStan/Integration/data/facades.php @@ -0,0 +1,15 @@ +usingConnection(null, static fn () => 42); + + return $migrator->usingConnection($connection, static fn () => 42); +} + +function notificationId(Notification $notification): string +{ + return $notification->id ?? 'pending'; +} + +/** @param resource $stream */ +function prepareMail(MailMessage $mail, $stream): void +{ + $mail->view = null; + $mail->from = ['sender@example.com']; + $mail->from = [['sender@example.com' => 'Sender'], null, 'tracking' => 42]; + $mail->replyTo = array_filter($mail->replyTo, static fn (array $address): bool => $address[0] !== 'internal@example.com'); + $mail->replyTo = ['support' => [['support@example.com' => 'Support']]]; + $mail->attachments = array_filter($mail->attachments, static fn (array $attachment): bool => $attachment['file'] !== 'internal.pdf'); + $mail->attachments['invoice'] = ['file' => Attachment::fromData(static fn (): string => 'invoice', 'invoice.txt'), 'options' => [], 'tracking' => 42]; + $mail->rawAttachments['stream'] = ['data' => $stream, 'name' => 'invoice.txt', 'options' => [], 'tracking' => 42]; + $mail->rawAttachments = array_filter($mail->rawAttachments, static fn (array $attachment): bool => $attachment['name'] !== 'internal.txt'); +} diff --git a/tests/PHPStan/Integration/data/helpers.php b/tests/PHPStan/Integration/data/helpers.php new file mode 100644 index 000000000..c54815979 --- /dev/null +++ b/tests/PHPStan/Integration/data/helpers.php @@ -0,0 +1,25 @@ + $user->toArray()); +} + +function retryAcceptsCallableAsSleepMilliseconds(): void +{ + retry(5, function (int $attempt): bool { + return false; + }, function (int $attempt, \Exception $e): int { + return 0; + }, function (\Exception $e): bool { + return true; + }); +} + +function translationsAcceptClosureReplacements(): void +{ + trans('Hello', ['wrap' => fn (string $chunk): string => '' . $chunk . '']); + __('Hello', ['wrap' => fn (string $chunk): string => '' . $chunk . '']); +} diff --git a/tests/PHPStan/Integration/data/model-builder.php b/tests/PHPStan/Integration/data/model-builder.php new file mode 100644 index 000000000..4d67d7cdd --- /dev/null +++ b/tests/PHPStan/Integration/data/model-builder.php @@ -0,0 +1,49 @@ + */ + public static function testQueryStatic(): Builder + { + return static::query(); + } + + public static function testCreateStatic(): static + { + return static::query()->create(); + } + + public static function testCreateSelf(): self + { + return self::query()->create(); + } +} + +function test(User|Account $userOrAccount): void +{ + \App\User::query()->where(DB::raw('1'), 1)->get(); + + /** @see https://github.com/larastan/larastan/issues/1806 */ + \App\User::query()->orderBy(Post::query()->select('id')->whereColumn('user_id', 'users.id')); + \App\User::query()->orderByDesc(Post::query()->select('id')->whereColumn('user_id', 'users.id')); + + \App\User::query()->get()->pluck('computed'); + + /** @see https://github.com/larastan/larastan/issues/1952 */ + Team::query()->where('name', 'Team A')->orderBy('name')->get(); + + assertType('int|false', $userOrAccount->increment('counter')); + assertType('int|false', $userOrAccount->decrement('counter')); + assertType('int', $userOrAccount->incrementQuietly('counter')); + assertType('int', $userOrAccount->decrementQuietly('counter')); +} diff --git a/tests/PHPStan/Integration/data/model-properties.php b/tests/PHPStan/Integration/data/model-properties.php new file mode 100644 index 000000000..ec91d8556 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-properties.php @@ -0,0 +1,119 @@ +blocked = 0; +} + +function testBooleanAcceptsOne(User $user): void +{ + $user->blocked = 1; +} + +function testBooleanAcceptsFalse(User $user): void +{ + $user->blocked = false; +} + +function testWriteToProperty(User $user): void +{ + $user->created_at = 'test'; + $user->created_at = now(); + $user->created_at = null; + $user->created_at = BaseCarbon::now(); + $user->unsigned_integer = -5; // Should raise an error + $user->unsigned_integer_method = -5; // Should raise an error +} + +/** @return mixed */ +function testKnownColumnNameWithUnknownType(User $user) +{ + $user->unknown_column = 5; + $user->unknown_column = 'foo'; + + return $user->unknown_column; +} + +function testWriteUUIDProperty(): bool +{ + $role = new Role(); + $role->id = 'abcd-efgh-ijkl'; + + return $role->save(); +} + +function testWriteIdPropertyWhenMigrationsCouldntBeRead(): bool +{ + $group = new Group(); + $group->id = 5; + + return $group->save(); +} + +function testWriteIdPropertyWhenMigrationsCouldntBeReadAndKeyTypeIsString(): bool +{ + $team = new Team(); + $team->id = 'five'; + + return $team->save(); +} + +function testDateCast(User $user): ?BaseCarbon +{ + assertType('string', $user->email); + $user->email_verified_at = now(); + + return $user->email_verified_at; +} + +function testNullablePropertyWithCast(User $user): void +{ + $user->email_verified_at = null; +} + +function testWriteToSoftDeletesColumn(User $user): void +{ + $user->deleted_at = 'test'; + $user->deleted_at = now(); + $user->deleted_at = null; + $user->deleted_at = BaseCarbon::now(); +} + +function testInboundCast(User $user): void +{ + $user->secret = 'secret'; +} + +function testWriteToDecimalCast(User $user): void +{ + $user->decimal = '1.5'; + $user->decimal = 1; + $user->decimal = 1.5; + $user->decimal = 'foo'; // Should raise an error + $user->decimal = []; // Should raise an error +} + +function testWriteToNullableDecimalCast(User $user): void +{ + $user->nullable_decimal = '1.5'; + $user->nullable_decimal = 1; + $user->nullable_decimal = 1.5; + $user->nullable_decimal = null; + $user->nullable_decimal = 'foo'; // Should raise an error + $user->nullable_decimal = []; // Should raise an error +} + +function testWriteToEnumCollectionCast(User $user, App\Casts\BackedEnumeration $backed, App\Casts\BasicEnumeration $basic): void +{ + $user->enum_collection = [$backed]; + $user->enum_collection = null; + $user->enum_collection = [$basic]; // Should raise an error +} diff --git a/tests/PHPStan/Integration/data/model-property-builder.php b/tests/PHPStan/Integration/data/model-property-builder.php new file mode 100644 index 000000000..a64e7f04f --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-builder.php @@ -0,0 +1,64 @@ + $builder + * @param 'unionNotExisting'|'id' $union + */ +function test(Builder $builder, User $user, string $union): void +{ + User::query()->firstWhere('foo', 'bar'); + User::query()->firstWhere($union, 'bar'); + User::query()->where('foo', 'bar')->get(); + + $builder->where('foo', 'bar'); + $user->where('foo', 'bar'); + + User::query()->firstWhere(DB::raw('name LIKE \'%john%\'')); + + User::query()->where(getKey(), '=', 'foo'); + User::query()->orWhere('foo', '=', 'foo'); + User::query()->orWhere('foo', 'foo'); + User::query()->orWhere([ + 'foo' => 'foo', + ]); + User::query()->value('foo'); + + User::query()->where('propertyDefinedOnlyInAnnotation', 'foo'); + User::query()->where('only_available_with_accessor', 'foo'); + + User::query()->first(['foo', 'bar']); + User::query()->first('foo'); + + // Joins + User::query()->join('roles', 'users.role_id', '=', 'roles.id')->where('roles.foo', 'admin'); + + User::query()->get(DB::raw('name')); + User::query()->get([DB::raw('name')]); + + User::query()->createQuietly(['foo' => 'bar']); + User::query()->createQuietly(['id' => 1]); +} + +User::query()->whereHas('accounts', function (Builder $query) { + $query->where('active', 1); + $query->where('foo', 'bar'); +}); + +User::orWhereHas('accounts', function (Builder $query) { + $query->where('foo', 5); +}); + +function getKey(): string +{ + if (random_int(0, 1)) { + return 'foo'; + } + + return 'bar'; +} diff --git a/tests/PHPStan/Integration/data/model-property-fill-and-insert-l12-6.php b/tests/PHPStan/Integration/data/model-property-fill-and-insert-l12-6.php new file mode 100644 index 000000000..1ebf44358 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-fill-and-insert-l12-6.php @@ -0,0 +1,35 @@ +fillAndInsert([['email' => 'taylor@example.com', 'foo' => 'bar']]); +User::query()->fillAndInsertOrIgnore([['email' => 'taylor@example.com', 'foo' => 'bar']]); +User::query()->fillAndInsertGetId(['email' => 'taylor@example.com', 'foo' => 'bar']); +User::query()->fillForInsert([['email' => 'taylor@example.com', 'foo' => 'bar']]); + +User::query()->fillAndInsert([['email' => 'taylor@example.com', 'name' => 'Taylor'], ['email' => 'abigail@example.com', 'name' => 'Abigail']]); +User::query()->fillAndInsertOrIgnore([['email' => 'taylor@example.com', 'name' => 'Taylor']]); +User::query()->fillAndInsertGetId(['email' => 'taylor@example.com', 'name' => 'Taylor']); +User::query()->fillForInsert([['email' => 'taylor@example.com', 'name' => 'Taylor']]); +User::query()->fillAndInsert([]); + +User::fillAndInsert([['email' => 'taylor@example.com', 'foo' => 'bar']]); +User::fillAndInsertGetId(['email' => 'taylor@example.com', 'name' => 'Taylor']); + +/** @param array $rows */ +function fillAndInsertRowsWithIntKeys(array $rows): void +{ + User::query()->fillAndInsert($rows); + User::query()->fillForInsert($rows); +} + +/** @param array $rows */ +function fillAndInsertInvalidRowsWithIntKeys(array $rows): void +{ + User::query()->fillAndInsert($rows); +} + +function fillAndInsertThroughRelation(User $user): void +{ + $user->accounts()->fillAndInsert([['name' => 'foo', 'foo' => 'bar']]); +} diff --git a/tests/PHPStan/Integration/data/model-property-fill.php b/tests/PHPStan/Integration/data/model-property-fill.php new file mode 100644 index 000000000..f4053eeb4 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-fill.php @@ -0,0 +1,32 @@ +fill(['foo' => 'bar']); + $user->forceFill(['foo' => 'bar'])->save(); + + $user->fill(['email' => 'taylor@example.com', 'name' => 'Taylor']); + $user->forceFill(['email' => 'taylor@example.com'])->save(); + $user->fill([]); +} + +/** @param array $attributes */ +function fillUserWithValidatedInput(User $user, array $attributes): void +{ + $user->fill($attributes); + $user->forceFill($attributes); +} + +class FillOnModel extends Model +{ + public function foo(): void + { + $this->fill(['foo' => 'bar']); + $this->forceFill(['foo' => 'bar']); + } +} diff --git a/tests/PHPStan/Integration/data/model-property-find-sole-find-or.php b/tests/PHPStan/Integration/data/model-property-find-sole-find-or.php new file mode 100644 index 000000000..3099aa2e9 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-find-sole-find-or.php @@ -0,0 +1,21 @@ +findSole(1, ['foo']); +User::query()->findSole(1, 'foo'); +User::query()->findSole(1, ['id', 'email']); +User::query()->findSole(1, '*'); +User::query()->findSole(1, DB::raw('name')); +User::findSole(1, ['foo']); +(new User())->accounts()->findSole(1, ['foo']); + +User::query()->findOr(1, ['foo'], fn () => null); +User::query()->findOr(1, 'foo', fn () => null); +User::query()->findOr(1, ['id', 'email'], fn () => null); +User::query()->findOr(1, [DB::raw('name')], fn () => null); +User::query()->findOr(1, fn () => null); +User::query()->findOr(1, callback: fn () => null); +User::findOr(1, ['foo'], fn () => null); +(new User())->accounts()->findOr(1, ['foo'], fn () => null); diff --git a/tests/PHPStan/Integration/data/model-property-force-create-many-l12-8.php b/tests/PHPStan/Integration/data/model-property-force-create-many-l12-8.php new file mode 100644 index 000000000..68ac5ae97 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-force-create-many-l12-8.php @@ -0,0 +1,24 @@ +accounts()->forceCreateMany([['foo' => 'bar']]); +$user->accounts()->forceCreateManyQuietly([['foo' => 'bar']]); +$user->address()->forceCreateMany([['foo' => 'bar']]); + +$user->accounts()->forceCreateMany([['name' => 'foo']]); +$user->accounts()->forceCreateManyQuietly([['name' => 'foo']]); +$user->address()->forceCreateMany([]); + +/** @param array $records */ +function forceCreateManyWithIntKeys(User $user, array $records): void +{ + $user->accounts()->forceCreateMany($records); +} + +/** @param array $records */ +function forceCreateManyInvalidWithIntKeys(User $user, array $records): void +{ + $user->accounts()->forceCreateMany($records); +} diff --git a/tests/PHPStan/Integration/data/model-property-force-create-quietly.php b/tests/PHPStan/Integration/data/model-property-force-create-quietly.php new file mode 100644 index 000000000..bc9b1d504 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-force-create-quietly.php @@ -0,0 +1,11 @@ +forceCreateQuietly(['foo' => 'bar']); +User::query()->forceCreateQuietly(['id' => 1]); +User::query()->forceCreateQuietly(); + +User::forceCreateQuietly(['foo' => 'bar']); +User::forceCreateQuietly(['id' => 1]); +User::forceCreateQuietly(); diff --git a/tests/PHPStan/Integration/data/model-property-increment-each-l13-3.php b/tests/PHPStan/Integration/data/model-property-increment-each-l13-3.php new file mode 100644 index 000000000..5672ee06c --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-increment-each-l13-3.php @@ -0,0 +1,16 @@ +incrementEach(['foo' => 1]); +User::query()->incrementEach(['id' => 1]); +User::query()->incrementEach(['id' => 1], ['foo' => 'bar']); +User::query()->incrementEach(['id' => 1], ['name' => 'bar']); + +User::query()->decrementEach(['foo' => 1]); +User::query()->decrementEach(['id' => 1]); +User::query()->decrementEach(['id' => 1], ['foo' => 'bar']); +User::query()->decrementEach(['id' => 1], ['name' => 'bar']); + +(new User())->accounts()->incrementEach(['foo' => 1]); +(new User())->accounts()->decrementEach(['foo' => 1]); diff --git a/tests/PHPStan/Integration/data/model-property-increment-or-create.php b/tests/PHPStan/Integration/data/model-property-increment-or-create.php new file mode 100644 index 000000000..de6e601a0 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-increment-or-create.php @@ -0,0 +1,12 @@ +incrementOrCreate(['foo' => 'bar']); +User::query()->incrementOrCreate(['email' => 'taylor@example.com']); +User::query()->incrementOrCreate(['email' => 'taylor@example.com'], 'foo'); +User::query()->incrementOrCreate(['email' => 'taylor@example.com'], 'integer'); +User::query()->incrementOrCreate(['email' => 'taylor@example.com'], 'integer', 1, 1, ['foo' => 'bar']); +User::query()->incrementOrCreate(['email' => 'taylor@example.com'], 'integer', 1, 1, ['name' => 'bar']); + +User::incrementOrCreate(['foo' => 'bar']); diff --git a/tests/PHPStan/Integration/data/model-property-model.php b/tests/PHPStan/Integration/data/model-property-model.php new file mode 100644 index 000000000..9b7c8f225 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-model.php @@ -0,0 +1,110 @@ +update([ + 'foo' => 'bar', + ]); + } + + public function unionMethod(\App\User|\App\Account $model): void + { + $model->update([ + 'foo' => 'bar', + ]); + } + + public function unionMethodWithPropertyOnlyInOne(\App\User|\App\Account $model): void + { + $model->update(['email_verified_at' => 'bar']); + } + + public function unionMethodGreen(\App\User|\App\Account $model): void + { + $model->update(['id' => 5]); + } +} + +class ModelPropertyCustomMethods extends Model +{ + /** + * @phpstan-param model-property<\App\User> $property + * + * @param string $property + */ + public function foo(string $property): void + { + } + + public function bar(): void + { + $this->foo('email'); // 'email' exists on \App\User + $this->foo('foo'); // 'foo' does not exist on \App\User + } +} + +class ModelPropertyCustomMethodsInNormalClass +{ + /** + * @phpstan-param model-property<\App\User> $property + * + * @param string $property + */ + public function foo(string $property): void + { + } + + public function bar(): void + { + $this->foo('email'); // 'email' exists on \App\User + $this->foo('foo'); // 'foo' does not exist on \App\User + } +} + +/** + * @param model-property<\App\User> $userModelProperty + */ +function acceptsUserProperty(string $userModelProperty): void +{ +} + +/** + * @param model-property<\App\Account> $accountModelProperty + */ +function acceptsAccountProperty(string $accountModelProperty): void +{ +} + +function getString(): string +{ + return 'string'; +} + +/** @param model-property<\App\Account> $property*/ +function getAccountProperty(string $property): void +{ + acceptsUserProperty($property); +} + +acceptsUserProperty(getString()); + +/** + * @param model-property<\App\Account|\App\User> $accountModelProperty + */ +function acceptsUserOrAccountProperty(string $accountModelProperty): void +{ +} + +acceptsUserOrAccountProperty('id'); // id exists in both models +acceptsUserOrAccountProperty('email'); // email exists only in User + +function testModelPropertyAlias(): void +{ + \App\User::find(1, ['id as user_id', 'users.name as user_name']); +} diff --git a/tests/PHPStan/Integration/data/model-property-mutator-and-casting.php b/tests/PHPStan/Integration/data/model-property-mutator-and-casting.php new file mode 100644 index 000000000..e43b65e1b --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-mutator-and-casting.php @@ -0,0 +1,33 @@ + + */ + protected function address(): Attribute + { + return Attribute::make( + get: fn(mixed $value, array $attributes) => new Address( + $attributes['address_line_one'], + $attributes['address_line_two'], + ), + set: fn(Address $value) => [ + 'address_line_one' => $value->lineOne, + 'address_line_two' => $value->lineTwo, + ], + ); + } +} diff --git a/tests/PHPStan/Integration/data/model-property-relation-create-variants.php b/tests/PHPStan/Integration/data/model-property-relation-create-variants.php new file mode 100644 index 000000000..b75d9dc0f --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-relation-create-variants.php @@ -0,0 +1,14 @@ +accounts()->createQuietly(['foo' => 'bar']); +$user->accounts()->forceCreate(['foo' => 'bar']); +$user->accounts()->forceCreateQuietly(['foo' => 'bar']); +$user->accounts()->createManyQuietly([['foo' => 'bar']]); + +$user->accounts()->createQuietly(['name' => 'foo']); +$user->accounts()->forceCreate(['name' => 'foo']); +$user->accounts()->forceCreateQuietly(['name' => 'foo']); +$user->accounts()->createManyQuietly([['name' => 'foo']]); +$user->accounts()->createQuietly(); +$user->accounts()->forceCreateQuietly(); diff --git a/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-35.php b/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-35.php new file mode 100644 index 000000000..766d73667 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-35.php @@ -0,0 +1,18 @@ +accounts()->incrementOrCreate(['foo' => 'bar']); +(new User())->accounts()->incrementOrCreate(['name' => 'taylor']); +(new User())->accounts()->incrementOrCreate(['name' => 'taylor'], 'foo'); +(new User())->accounts()->incrementOrCreate(['name' => 'taylor'], 'active'); +(new User())->accounts()->incrementOrCreate(['name' => 'taylor'], 'active', 1, 1, ['foo' => 'bar']); +(new User())->accounts()->incrementOrCreate(['name' => 'taylor'], 'active', 1, 1, ['name' => 'otwell']); + +(new User())->oneAddress()->incrementOrCreate(['foo' => 'bar']); +(new User())->address()->incrementOrCreate(['foo' => 'bar']); + +(new User())->roles()->incrementOrCreate(['foo' => 'bar']); +(new User())->roles()->incrementOrCreate(['name' => 'admin']); +(new User())->roles()->incrementOrCreate(['name' => 'admin'], 'foo'); +(new User())->roles()->incrementOrCreate(['name' => 'admin'], 'name', 1, 1, ['foo' => 'bar']); diff --git a/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-37.php b/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-37.php new file mode 100644 index 000000000..e476ea14b --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-37.php @@ -0,0 +1,8 @@ +transactions()->incrementOrCreate(['foo' => 'bar']); +(new User())->transactions()->incrementOrCreate([]); +(new User())->car()->incrementOrCreate([], 'foo'); +(new User())->car()->incrementOrCreate([], 'foo', 1, 1, ['foo' => 'bar']); diff --git a/tests/PHPStan/Integration/data/model-property-relation.php b/tests/PHPStan/Integration/data/model-property-relation.php new file mode 100644 index 000000000..4b4c66d30 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-relation.php @@ -0,0 +1,17 @@ +accounts()->where('foo', 'bar'); +$user->accounts()->create(['foo' => 'bar']); +$user->accounts()->firstOrNew(['foo' => 'bar']); +$user->accounts()->firstOrCreate(['foo' => 'bar']); +$user->accounts()->updateOrCreate(['foo' => 'bar']); + +$user->posts()->where('foo', 'bar'); + +$user->accounts()->createOrFirst(['foo' => 'bar']); + +$user->accountsCamel()->createMany([ + ['name' => 'foo'], + ['name' => 'bar'], +]); diff --git a/tests/PHPStan/Integration/data/model-property-static-call.php b/tests/PHPStan/Integration/data/model-property-static-call.php new file mode 100644 index 000000000..37c401c8a --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-static-call.php @@ -0,0 +1,39 @@ + 'bar', + ]); + + return $foo::create([ + 'foo' => 'bar', + ]); +} + +/** + * @property string $name + */ +class ModelPropertyStaticCallsInClass extends Model +{ + public static function foo(): ModelPropertyStaticCallsInClass + { + return static::create([ + 'foo' => 'bar', + 'name' => 'John Doe', + ]); + } + + public function bar(): ModelPropertyStaticCallsInClass + { + return self::create([ + 'foo' => 'bar', + 'name' => 'John Doe', + ]); + } +} diff --git a/tests/PHPStan/Integration/data/model-property-upsert.php b/tests/PHPStan/Integration/data/model-property-upsert.php new file mode 100644 index 000000000..bf82c5690 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-upsert.php @@ -0,0 +1,44 @@ +upsert(['foo' => 'bar'], 'email'); +User::query()->upsert([['email' => 'taylor@example.com', 'foo' => 'bar']], 'email'); +User::query()->upsert(['email' => 'taylor@example.com'], 'foo'); +User::query()->upsert(['email' => 'taylor@example.com'], ['email', 'foo']); +User::query()->upsert(['email' => 'taylor@example.com'], 'email', ['foo' => DB::raw('1')]); + +User::query()->upsert(['email' => 'taylor@example.com', 'name' => 'Taylor'], 'email'); +User::query()->upsert([['email' => 'taylor@example.com', 'name' => 'Taylor'], ['email' => 'abigail@example.com', 'name' => 'Abigail']], ['email'], ['name']); +User::query()->upsert(['email' => 'taylor@example.com', 'name' => 'Taylor'], 'email', ['name' => DB::raw('upper(name)')]); +User::query()->upsert(['email' => 'taylor@example.com', 'email_verified_at' => now()], 'email', null); +User::query()->upsert([], 'email'); + +User::upsert(['foo' => 'bar'], 'email'); +User::upsert(['email' => 'taylor@example.com', 'name' => 'Taylor'], 'email', ['name']); + +/** @var User $user */ +$user->accounts()->upsert(['foo' => 'bar'], 'name'); +$user->accounts()->upsert([['name' => 'foo', 'foo' => 'bar']], 'name'); +$user->accounts()->upsert(['name' => 'foo'], 'foo'); +$user->accounts()->upsert([['name' => 'foo', 'active' => '1']], ['name'], ['active']); + +/** @param array $rows */ +function upsertRowsWithIntKeys(array $rows): void +{ + User::query()->upsert($rows, 'email'); +} + +/** @param array $rows */ +function upsertInvalidRowsWithIntKeys(array $rows): void +{ + User::query()->upsert($rows, 'email'); +} + +// Hypervel: PDO binds resource values as large objects. +/** @param resource $name */ +function upsertResourceValue($name): void +{ + User::query()->upsert(['email' => 'taylor@example.com', 'name' => $name], 'email'); +} diff --git a/tests/PHPStan/Integration/data/model-property-with-attributes-l12-4.php b/tests/PHPStan/Integration/data/model-property-with-attributes-l12-4.php new file mode 100644 index 000000000..d095768e0 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-with-attributes-l12-4.php @@ -0,0 +1,9 @@ +withAttributes(['email' => 'taylor@example.com', 'foo' => 'bar'], asConditions: false); +User::query()->withAttributes('foo', 'bar', false); + +User::query()->withAttributes(['email' => 'taylor@example.com'], asConditions: false); +User::query()->withAttributes('email', 'taylor@example.com', false); diff --git a/tests/PHPStan/Integration/data/model-property-with-attributes.php b/tests/PHPStan/Integration/data/model-property-with-attributes.php new file mode 100644 index 000000000..bdc1b5d88 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-with-attributes.php @@ -0,0 +1,26 @@ +withAttributes(['foo' => 'bar']); +User::query()->withAttributes('foo', 'bar'); + +User::query()->withAttributes(['email' => 'taylor@example.com', 'name' => 'Taylor']); +User::query()->withAttributes('email', 'taylor@example.com'); +User::query()->withAttributes(DB::raw('email'), 'taylor@example.com'); +User::query()->where('name', 'Taylor')->withAttributes('email', 'taylor@example.com')->create(); + +User::withAttributes(['foo' => 'bar']); +User::withAttributes(['email' => 'taylor@example.com']); + +/** @var User $user */ +$user->accounts()->withAttributes(['foo' => 'bar']); +$user->accounts()->withAttributes('foo', 'bar'); +$user->accounts()->withAttributes(['name' => 'foo']); + +/** @param array $attributes */ +function withAttributesFromStringKeys(array $attributes): void +{ + User::query()->withAttributes($attributes); +} diff --git a/tests/PHPStan/Integration/data/model-save-options.php b/tests/PHPStan/Integration/data/model-save-options.php new file mode 100644 index 000000000..69237db8b --- /dev/null +++ b/tests/PHPStan/Integration/data/model-save-options.php @@ -0,0 +1,7 @@ +save(['touch' => false]); +$user->save(['touch' => 'no']); +$user->save(); +$user->update(['name' => 'Taylor'], ['touch' => 'no']); diff --git a/tests/PHPStan/Integration/data/model-with-relations.php b/tests/PHPStan/Integration/data/model-with-relations.php new file mode 100644 index 000000000..102859dc1 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-with-relations.php @@ -0,0 +1,40 @@ + function (HasMany $relation): void {}]); + $user->with(['group' => function (BelongsTo $relation): void {}]); + User::with([ + 'accounts' => function (HasMany $relation): void {}, + 'group' => function (BelongsTo $relation): void {}, + ]); + User::with(['accounts' => function (Relation $relation): void {}]); + User::with([$dynamic => function (Relation $relation): void {}]); + + // A benevolent union intentionally does not retain key-to-callback correlation. + User::with([ + 'accounts' => function (BelongsTo $relation): void {}, + 'group' => function (HasMany $relation): void {}, + ]); + + User::with([$dynamic => function (HasMany $relation): void {}]); + User::with(['accounts' => function (BelongsTo $relation): void {}]); + User::with(['accounts' => function (int $relation): void {}]); + User::with(['missing' => function ($relation): void {}]); + User::with(['accounts' => function (HasMany $relation): void {}, $dynamic => function ($relation): void {}]); + + acceptsAccounts($user->accounts()); + acceptsAccounts($user->group()); +} + +/** @param relation-of $relation */ +function acceptsAccounts(Relation $relation): void +{ +} diff --git a/tests/PHPStan/Integration/data/relation-cursor-paginate.php b/tests/PHPStan/Integration/data/relation-cursor-paginate.php new file mode 100644 index 000000000..ef684d8b5 --- /dev/null +++ b/tests/PHPStan/Integration/data/relation-cursor-paginate.php @@ -0,0 +1,33 @@ +roles()->cursorPaginate(cursor: $cursor); + $user->roles()->cursorPaginate(15, ['*'], 'cursor', $cursor); + $user->roles()->cursorPaginate(cursor: 'eyJpZCI6MX0'); + $user->roles()->cursorPaginate(cursor: null); +} + +function hasManyThrough(User $user, Cursor $cursor): void +{ + $user->transactions()->cursorPaginate(cursor: $cursor); + $user->transactions()->cursorPaginate(15, ['*'], 'cursor', $cursor); + $user->transactions()->cursorPaginate(cursor: 'eyJpZCI6MX0'); + $user->transactions()->cursorPaginate(cursor: null); +} + +function hasOneThrough(User $user, Cursor $cursor): void +{ + $user->car()->cursorPaginate(cursor: $cursor); +} + +function builderAndHasMany(User $user, Cursor $cursor): void +{ + User::query()->cursorPaginate(cursor: $cursor); + $user->accounts()->cursorPaginate(cursor: $cursor); +} diff --git a/tests/PHPStan/Integration/data/relationship-query-callbacks.php b/tests/PHPStan/Integration/data/relationship-query-callbacks.php new file mode 100644 index 000000000..c5049d0e7 --- /dev/null +++ b/tests/PHPStan/Integration/data/relationship-query-callbacks.php @@ -0,0 +1,144 @@ +has('accounts', callback: function (Builder $query) { + $query->whereKey(1); + }); + + User::doesntHave('posts', callback: function (PostBuilder $query) { + $query->whereKey(1); + }); + + User::query()->whereHas($user->posts(), function (PostBuilder $query) { + $query->whereKey(1); + }); + + User::query()->orWhereHas('posts.comments', function (Builder $query) { + $query->whereKey(1); + }); + + User::query()->whereDoesntHave('accounts'); + User::orWhereDoesntHave('accounts', null); + User::query()->whereHas('accounts', count: new Expression('1')); +} + +function morphCallbacks(Comment $comment): void +{ + Comment::query()->whereHasMorph('commentable', [Post::class], function (PostBuilder $query, string $type) { + $query->whereKey(1); + }); + + Comment::whereHasMorph($comment->commentable(), '*', function (Builder $query) { + $query->whereKey(1); + }); + + Comment::query()->whereHasMorph('commentable', [], function (Builder $query) { + $query->whereKey(1); + }); +} + +function declaredMorphCallbacks(PostComment $comment, string $unknown): void +{ + PostComment::query()->whereHasMorph('commentable', '*', function (PostBuilder $query) { + $query->whereKey(1); + }); + + PostComment::whereHasMorph('commentable', $unknown, function (PostBuilder $query) { + $query->whereKey(1); + }); + + PostComment::whereHasMorph($comment->commentable(), Post::class, function (PostBuilder $query) { + $query->whereKey(1); + }); +} + +function invalidMorphCallbacks(): void +{ + Comment::whereHasMorph('commentable', Post::class, function (int $query) {}); + Comment::whereHasMorph('commentable', Post::class, function (Builder $query, int $type) {}); + Comment::whereHasMorph('commentable', User::class, function (PostBuilder $query) {}); +} + +function eagerCallbacks(): void +{ + User::withWhereHas('accounts', function (Builder|Relation $query) { + $query->where('active', true); + }); + + User::query()->withWhereHas('posts.comments', function (Builder|Relation $query) { + $query->whereKey(1); + }); + + User::withWhereHas(relation: 'posts', callback: function ($query) { + $query->whereKey(1); + }, count: new Expression('1')); + + User::withWhereHas('accounts'); + User::query()->withWhereHas('accounts', null); + + User::withWhereHas('accounts', function (Builder $query) {}); + User::query()->withWhereHas('accounts', function (Relation $query) {}); +} + +function shortcutCallbacks(User $user, Comment $comment): void +{ + User::whereRelation('posts', fn (PostBuilder $query) => $query->whereKey(1)); + User::query()->orWhereRelation($user->posts(), fn (PostBuilder $query) => $query->whereKey(1)); + User::whereDoesntHaveRelation('posts', fn (PostBuilder $query) => $query->whereKey(1)); + User::orWhereDoesntHaveRelation('posts', fn (PostBuilder $query) => $query->whereKey(1)); + + Comment::whereMorphRelation('commentable', Post::class, fn (PostBuilder $query) => $query->whereKey(1)); + Comment::query()->orWhereMorphRelation('commentable', [Post::class], fn (PostBuilder $query) => $query->whereKey(1)); + Comment::whereMorphDoesntHaveRelation('commentable', Post::class, fn (PostBuilder $query) => $query->whereKey(1)); + Comment::orWhereMorphDoesntHaveRelation($comment->commentable(), Post::class, fn (PostBuilder $query) => $query->whereKey(1)); + + User::withWhereRelation('posts', fn (PostBuilder|Relation $query) => $query->whereKey(1)); + User::withWhereHas('posts', fn (PostBuilder|Relation $query) => $query->whereKey(1)); + User::withWhereHas('posts:id'); + User::query()->with('accounts', fn (Relation $query) => $query->where('id', 1)); + + User::whereRelation('accounts', 'id', 1); + User::orWhereRelation('accounts', ['id' => 1]); + User::whereDoesntHaveRelation('accounts', new Expression('id'), '=', 1); + User::orWhereDoesntHaveRelation('accounts', 'id', '>', 1); + User::withWhereRelation('accounts', 'id', 1); + Comment::whereMorphRelation('commentable', Post::class, 'id', 1); + Comment::orWhereMorphRelation('commentable', Post::class, ['id' => 1]); + Comment::whereMorphDoesntHaveRelation('commentable', Post::class, new Expression('id'), '=', 1); + Comment::orWhereMorphDoesntHaveRelation('commentable', Post::class, 'id', '>', 1); + User::query()->with('accounts', 'posts'); +} + +function invalidShortcutCallbacks(): void +{ + User::whereRelation('accounts', function (PostBuilder $query) {}); + Comment::whereMorphRelation('commentable', Post::class, function (int $query) {}); + Comment::whereMorphRelation('commentable', Post::class, function (PostBuilder $query, string $type) {}); + User::withWhereRelation('accounts', function (Builder $query) {}); + User::query()->with('accounts', function (Builder $query) {}); + + User::withWhereHas('accounts', function (Builder|\Hypervel\Database\Eloquent\Relations\HasMany $query) {}); + User::withWhereRelation('accounts', function (Builder|\Hypervel\Database\Eloquent\Relations\HasMany $query) {}); +} + +/** + * @param \Closure(Builder): void $userCallback + * @param \Closure(PostBuilder): void $postCallback + */ +function typedMorphCallbacks(\Closure $userCallback, \Closure $postCallback): void +{ + Comment::whereHasMorph('commentable', Post::class, $userCallback); + Comment::whereHasMorph('commentable', Post::class, $postCallback); +} diff --git a/tests/PHPStan/application/app/Casts/BasicEnumeration.php b/tests/PHPStan/application/app/Casts/BasicEnumeration.php index 46291c9c9..1cadaeb12 100644 --- a/tests/PHPStan/application/app/Casts/BasicEnumeration.php +++ b/tests/PHPStan/application/app/Casts/BasicEnumeration.php @@ -4,4 +4,6 @@ enum BasicEnumeration { + // Hypervel: PHPStan treats an enum without cases as having no values, which any type accepts. + case Basic; } From f477f0c70b1b96dbd399d5576827aea831225405 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:48:15 +0000 Subject: [PATCH 129/303] Document relation diagnostics and remaining port work Document model relation diagnostics and the boundaries for nested and dynamic relation names. Update the implementation plan with the reviewed contracts and correct the remaining-file checklist. Keep the remaining fluent annotation restoration, collection and factory flows, runtime-input tracking and warm-cache verification explicit for subsequent implementation. --- .../plans/2026-11-04-hypervel-phpstan-checklist.md | 9 ++++----- docs/plans/2026-11-04-hypervel-phpstan.md | 9 ++++++--- src/docs/static-analysis.md | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index 388b6cc51..4d4cd850d 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -25,8 +25,8 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `Methods/ModelFactoryMethodsClassReflectionExtension.php` - omit: `Properties/ReflectionTypeContainer.php` (unused since upstream removed container ArrayAccess support) - copy: `ReturnTypes/{FactoryDynamicMethodReturnTypeExtension,ModelFactoryDynamicStaticMethodReturnTypeExtension}.php` -- copy with the Eloquent collection port: `ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php`, moving `common/EloquentCollection.stub`'s `map()`/`mapWithKeys()` conditionals into the Eloquent `Collection` source; also `toQuery()`/`load()`/`loadMissing()` typing with `Type/data/eager-loading-callbacks.php` intact (investigate the supported PHPStan hooks before choosing a mechanism) -- copy: `Rules/*` except below and the ported `ArrKeyExistenceRule`, auth request-scope, config, console, `NoEnvCallsOutsideOfConfigRule`, `NoMissingTranslationsRule`, `NoUnnecessaryEnumerableToArrayCallsRule`, `UnusedViewsRule` and `UselessConstructs/` rules (including `Queue/`) +- copy with the Eloquent collection port: `ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php`, moving `common/EloquentCollection.stub`'s `map()`/`mapWithKeys()` conditionals into the Eloquent `Collection` source; also `toQuery()`/`load()`/`loadMissing()` typing, merging `Integration/data/eager-loading-callbacks.php` and its `Integration/IntegrationTest.php` entry (investigate the supported PHPStan hooks before choosing a mechanism) +- copy: `Rules/*` except below and the ported `ArrKeyExistenceRule`, auth request-scope, config, console, `NoEnvCallsOutsideOfConfigRule`, `NoMissingTranslationsRule`, `NoUnnecessaryEnumerableToArrayCallsRule`, `RelationExistenceRule` (with `RelationExistenceHelper` and `ModelRuleHelper`), `UnusedViewsRule` and `UselessConstructs/` rules (including `Queue/`) - omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` - omit: `SQL/*` (`hypervel/schema-parser` replaces the parser boundary and its iamcal/phpMyAdmin adapters) - copy: `Types/Factory/` with the factory flow; `Types/GenericEloquentCollectionTypeNodeResolverExtension.php` with the Eloquent collection port @@ -54,8 +54,7 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - omit: `laravel-test.sh` - copy with the Eloquent collection port: `application/app/UserCollection.php`; with the factory flow: `application/app/{Domain/,Shapes/}` and `application/database/factories/` - omit: `application/app/{ModelWithConflictingInitializer.php,UserResource.php,Providers/}` (no upstream test or source references them) -- next checkpoint, with the source corrections it exposes: merge into `Integration/IntegrationTest.php` the remaining upstream entries with `Integration/data/*` (46 files), switching its config to `Type/data/config-check-model-properties.neon` with model properties -- copy: `Rules/*Test.php` except below and the ported `ArrKeyExistence`, auth, config, console, environment, enumerable `toArray()`, view, translation and useless-construct rule tests, the remaining `Rules/data/*`, `Rules/Queue/data/*` +- copy: `Rules/*Test.php` except below and the ported `ArrKeyExistence`, auth, config, console, environment, enumerable `toArray()`, relation existence, view, translation and useless-construct rule tests, the remaining `Rules/data/*`, `Rules/Queue/data/*` - omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` - copy: `Type/*Test.php` except below, the ported collection, config and model-property tests, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php`, with the remaining `Type/data/*` - merge into `Type/BleedingEdgeTypeTest.php` with the Eloquent ports: `bleeding-edge/closure-usage-inference.php` (relation callbacks) and `bleeding-edge/eloquent-collection-map.php` @@ -63,5 +62,5 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - merge into `Type/CollectionDynamicReturnTypeExtensionTest.php` with the model fixtures: `collection-filter.php`, `collection-reject.php`, `collection-where.php` (they need `accounts()->active()->get()` to return `AccountCollection`) - omit: `Type/data/collection-generic-static-methods-l11.php` (Laravel 11's Eloquent `partition()` returned `static`; Hypervel follows the Laravel 12 base collection) - merge: the remaining `Type/GeneralTypeTest.php` data entries (its `phpstan-tests.neon` already matches upstream's `config-with-migrations.neon`) -- copy: `Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php` with the factory flow +- copy with the factory flow: `Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php`; merge `Integration/data/{model-factories,model-property-model-factory}.php` and their `Integration/IntegrationTest.php` entries - omit: `Unit/SQL/*` (parser factory and phpMyAdmin adapter tests; `hypervel/schema-parser` has its own) diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 4eaab38f5..924d870b0 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -25,7 +25,8 @@ Remaining work: - Also completed and reviewed: the model-properties/schema flow, including schema-parser impact, casts/accessors, fetched types, schema and forwarding dependencies, the schema-dump diagnostic, the routing and SQLite JSON fixes, Attribute variance, inherited method-template declarations, tests and documentation. - Also completed: the approved `final` cleanup in the PHPStan and schema-parser packages and branch-added fixtures. `readonly` is preserved; the `Comment`/`Comments` type fixtures are unchanged. - Also completed: the Eloquent builder and relation forwarding flow (`BuilderHelper`, the `builder-of`, `collection-of` and `relation-of` types, `ClosureTypes/`, the forwarding return types, the 13.x stub merges, dynamic wheres and stub-validation compliance), with its type and integration fixtures. -- Next: merge the remaining upstream Integration entries (checklist), with the source corrections they expose. Then port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension`, the `toQuery()`/`load()`/`loadMissing()` typing with `eager-loading-callbacks.php`, and the model-dependent collection fixtures listed in the checklist. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. +- Also completed: the upstream Integration merge (except the entries the checklist assigns to the Eloquent collection and factory flows), with its source corrections, `RelationExistenceRule`, and Laravel's `@return $this` tags restored on the Query and Eloquent builders, `JoinClause` and `QueriesRelationships`. +- Next: restore the remaining missing Laravel `@return $this` tags (see Type inference and stubs) across Components: the rest of Database (Model, Eloquent collection, connection, attribute and relationship concerns, schema, seeder, factories, exceptions) and every other package. Then port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension`, the `toQuery()`/`load()`/`loadMissing()` typing with `eager-loading-callbacks.php`, and the model-dependent collection fixtures listed in the checklist. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -243,7 +244,7 @@ Port every applicable upstream inference feature, not just the rules in the next | Authentication | One shared guard-type helper serves `Auth::guard()`, `Factory::guard()` and `auth($guard)`, following `AuthManager::resolve()`: a booted custom creator's declared return (`ClosureTypeFactory`, never invoked), else the booted manager's `create{Driver}Driver()` declaration, else the declared return. `viaRequest()`'s creator declares `RequestGuard`, so request guards are typed; an untyped creator keeps the declared return. Never claim `StatefulGuard` for an unmodelled guard: only `SessionGuard` is stateful, and token/request/Sanctum/JWT/Passport guards are not. An omitted, null or empty name is the union of every configured guard's type, because `shouldUse()` changes the default per coroutine; so is an unknown or unpacked name, because resolving an unconfigured name throws. A known set of string or enum names gives the union of those guards. `auth()` and `auth(null)` type as the booted factory concrete; unlike `guard(null)`, a maybe-null or unpacked `auth()` argument can return the factory, so it keeps the declared conditional. Guard names come only from `guard()` on the factory or facade, the global `auth()` (resolved function identity) and `Request::user()`; enum cases map like `enum_value()` where the signature accepts enums. User models are provider-based inference: an Eloquent provider gives its model for any guard driver; a database provider gives `GenericUser` only for the stock session/token drivers (Sanctum accepts session-guard users when its provider has no `getModel()`). A guard without a modelled provider, or one whose provider driver has a custom creator, keeps the call's declaration. Preserve narrower declarations; manager and facade `user()` calls are typed only when they forward the `Guard` contract's method. Omit `AuthsMethodsExtension`: `AuthManager`'s mixins already forward guard methods, and contracts do not gain implementation methods. | | Console commands | Argument/option types and the undefined-input rule read the booted kernel's commands (`$kernel->all()`: `withCommands()` discovery, `command()` closures and providers' `starting()` registrations; a standalone application is the fallback when the kernel cannot list them). Look up the call's receiver, not the enclosing class; exactly `Command` and `ClosureCommand` receivers keep the declaration, because they name every command or unrelated per-instance signatures. Merge each matched command's application definition once at lookup, as `run()` does, so bulk shapes include `command` and the global options regardless of analysis order. A name lookup is the union over matching commands that define it (missing ones throw), else the declaration; option names follow `Input`, so negations resolve to their option and shortcuts are not names. Aliases list a command once. A definitely-null key is a bulk lookup; overridden methods keep their declarations for typing and diagnostics. The rule skips a call whose same-receiver `hasArgument()`/`hasOption()` guard is definitely true or unreachable in scope, because the call never reaches the commands without the name; Components' `GeneratorCommand` and `CreatesMatchingTest` guard `--force` this way. Scope keys the guard as written, so check its positional and `name:` forms built from the getter's value, never the getter's own argument nodes. A receiver union with an alternative that has no known definition (e.g. `FooCommand|ClosureCommand`) keeps the declaration. `@param-closure-this` for `command()` lives on the Kernel contract and Foundation Kernel. | | Views and translations | Retain `view-string` and live view-finder namespaces. Keep translation typing and the adapted missing-key rule. The optional unused-view rule reports absent statically discovered usage, not proof that a view can be deleted. | -| Custom PHPDoc types | Port `model-property`, `relation-of`, `builder-of`, `collection-of`, `view-string`, their parsers, templates and applicable tests. Keep these analyzer-only annotations in stubs where adding them to framework source would require this dev package. Unlike upstream, the late-resolvable types compose their position variance with invariant, so a `*` argument in a parameter resolves as it does in the generic class it describes instead of to `never`. `model-property` may be a supertype of an arbitrary string, as `accepts()` allows, because PHPStan rechecks an accepted intersection through `isSuperTypeOf()`. | +| Custom PHPDoc types | Port `model-property`, `relation-of`, `builder-of`, `collection-of`, `view-string`, their parsers, templates and applicable tests. Keep these analyzer-only annotations in stubs where adding them to framework source would require this dev package. Unlike upstream, the late-resolvable types compose their position variance with invariant, so a `*` argument in a parameter resolves as it does in the generic class it describes instead of to `never`. `model-property` may be a supertype of an arbitrary string, as `accepts()` allows, because PHPStan rechecks an accepted intersection through `isSuperTypeOf()`. For a constant string, `isSuperTypeOf()` gives the model's actual property existence, so an abstract or template model is maybe rather than `accepts()`'s yes: union simplification would otherwise drop alternatives such as `'*'` from stub types. | | Stub validation | On a full analysis PHPStan validates stubs outside the project's vendor directory (this repo, and symlinked path-repository installs such as the owner's apps) against the stubs alone, and its errors cannot be ignored. Stubs therefore declare every class they reference: an empty declaration for a non-generic one (throwables with their real parent chain), and for a generic one its verbatim source class docblock, matching imports and native `extends`/`implements`, with no methods unless an analyzer override or validation needs them (the Query `Builder`, `ModelNotFoundException`, `Arrayable`). A class declared in a stub takes the nearest ancestor stub's PHPDoc for a method its own stub omits, including PHPStan's `ArrayAccess`/`IteratorAggregate`/`Countable` stubs (`PhpClassReflectionExtension::findMethodPhpDocIncludingAncestors()`), so a stub also declares, verbatim, each such method whose own source docblock that would replace (the Config and Cache `Repository` `offset*()` methods, with a diagnostic fixture for their key types). The support and Eloquent collections and the paginators cannot be declared: their verbatim docblocks fail validation (covariant `TValue` in `ArrayAccess`/`ArrayIterator`, accepted in source only through the `generics.variance` ignore), and editing them would replace the source declarations. Stubs therefore do not reference them: `BelongsToMany`'s `TResult` is `collection-of` (its results come from the related model's `newCollection()`), and the `paginate()` family and `pluck()` stay unstubbed, their returns coming from source through `EloquentBuilderExtension`/`RelationCollectionExtension`, while `ModelPropertyColumnRule` (enabled by `checkModelProperties`) checks their column arguments as the stubs' `model-property` types did, for builder, model-forwarded and relation-forwarded calls. A stub class docblock replaces the source's, and a stub method docblock replaces the source method's, so a stubbed method repeats the source's native signature and every source PHPDoc tag besides its analyzer-only refinements. `StubMirrorsTest` compares an explicit mapping of the verbatim class docblocks (the declaration mirrors and the Eloquent builder/relation stubs except `BelongsToMany`) and mirrored method docblocks with source; stubs whose class docblocks intentionally differ, such as the facade mixins and `BelongsToMany`, are not mapped. Extension code reached while validating stubs must not assume project reflection, such as methods the stubs omit; an unreflectable provider counts as unknown writes, which also covers anonymous providers. | | Dates and environment | Keep native/source `CarbonInterface` boundaries for configurable dates and exact immutable types for framework-owned values. Do not infer configurable dates from `get_class(now())`, or assume an `env()` default dictates the supplied environment value's type. `env()` and `Env::get()` declare `bool|string|TDefault|null` in source PHPDoc (`TDefault` from a value or `Closure` default; native signatures unchanged), matching `Env::getOption()` and `value()`; no stub or return-type extension. | @@ -269,6 +270,8 @@ Do not copy `ContractsMethodsExtension`, container ArrayAccess/dynamic-property General type improvements go into source, with dedicated type fixtures and no runtime overhead. Confirmed examples include `Attribute` and the conditional `FormRequest::validated()` result. `Attribute`'s get callback input is a method template (`TValue`) on `__construct()`, `make()` and `get()`, so typed getters such as Laravel's `fn (string $value) => ucfirst($value)` are accepted; `make()`'s `TMakeGet`/`TMakeSet` default to `never`, so `make(get: ...)` resolves. The runtime bodies stay Laravel's. The owner approved declaration variance: `@template-covariant TGet`, `@template-contravariant TSet` and PHPDoc `@readonly` `$get`/`$set` (no native `readonly`), so a declaration may read wider and accept narrower than its callbacks infer (`Attribute` around a `non-falsy-string` getter). Reassigning a callback is reported (`property.readOnlyByPhpDocAssignOutOfClass`) and may be ignored with a reason; model attribute reads and writes are unaffected. PHPStan leaves the constructor's own template unresolved in the inferred class type (`string&TValue`); fixtures do not assert that leak. At max level, source analysis of `set()` reports `return.type` because a missing getter leaves `TGet` unresolved; the repo analyses source at level 5 and callers get the declared type. Class template defaults (`= never`) would remove that report but also stop `missingType.generics` for bare `Attribute` returns, so they are not used. `Enumerable::keyBy()` already declares a new-key template (its bound includes `null`, which both collections key as `''`), and `Builder::make()`/`create()` already return `TModel`; add no stubs for these. PHPStan 2.3.5 does not inherit a parent's method templates into an override whose PHPDoc omits them, so they stay unresolved when the arguments do not infer them. The affected overrides (`Collection::keyBy()`, `LazyCollection::keyBy()`/`mapToDictionary()`/`crossJoin()`/`merge()`/`mergeRecursive()`, `Sentry\Hub::getIntegration()`/`withScope()`) repeat the parent's declaration with an `@TODO` to remove it once PHPStan inherits them. `duplicates()`/`duplicatesStrict()` cannot leak (a callback always infers their template) and the Eloquent collection's overrides declare their own types, so they repeat nothing. Analyzer-only `model-property` refinements on Model/Builder (including `array, mixed>` inputs) and `view-string` refinements on mail/view/route methods live in package stubs, not framework source. The Redis facade's tags and the `RedisManager`/`RedisProxy` mixins already type Hypervel's phpredis-only pooled surface: port the applicable phpredis assertions and add stubs only for demonstrated gaps. Omit Predis stubs, Laravel's client-selection branches and the `redisDriver` parameter; there is no supported alternate transport to select. +Fluent methods keep Laravel's `@return $this` beside the native `: static` wherever the Hypervel body returns the receiving object: native `static` promises the class, not the same instance, so a custom builder declaring `@return $this` and returning a parent call otherwise fails. Check each tag against the method's return paths (clone and new-instance returns keep `static`); replacing stub docblocks repeat it. The auth and notification traits (`MustVerifyEmail`, `CanResetPassword`, `RoutesNotifications`) declare no `email`/`email_verified_at` properties, which Laravel lacks: a trait tag overrides the model's schema and casts (`email_verified_at` lost its cast's write types). Truthful trait contracts stay, such as Nested Set's computed `depth` and relationship properties. `MustVerifyEmail` and `CanResetPassword` keep `@method notify()`, as Foundation `User` uses them without `Notifiable`. The `save()` family's options are `array{touch?: null|bool, ...}` in source and the `update()` stub (only `touch` is read; subclasses may read other keys); `transform()` takes `null|TValue`, as its callback runs only for a filled value; a single `upsert()` row's values are `scalar|object|resource|null` (PDO binds resources as large objects), so a list of rows keeps its column checks. The polymorphic relationship queries with a callback are stubbed with upstream's typing: `TMorphModel` from the `class-string`s in `$types` (a `TType of string` alternative keeps other strings from absorbing them) and the callback's `builder-of`, so an already-typed callback for another model is reported; `'*'` and morph aliases leave `TMorphModel` unresolved, which accepts any callback, and `RelationshipQueryCallbackExtension` still types inline closures from the relation for them. Stub validation needs the empty `BelongsTo` and `MorphTo` declarations this adds. + Preserve upstream's Request and Optional `universalObjectCratesClasses` entries: `Request::__get()` resolves arbitrary input/route keys, and `Optional::__get()` forwards an arbitrary property name or returns null. These are supported dynamic APIs, not undefined-property mistakes. Do not add blanket crates for models or other classes to hide missing inference; use their dedicated reflection extensions and preserve explicit property types. Include the documented `$request->name` and Optional property access in typing coverage. Removing either dynamic API would require owner approval. ## Configuration and schema analysis @@ -430,7 +433,7 @@ Copy retained rule files first, then adapt them. Retain their relevant toggles a | `Queue/JobWithModelPropertyDeclaresSerializesModelsRule` | Include protected/private/nullable model state and supported model collections. Recognize inherited serialization and valid custom `__serialize()`/`__unserialize()` behavior. | | `Queue/NoBatchedUniqueJobRule` | Cover the verified batch/bulk paths that bypass uniqueness acquisition, with an accurate explanation. Do not generalize to dispatch paths that acquire locks normally. | | `Queue/UniqueJobDeclaresUniqueIdRule` | Require explicit identity for parameterized unique jobs; permit intentional class-wide identity through an explicit constant-returning method. Do not invent an identity or include secrets in diagnostics. | -| `RelationExistenceRule` | Preserve relation existence checking for loading/query APIs, including nested paths, selected columns, polymorphism and dynamic uncertainty. A name with a booted dynamic relation (`$model->relationResolver($class, $name)` on the model instance, inherited resolvers included) exists; read the registry only, never invoke the resolver. | +| `RelationExistenceRule` | Preserve relation existence checking for loading/query APIs, including nested paths, selected columns, polymorphism and dynamic uncertainty. A name with a booted dynamic relation (`$model->relationResolver($class, $name)` on the model instance, inherited resolvers included) exists, and ends the path because its related model is unknown; read the registry only, never invoke the resolver. `ModelRuleHelper` shares `ModelPropertyColumnRule`'s receiver normalization (a class-string receiver is its model) and model resolution from `Builder`'s `TModel` or `Relation`'s `TRelatedModel`, so custom builders with other type parameters and non-generic builders resolve; relation existence checks only a single concrete model. Names follow each runtime family: eager loading and aggregates parse a `:columns` selection (except `with()`'s name paired with a closure, and unlike upstream including Hypervel's `withWhereHas()` with a callback); the `has`/`whereHas`/`whereRelation` families and `withWhereRelation()` use the name as written; dotted paths are followed except by aggregates and the polymorphic queries, which get the relation directly. Each path segment is resolved on the model's type, keeping a generic model's type arguments. | | `UselessConstructs/NoUselessValueFunctionCallsRule` | Resolve parameters correctly and preserve all argument evaluation effects; spread arguments are not ordinary positional values. | | `UselessConstructs/NoUselessWithFunctionCallsRule` | Same argument/effect requirements; reordered named callback/value arguments are valid. | | `DeferrableServiceProviderMissingProvidesRule` | Omit: no deferred-provider surface. | diff --git a/src/docs/static-analysis.md b/src/docs/static-analysis.md index 90a7d5d01..e861fb77c 100644 --- a/src/docs/static-analysis.md +++ b/src/docs/static-analysis.md @@ -21,6 +21,7 @@ - [Console Input](#console-input) - [Authentication in Requests](#authentication-in-requests) - [Redis Connections](#redis-connections) + - [Model Relations](#model-relations) - [Schema Dumps](#schema-dumps) - [Deprecations](#deprecations) @@ -233,6 +234,7 @@ The following table summarizes the preferred pattern for each of Hypervel's diag | [Console Input](#console-input) | Read the arguments and options the command defines. | `hypervel.console.undefinedArgument`, `hypervel.console.undefinedOption` | | [Authentication in Requests](#authentication-in-requests) | Use `$request->user()` where a request is available. | `hypervel.noAuthFacadeInRequestScope`, `hypervel.noAuthHelperInRequestScope` | | [Redis Connections](#redis-connections) | Call connection-bound methods on a held connection. | `hypervel.redisConnectionBoundMethod` | +| [Model Relations](#model-relations) | Load and query relations the model defines. | `hypervel.relationExistence` | | [Schema Dumps](#schema-dumps) | Keep schema dumps in the plain SQL `schema:dump` writes. | `hypervel.schemaDump` | | [Deprecations](#deprecations) | Use the replacement the deprecation names. | `method.deprecated` and the other deprecation identifiers | @@ -382,6 +384,18 @@ Redis::withConnection(function (RedisConnection $connection) { These checks may be disabled using the `checkRedisConnectionBoundMethods` parameter. + +### Model Relations + +Eager loading, relationship queries and relationship aggregates report a relation the model does not define. Eager loading and the `has`, `whereHas` and `whereRelation` families also check each segment of a nested path: + +```php +// Relation 'comment' is not found in App\Models\Post model. +$users = User::with('posts.comment')->get(); +``` + +A relation registered using `resolveRelationUsing` while the application boots is found, though the segments after it are not checked. Relations on an abstract model or a union of models are not checked, since each model may define them differently. + ### Schema Dumps From 6858b31315fe61f21be06a37236dc73f5b791703 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:56 +0000 Subject: [PATCH 130/303] Restore fluent authentication and provider contracts Restore receiver identity on authentication managers, guards, authorization responses and provider configuration. Include the relocated Socialite setters and their parent-call contracts while preserving coroutine-local state behavior. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/auth/src/Access/AuthorizationException.php | 6 ++++++ src/auth/src/Access/Gate.php | 16 ++++++++++++++++ src/auth/src/Access/Response.php | 4 ++++ src/auth/src/AuthManager.php | 16 ++++++++++++++++ src/auth/src/EloquentUserProvider.php | 5 +++++ src/auth/src/RequestGuard.php | 4 ++++ src/auth/src/SessionGuard.php | 6 ++++++ src/auth/src/TokenGuard.php | 4 ++++ src/contracts/src/Auth/Access/Gate.php | 10 ++++++++++ src/contracts/src/Auth/Guard.php | 2 ++ src/socialite/src/AbstractProvider.php | 12 ++++++++++++ src/socialite/src/AbstractUser.php | 4 ++++ src/socialite/src/Testing/SocialiteFake.php | 2 ++ src/socialite/src/Two/AbstractProvider.php | 13 +++++++++++++ src/socialite/src/Two/FacebookProvider.php | 12 ++++++++++++ src/socialite/src/Two/GitlabProvider.php | 2 ++ src/socialite/src/Two/SlackProvider.php | 4 ++++ src/socialite/src/Two/User.php | 10 ++++++++++ 18 files changed, 132 insertions(+) diff --git a/src/auth/src/Access/AuthorizationException.php b/src/auth/src/Access/AuthorizationException.php index c67faf7d9..c65108dfc 100644 --- a/src/auth/src/Access/AuthorizationException.php +++ b/src/auth/src/Access/AuthorizationException.php @@ -39,6 +39,8 @@ public function response(): ?Response /** * Set the response from the gate. + * + * @return $this */ public function setResponse(?Response $response): static { @@ -49,6 +51,8 @@ public function setResponse(?Response $response): static /** * Set the HTTP response status code. + * + * @return $this */ public function withStatus(?int $status): static { @@ -59,6 +63,8 @@ public function withStatus(?int $status): static /** * Set the HTTP response status code to 404. + * + * @return $this */ public function asNotFound(): static { diff --git a/src/auth/src/Access/Gate.php b/src/auth/src/Access/Gate.php index e250bfb64..679c36e2c 100644 --- a/src/auth/src/Access/Gate.php +++ b/src/auth/src/Access/Gate.php @@ -179,6 +179,8 @@ protected function authorizeOnDemand(mixed $condition, ?string $message, int|str * Boot-only. The definition persists on the singleton Gate for the worker * lifetime and affects authorization in every subsequent request. * + * @return $this + * * @throws InvalidArgumentException */ public function define(UnitEnum|string $ability, array|callable|string $callback): static @@ -207,6 +209,8 @@ public function define(UnitEnum|string $ability, array|callable|string $callback * * Boot-only. The definitions persist on the singleton Gate for the worker * lifetime and affect authorization in every subsequent request. + * + * @return $this */ public function resource(string $name, string $class, ?array $abilities = null): static { @@ -265,6 +269,8 @@ protected function buildAbilityCallback(string $ability, string $callback): Clos * * Boot-only. The policy persists on the singleton Gate for the worker * lifetime and affects authorization in every subsequent request. + * + * @return $this */ public function policy(string $class, string $policy): static { @@ -282,6 +288,8 @@ public function policy(string $class, string $policy): static * * Boot-only. The callback persists on the singleton Gate for the worker * lifetime and runs during authorization in every subsequent request. + * + * @return $this */ public function before(callable $callback): static { @@ -295,6 +303,8 @@ public function before(callable $callback): static * * Boot-only. The callback persists on the singleton Gate for the worker * lifetime and runs during authorization in every subsequent request. + * + * @return $this */ public function after(callable $callback): static { @@ -706,6 +716,8 @@ protected function guessPolicyName(string $class): array * * Boot-only. The callback persists on the singleton Gate for the worker * lifetime and affects policy discovery in every subsequent request. + * + * @return $this */ public function guessPolicyNamesUsing(callable $callback): static { @@ -860,6 +872,8 @@ public function policies(): array * * Boot-only. The response persists on the singleton Gate for the worker * lifetime and affects authorization failures in every subsequent request. + * + * @return $this */ public function defaultDenialResponse(Response $response): static { @@ -874,6 +888,8 @@ public function defaultDenialResponse(Response $response): static * Tests only. Swaps the container on the singleton Gate used by every * coroutine; per-request use races across coroutines and breaks every * concurrent authorization check. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/auth/src/Access/Response.php b/src/auth/src/Access/Response.php index 0524b348b..fadd8d99d 100644 --- a/src/auth/src/Access/Response.php +++ b/src/auth/src/Access/Response.php @@ -110,6 +110,8 @@ public function authorize(): static /** * Set the HTTP response status code. + * + * @return $this */ public function withStatus(?int $status): static { @@ -120,6 +122,8 @@ public function withStatus(?int $status): static /** * Set the HTTP response status code to 404. + * + * @return $this */ public function asNotFound(): static { diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php index 6846988d7..b70379dd6 100755 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -219,6 +219,8 @@ public function setDefaultDriver(UnitEnum|string $name): void /** * Register a new callback based request guard. + * + * @return $this */ public function viaRequest(string $driver, callable $callback): static { @@ -246,6 +248,8 @@ public function userResolver(): Closure * Set the callback to be used to resolve users. * * Uses coroutine Context so one request's override doesn't affect others. + * + * @return $this */ public function resolveUsersUsing(Closure $userResolver): static { @@ -292,6 +296,8 @@ public function clearUserCache(mixed $identifier, UnitEnum|string|null $guard = * Boot-only. The callback persists in authentication middleware and * exception static properties for the worker lifetime and affects every * subsequent unauthenticated or session-mismatch request. + * + * @return $this */ public function redirectGuestsTo(callable|string|null $redirect): static { @@ -306,6 +312,8 @@ public function redirectGuestsTo(callable|string|null $redirect): static * Boot-only. The callback persists in the guest middleware static property * for the worker lifetime and affects every subsequent already-authenticated * guest-route request. + * + * @return $this */ public function redirectUsersTo(callable|string $redirect): static { @@ -320,6 +328,8 @@ public function redirectUsersTo(callable|string $redirect): static * Boot-only. The callbacks persist in authentication middleware and * exception static properties for the worker lifetime and affect every * subsequent matching request. + * + * @return $this */ public function redirectTo(callable|string|null $guests = null, callable|string|null $users = null): static { @@ -359,6 +369,8 @@ public function extend(string $driver, Closure $callback): static * Boot-only. The callback persists in the singleton's customProviderCreators * array for the worker lifetime and applies to every subsequent user * provider resolution. + * + * @return $this */ public function provider(string $name, Closure $callback): static { @@ -382,6 +394,8 @@ public function hasResolvedGuards(): bool * coroutines may already hold references to the prior guard instances * (each holding its own coroutine-scoped state) that next resolution * will not share. + * + * @return $this */ public function forgetGuards(): static { @@ -427,6 +441,8 @@ public function getAuthContextKeys(): array * Tests only. Swaps the singleton's container reference; per-request use * races across coroutines and breaks every concurrent request's auth * resolution. + * + * @return $this */ public function setApplication(Container $app): static { diff --git a/src/auth/src/EloquentUserProvider.php b/src/auth/src/EloquentUserProvider.php index 926234d8e..77cd3ced2 100755 --- a/src/auth/src/EloquentUserProvider.php +++ b/src/auth/src/EloquentUserProvider.php @@ -296,6 +296,7 @@ public function rehashPasswordIfRequired(UserContract $user, #[SensitiveParamete * the provider used by every subsequent authentication lookup. * * @param null|array $tags optional tag names enabling tag-based bulk flush; requires any-mode tag support + * @return $this * * @throws InvalidArgumentException when the TTL, tags, or resolved store are not supported */ @@ -637,6 +638,8 @@ public function getHasher(): HasherContract * * Boot or tests only. User providers are held by cached guards; runtime use * mutates password verification for every subsequent authentication lookup. + * + * @return $this */ public function setHasher(HasherContract $hasher): static { @@ -662,6 +665,7 @@ public function getModel(): string * changes the model used by every subsequent authentication lookup. * * @param class-string $model + * @return $this */ public function setModel(string $model): static { @@ -691,6 +695,7 @@ public function getQueryCallback(): ?Closure * mutates the query applied to every subsequent authentication lookup. * * @param null|(Closure(Builder<*>):mixed) $queryCallback + * @return $this */ public function withQuery(?Closure $queryCallback = null): static { diff --git a/src/auth/src/RequestGuard.php b/src/auth/src/RequestGuard.php index 61d83955f..25e793966 100644 --- a/src/auth/src/RequestGuard.php +++ b/src/auth/src/RequestGuard.php @@ -116,6 +116,8 @@ public function hasUser(): bool /** * Set the current user. + * + * @return $this */ public function setUser(AuthenticatableContract $user): static { @@ -126,6 +128,8 @@ public function setUser(AuthenticatableContract $user): static /** * Forget the current user. + * + * @return $this */ public function forgetUser(): static { diff --git a/src/auth/src/SessionGuard.php b/src/auth/src/SessionGuard.php index 4de41ca28..7c6319e90 100644 --- a/src/auth/src/SessionGuard.php +++ b/src/auth/src/SessionGuard.php @@ -816,6 +816,8 @@ protected function getRememberDuration(): int * * Boot-only. The duration is stored on the worker-lifetime guard and affects * every subsequent request. + * + * @return $this */ public function setRememberDuration(int $minutes): static { @@ -894,6 +896,8 @@ public function hasUser(): bool /** * Set the current user. + * + * @return $this */ public function setUser(AuthenticatableContract $user): static { @@ -908,6 +912,8 @@ public function setUser(AuthenticatableContract $user): static /** * Forget the current user. + * + * @return $this */ public function forgetUser(): static { diff --git a/src/auth/src/TokenGuard.php b/src/auth/src/TokenGuard.php index 9b84e3a94..cb6018b89 100644 --- a/src/auth/src/TokenGuard.php +++ b/src/auth/src/TokenGuard.php @@ -156,6 +156,8 @@ public function hasUser(): bool /** * Set the current user. + * + * @return $this */ public function setUser(AuthenticatableContract $user): static { @@ -166,6 +168,8 @@ public function setUser(AuthenticatableContract $user): static /** * Forget the current user. + * + * @return $this */ public function forgetUser(): static { diff --git a/src/contracts/src/Auth/Access/Gate.php b/src/contracts/src/Auth/Access/Gate.php index e5d27975e..10e3261eb 100644 --- a/src/contracts/src/Auth/Access/Gate.php +++ b/src/contracts/src/Auth/Access/Gate.php @@ -25,27 +25,37 @@ public function has(array|UnitEnum|string $ability): bool; /** * Define a new ability. * + * @return $this + * * @throws InvalidArgumentException */ public function define(UnitEnum|string $ability, array|callable|string $callback): static; /** * Define abilities for a resource. + * + * @return $this */ public function resource(string $name, string $class, ?array $abilities = null): static; /** * Define a policy class for a given class type. + * + * @return $this */ public function policy(string $class, string $policy): static; /** * Register a callback to run before all Gate checks. + * + * @return $this */ public function before(callable $callback): static; /** * Register a callback to run after all Gate checks. + * + * @return $this */ public function after(callable $callback): static; diff --git a/src/contracts/src/Auth/Guard.php b/src/contracts/src/Auth/Guard.php index 9261697b9..06b675b28 100644 --- a/src/contracts/src/Auth/Guard.php +++ b/src/contracts/src/Auth/Guard.php @@ -50,6 +50,8 @@ public function hasUser(): bool; /** * Set the current user. + * + * @return $this */ public function setUser(Authenticatable $user): static; } diff --git a/src/socialite/src/AbstractProvider.php b/src/socialite/src/AbstractProvider.php index 6a17f0d6f..e7a219445 100644 --- a/src/socialite/src/AbstractProvider.php +++ b/src/socialite/src/AbstractProvider.php @@ -50,6 +50,8 @@ public function __construct(Request $request, protected array $guzzle = []) * * Boot-only. The configuration persists for the worker lifetime and affects * every subsequent request. Use setConfig() for per-request overrides. + * + * @return $this */ public function withConfig(#[SensitiveParameter] array $config): static { @@ -63,6 +65,8 @@ public function withConfig(#[SensitiveParameter] array $config): static * * The override is isolated to the current coroutine and must be applied * independently on the redirect and callback requests. + * + * @return $this */ public function setConfig(#[SensitiveParameter] array $config): static { @@ -104,6 +108,8 @@ protected function getHttpClient(): Client /** * Set the Guzzle HTTP client instance. + * + * @return $this */ public function setHttpClient(Client $client): static { @@ -117,6 +123,8 @@ public function setHttpClient(Client $client): static * * Stores the request in coroutine context so cached providers read the * current request without leaking it to concurrent coroutines. + * + * @return $this */ public function setRequest(Request $request): static { @@ -159,6 +167,8 @@ protected function isStateless(): bool /** * Indicate that the provider should operate as stateless. + * + * @return $this */ public function stateless(): static { @@ -177,6 +187,8 @@ protected function getState(): string /** * Set the custom parameters of the request. + * + * @return $this */ public function with(array $parameters): static { diff --git a/src/socialite/src/AbstractUser.php b/src/socialite/src/AbstractUser.php index 78f42f2c0..eedf06caf 100644 --- a/src/socialite/src/AbstractUser.php +++ b/src/socialite/src/AbstractUser.php @@ -94,6 +94,8 @@ public function getRaw(): array /** * Set the raw user array from the provider. + * + * @return $this */ public function setRaw(array $user): static { @@ -104,6 +106,8 @@ public function setRaw(array $user): static /** * Map the given array onto the user's properties. + * + * @return $this */ public function map(array $attributes): static { diff --git a/src/socialite/src/Testing/SocialiteFake.php b/src/socialite/src/Testing/SocialiteFake.php index faa69cc42..4022bc932 100644 --- a/src/socialite/src/Testing/SocialiteFake.php +++ b/src/socialite/src/Testing/SocialiteFake.php @@ -43,6 +43,8 @@ public function driver(UnitEnum|string|null $driver = null): Provider /** * Register a fake user for the given driver. + * + * @return $this */ public function fake(string $driver, UserContract|Closure|null $user = null): static { diff --git a/src/socialite/src/Two/AbstractProvider.php b/src/socialite/src/Two/AbstractProvider.php index d0d33e22b..acd6d8e79 100644 --- a/src/socialite/src/Two/AbstractProvider.php +++ b/src/socialite/src/Two/AbstractProvider.php @@ -176,6 +176,8 @@ protected function getUser(): ?User /** * Set the user instance in the context. + * + * @return $this */ protected function setUser(User $user): static { @@ -379,6 +381,8 @@ protected function getCode(): string /** * Merge the scopes of the requested access. + * + * @return $this */ public function scopes(array|string $scopes): static { @@ -391,6 +395,8 @@ public function scopes(array|string $scopes): static /** * Set the scopes of the requested access. + * + * @return $this */ public function setScopes(array|string $scopes): static { @@ -419,6 +425,7 @@ public function getScopes(): array * every subsequent request. Use setConfig() for per-request redirect values. * * @param Closure(array): string $formatter + * @return $this */ public function withRedirectFormatter(Closure $formatter): static { @@ -429,6 +436,8 @@ public function withRedirectFormatter(Closure $formatter): static /** * Set the redirect URL. + * + * @return $this */ public function redirectUrl(string $url): static { @@ -513,6 +522,8 @@ protected function usesPKCE(): bool /** * Enable PKCE for the provider. + * + * @return $this */ public function enablePKCE(): static { @@ -552,6 +563,8 @@ protected function getCodeChallengeMethod(): string * * Extends the base setConfig to also handle OAuth2-specific credential * keys (client_id, client_secret, redirect) in coroutine context. + * + * @return $this */ public function setConfig(#[SensitiveParameter] array $config): static { diff --git a/src/socialite/src/Two/FacebookProvider.php b/src/socialite/src/Two/FacebookProvider.php index ac80d0380..d2a468a32 100644 --- a/src/socialite/src/Two/FacebookProvider.php +++ b/src/socialite/src/Two/FacebookProvider.php @@ -195,6 +195,8 @@ protected function getCodeFields(?string $state = null): array /** * Set the user fields to request from Facebook. + * + * @return $this */ public function fields(array $fields): static { @@ -213,6 +215,8 @@ protected function getFields(): array /** * Set the dialog to be displayed as a popup. + * + * @return $this */ public function asPopup(): static { @@ -231,6 +235,8 @@ protected function getPopup(): bool /** * Re-request permissions which were previously declined. + * + * @return $this */ public function reRequest(): static { @@ -257,6 +263,8 @@ public function lastToken(): ?string /** * Set the last access token used. + * + * @return $this */ protected function setLastToken(#[SensitiveParameter] string $token): static { @@ -267,6 +275,8 @@ protected function setLastToken(#[SensitiveParameter] string $token): static /** * Specify the nonce expected when using Facebook Limited Login OIDC tokens. + * + * @return $this */ public function withNonce(string $nonce): static { @@ -285,6 +295,8 @@ protected function getExpectedNonce(): ?string /** * Specify which graph version should be used. + * + * @return $this */ public function usingGraphVersion(string $version): static { diff --git a/src/socialite/src/Two/GitlabProvider.php b/src/socialite/src/Two/GitlabProvider.php index 288d9507e..a06024ed4 100644 --- a/src/socialite/src/Two/GitlabProvider.php +++ b/src/socialite/src/Two/GitlabProvider.php @@ -26,6 +26,8 @@ class GitlabProvider extends AbstractProvider implements ProviderInterface /** * Set the Gitlab instance host. + * + * @return $this */ public function setHost(?string $host): static { diff --git a/src/socialite/src/Two/SlackProvider.php b/src/socialite/src/Two/SlackProvider.php index ffd75705d..1612a8981 100644 --- a/src/socialite/src/Two/SlackProvider.php +++ b/src/socialite/src/Two/SlackProvider.php @@ -22,6 +22,8 @@ class SlackProvider extends AbstractProvider implements ProviderInterface /** * Indicate that the requested token should be for a bot user. + * + * @return $this */ public function asBotUser(): static { @@ -40,6 +42,8 @@ protected function getScopeKey(): string /** * Indicate that the requested token should be for a user. + * + * @return $this */ protected function setScopeKey(string $scopeKey): static { diff --git a/src/socialite/src/Two/User.php b/src/socialite/src/Two/User.php index 9df9190cc..900acc575 100644 --- a/src/socialite/src/Two/User.php +++ b/src/socialite/src/Two/User.php @@ -62,6 +62,8 @@ public static function fake(#[SensitiveParameter] array $attributes = []): stati /** * Set the token on the user. + * + * @return $this */ public function setToken(#[SensitiveParameter] ?string $token): static { @@ -72,6 +74,8 @@ public function setToken(#[SensitiveParameter] ?string $token): static /** * Set the refresh token required to obtain a new access token. + * + * @return $this */ public function setRefreshToken(#[SensitiveParameter] ?string $refreshToken): static { @@ -82,6 +86,8 @@ public function setRefreshToken(#[SensitiveParameter] ?string $refreshToken): st /** * Set the number of seconds the access token is valid for. + * + * @return $this */ public function setExpiresIn(?int $expiresIn): static { @@ -92,6 +98,8 @@ public function setExpiresIn(?int $expiresIn): static /** * Set the scopes that were approved by the user during authentication. + * + * @return $this */ public function setApprovedScopes(array $approvedScopes): static { @@ -102,6 +110,8 @@ public function setApprovedScopes(array $approvedScopes): static /** * Set the complete access token response on the user. + * + * @return $this */ public function setAccessTokenResponseBody(#[SensitiveParameter] array $accessTokenResponseBody): static { From f8181967cbcee8c5b618b7b7d092ae7062089feb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 131/303] Restore fluent broadcasting and event contracts Describe receiver-preserving broadcast configuration, socket interaction and queued event callbacks. Retain native signatures and every existing runtime path. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/broadcasting/src/BroadcastManager.php | 4 ++++ src/broadcasting/src/Broadcasters/Broadcaster.php | 2 ++ src/broadcasting/src/FakePendingBroadcast.php | 4 ++++ src/broadcasting/src/InteractsWithBroadcasting.php | 2 ++ src/broadcasting/src/InteractsWithSockets.php | 4 ++++ src/broadcasting/src/PendingBroadcast.php | 4 ++++ src/events/src/Dispatcher.php | 2 ++ src/events/src/QueuedClosure.php | 12 ++++++++++++ 8 files changed, 34 insertions(+) diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 780e1e164..2e58f8f5e 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -682,6 +682,8 @@ public function getApplication(): Container * Tests only. Swaps the singleton's container reference; per-request use * races across coroutines and breaks every concurrent request resolving * broadcasters through this manager. + * + * @return $this */ public function setApplication(Container $app): static { @@ -695,6 +697,8 @@ public function setApplication(Container $app): static * * Boot or tests only. This is cache-only: pooled broadcasters remain * shared resources until purged or reclaimed by their idle TTL. + * + * @return $this */ public function forgetDrivers(): static { diff --git a/src/broadcasting/src/Broadcasters/Broadcaster.php b/src/broadcasting/src/Broadcasters/Broadcaster.php index c25812c25..5072dca7e 100644 --- a/src/broadcasting/src/Broadcasters/Broadcaster.php +++ b/src/broadcasting/src/Broadcasters/Broadcaster.php @@ -93,6 +93,8 @@ public function resolveAuthenticatedUserUsing(?Closure $callback): void * Boot-only. The channel authorizer and options persist in shared static * state on the Broadcaster class for the worker lifetime and apply to * every subsequent connection authentication. + * + * @return $this */ public function channel(HasBroadcastChannel|string $channel, callable|string $callback, array $options = []): static { diff --git a/src/broadcasting/src/FakePendingBroadcast.php b/src/broadcasting/src/FakePendingBroadcast.php index 21b141783..18212c563 100644 --- a/src/broadcasting/src/FakePendingBroadcast.php +++ b/src/broadcasting/src/FakePendingBroadcast.php @@ -17,6 +17,8 @@ public function __construct() /** * Broadcast the event using a specific broadcaster. + * + * @return $this */ public function via(UnitEnum|string|null $connection = null): static { @@ -25,6 +27,8 @@ public function via(UnitEnum|string|null $connection = null): static /** * Broadcast the event to everyone except the current user. + * + * @return $this */ public function toOthers(): static { diff --git a/src/broadcasting/src/InteractsWithBroadcasting.php b/src/broadcasting/src/InteractsWithBroadcasting.php index 10f223f4e..479fd32fa 100644 --- a/src/broadcasting/src/InteractsWithBroadcasting.php +++ b/src/broadcasting/src/InteractsWithBroadcasting.php @@ -18,6 +18,8 @@ trait InteractsWithBroadcasting /** * Broadcast the event using a specific broadcaster. + * + * @return $this */ public function broadcastVia(UnitEnum|array|string|null $connection = null): static { diff --git a/src/broadcasting/src/InteractsWithSockets.php b/src/broadcasting/src/InteractsWithSockets.php index e902547c6..fd3aa03ac 100644 --- a/src/broadcasting/src/InteractsWithSockets.php +++ b/src/broadcasting/src/InteractsWithSockets.php @@ -15,6 +15,8 @@ trait InteractsWithSockets /** * Exclude the current user from receiving the broadcast. + * + * @return $this */ public function dontBroadcastToCurrentUser(): static { @@ -25,6 +27,8 @@ public function dontBroadcastToCurrentUser(): static /** * Broadcast the event to everyone. + * + * @return $this */ public function broadcastToEveryone(): static { diff --git a/src/broadcasting/src/PendingBroadcast.php b/src/broadcasting/src/PendingBroadcast.php index c9125c4ad..31ac63bc8 100644 --- a/src/broadcasting/src/PendingBroadcast.php +++ b/src/broadcasting/src/PendingBroadcast.php @@ -22,6 +22,8 @@ public function __construct( /** * Broadcast the event using a specific broadcaster. + * + * @return $this */ public function via(UnitEnum|string|null $connection = null): static { @@ -36,6 +38,8 @@ public function via(UnitEnum|string|null $connection = null): static /** * Broadcast the event to everyone except the current user. + * + * @return $this */ public function toOthers(): static { diff --git a/src/events/src/Dispatcher.php b/src/events/src/Dispatcher.php index 2aae25521..2717e362e 100755 --- a/src/events/src/Dispatcher.php +++ b/src/events/src/Dispatcher.php @@ -1160,6 +1160,7 @@ protected function queueRoutesContainer(): ContainerContract * worker lifetime and is consulted whenever a queued listener is dispatched. * * @param callable(): QueueFactory $resolver + * @return $this */ public function setQueueResolver(callable $resolver): static { @@ -1187,6 +1188,7 @@ protected function resolveTransactionManager(): ?DatabaseTransactionsManager * worker lifetime and is consulted for every transaction-aware dispatch. * * @param callable(): ?DatabaseTransactionsManager $resolver + * @return $this */ public function setTransactionManagerResolver(callable $resolver): static { diff --git a/src/events/src/QueuedClosure.php b/src/events/src/QueuedClosure.php index 86739c22c..81ec5ef76 100644 --- a/src/events/src/QueuedClosure.php +++ b/src/events/src/QueuedClosure.php @@ -62,6 +62,8 @@ public function __construct(Closure $closure) /** * Set the desired connection for the job. + * + * @return $this */ public function onConnection(UnitEnum|string|null $connection): static { @@ -74,6 +76,8 @@ public function onConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the job. + * + * @return $this */ public function onQueue(UnitEnum|string|null $queue): static { @@ -88,6 +92,8 @@ public function onQueue(UnitEnum|string|null $queue): static * Set the desired job "group". * * This feature is only supported by some queues, such as Amazon SQS. + * + * @return $this */ public function onGroup(UnitEnum|string $group): static { @@ -100,6 +106,8 @@ public function onGroup(UnitEnum|string $group): static * Set the desired job deduplicator callback. * * This feature is only supported by some queues, such as Amazon SQS FIFO. + * + * @return $this */ public function withDeduplicator(?callable $deduplicator): static { @@ -112,6 +120,8 @@ public function withDeduplicator(?callable $deduplicator): static /** * Set the desired delay in seconds for the job. + * + * @return $this */ public function delay(DateTimeInterface|DateInterval|int|null $delay): static { @@ -122,6 +132,8 @@ public function delay(DateTimeInterface|DateInterval|int|null $delay): static /** * Specify a callback that should be invoked if the queued listener job fails. + * + * @return $this */ public function catch(Closure $closure): static { From 9c6cc0d5df6affe708dc262ceba78ebff489309d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 132/303] Restore fluent dispatch and queue contracts Restore identity annotations for dispatch configuration, batches, queue middleware and worker configuration. Include the matching dispatcher and queue interfaces and Hypervel outbox configuration methods. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/bus/src/Batchable.php | 2 ++ src/bus/src/Dispatcher.php | 8 +++++ src/bus/src/PendingBatch.php | 27 ++++++++++++++++ src/bus/src/Queueable.php | 28 ++++++++++++++++ src/contracts/src/Bus/Dispatcher.php | 4 +++ src/contracts/src/Queue/Queue.php | 2 ++ src/foundation/src/Bus/PendingChain.php | 12 +++++++ .../src/Bus/PendingClosureDispatch.php | 2 ++ src/foundation/src/Bus/PendingDispatch.php | 32 +++++++++++++++++++ src/queue/src/CallQueuedClosure.php | 4 +++ src/queue/src/InteractsWithQueue.php | 18 +++++++++++ src/queue/src/Middleware/RateLimited.php | 8 +++++ .../src/Middleware/ThrottlesExceptions.php | 17 ++++++++++ .../src/Middleware/WithoutOverlapping.php | 12 +++++++ src/queue/src/Queue.php | 5 +++ src/queue/src/QueueManager.php | 2 ++ src/queue/src/Worker.php | 4 +++ 17 files changed, 187 insertions(+) diff --git a/src/bus/src/Batchable.php b/src/bus/src/Batchable.php index 4c5ba4e10..395e948fc 100644 --- a/src/bus/src/Batchable.php +++ b/src/bus/src/Batchable.php @@ -51,6 +51,8 @@ public function batching(): bool /** * Set the batch ID on the job. + * + * @return $this */ public function withBatchId(string $batchId): static { diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php index c3d43c45a..2ef692789 100644 --- a/src/bus/src/Dispatcher.php +++ b/src/bus/src/Dispatcher.php @@ -299,6 +299,8 @@ protected function queueRoutesContainer(): Container * Boot-only. The pipes persist on the singleton dispatcher for the worker * lifetime and apply to every subsequent dispatch; per-request use races * across coroutines. + * + * @return $this */ public function pipeThrough(array $pipes): static { @@ -312,6 +314,8 @@ public function pipeThrough(array $pipes): static * * Boot-only. The handler map persists on the singleton dispatcher for the * worker lifetime; per-request use races across coroutines. + * + * @return $this */ public function map(array $map): static { @@ -325,6 +329,8 @@ public function map(array $map): static * * Boot-only. Toggles a flag on the singleton dispatcher for the worker * lifetime; per-request use races across coroutines. + * + * @return $this */ public function withDispatchingAfterResponses(): static { @@ -338,6 +344,8 @@ public function withDispatchingAfterResponses(): static * * Boot-only. Toggles a flag on the singleton dispatcher for the worker * lifetime; per-request use races across coroutines. + * + * @return $this */ public function withoutDispatchingAfterResponses(): static { diff --git a/src/bus/src/PendingBatch.php b/src/bus/src/PendingBatch.php index 74d9495ba..e3e3737d6 100644 --- a/src/bus/src/PendingBatch.php +++ b/src/bus/src/PendingBatch.php @@ -56,6 +56,8 @@ public function __construct( /** * Add jobs to the batch. + * + * @return $this */ public function add(iterable|object $jobs): static { @@ -94,6 +96,8 @@ protected function ensureJobIsBatchable(object|array $job): void /** * Add a callback to be executed when the batch is stored. + * + * @return $this */ public function before(callable $callback): static { @@ -112,6 +116,8 @@ public function beforeCallbacks(): array /** * Add a callback to be executed after a job in the batch have executed successfully. + * + * @return $this */ public function progress(callable $callback): static { @@ -130,6 +136,8 @@ public function progressCallbacks(): array /** * Add a callback to be executed after all jobs in the batch have executed successfully. + * + * @return $this */ public function then(callable $callback): static { @@ -148,6 +156,8 @@ public function thenCallbacks(): array /** * Add a callback to be executed after the first failing job in the batch. + * + * @return $this */ public function catch(callable $callback): static { @@ -166,6 +176,8 @@ public function catchCallbacks(): array /** * Add a callback to be executed after the batch has finished executing. + * + * @return $this */ public function finally(callable $callback): static { @@ -188,6 +200,7 @@ public function finallyCallbacks(): array * Optionally, add callbacks to be executed upon each job failure. * * @param array|bool|(callable(Batch, ?Throwable): mixed) $param + * @return $this */ public function allowFailures(mixed $param = true): static { @@ -236,6 +249,8 @@ private function registerCallback(string $type, Closure|callable $callback): voi /** * Set the name for the batch. + * + * @return $this */ public function name(string $name): static { @@ -246,6 +261,8 @@ public function name(string $name): static /** * Specify the queue connection that the batched jobs should run on. + * + * @return $this */ public function onConnection(UnitEnum|string $connection): static { @@ -266,6 +283,8 @@ public function connection(): ?string /** * Specify the queue that the batched jobs should run on. + * + * @return $this */ public function onQueue(UnitEnum|string|null $queue): static { @@ -286,6 +305,8 @@ public function queue(): ?string /** * Dispatch batched jobs through the transactional outbox by default. + * + * @return $this */ public function viaOutbox(): static { @@ -296,6 +317,8 @@ public function viaOutbox(): static /** * Dispatch batched jobs without the transactional outbox by default. + * + * @return $this */ public function withoutOutbox(): static { @@ -308,6 +331,8 @@ public function withoutOutbox(): static /** * Stage batched jobs on the given database connection by default. + * + * @return $this */ public function onOutboxDatabase(UnitEnum|string $connection): static { @@ -325,6 +350,8 @@ public function onOutboxDatabase(UnitEnum|string $connection): static /** * Add additional data into the batch's options array. + * + * @return $this */ public function withOption(string $key, mixed $value): static { diff --git a/src/bus/src/Queueable.php b/src/bus/src/Queueable.php index 15678c77f..0cfa7a305 100644 --- a/src/bus/src/Queueable.php +++ b/src/bus/src/Queueable.php @@ -95,6 +95,8 @@ trait Queueable /** * Set the desired connection for the job. + * + * @return $this */ public function onConnection(UnitEnum|string|null $connection): static { @@ -107,6 +109,8 @@ public function onConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the job. + * + * @return $this */ public function onQueue(UnitEnum|string|null $queue): static { @@ -121,6 +125,8 @@ public function onQueue(UnitEnum|string|null $queue): static * Set the desired job message group. * * This feature is only supported by some queues, such as Amazon SQS. + * + * @return $this */ public function onGroup(array|UnitEnum|string|int|null $group): static { @@ -133,6 +139,8 @@ public function onGroup(array|UnitEnum|string|int|null $group): static * Set the desired job deduplicator callback. * * This feature is only supported by some queues, such as Amazon SQS FIFO. + * + * @return $this */ public function withDeduplicator(array|callable|null $deduplicator): static { @@ -151,6 +159,8 @@ public function withDeduplicator(array|callable|null $deduplicator): static /** * Set the desired connection for the chain. + * + * @return $this */ public function allOnConnection(UnitEnum|string|null $connection): static { @@ -166,6 +176,8 @@ public function allOnConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the chain. + * + * @return $this */ public function allOnQueue(UnitEnum|string|null $queue): static { @@ -181,6 +193,8 @@ public function allOnQueue(UnitEnum|string|null $queue): static /** * Set the desired delay in seconds for the job. + * + * @return $this */ public function delay(array|DateInterval|DateTimeInterface|int|null $delay): static { @@ -191,6 +205,8 @@ public function delay(array|DateInterval|DateTimeInterface|int|null $delay): sta /** * Set the delay for the job to zero seconds. + * + * @return $this */ public function withoutDelay(): static { @@ -201,6 +217,8 @@ public function withoutDelay(): static /** * Indicate that the job should be dispatched after the open parent database transactions have committed. + * + * @return $this */ public function afterCommit(): static { @@ -211,6 +229,8 @@ public function afterCommit(): static /** * Indicate that the job should not wait until database transactions have been committed before dispatching. + * + * @return $this */ public function beforeCommit(): static { @@ -221,6 +241,8 @@ public function beforeCommit(): static /** * Specify the middleware the job should be dispatched through. + * + * @return $this */ public function through(array|object $middleware): static { @@ -231,6 +253,8 @@ public function through(array|object $middleware): static /** * Set the jobs that should run if this job is successful. + * + * @return $this */ public function chain(array $chain): static { @@ -243,6 +267,8 @@ public function chain(array $chain): static /** * Prepend a job to the current chain so that it is run after the currently running job. + * + * @return $this */ public function prependToChain(mixed $job): static { @@ -257,6 +283,8 @@ public function prependToChain(mixed $job): static /** * Append a job to the end of the current chain. + * + * @return $this */ public function appendToChain(mixed $job): static { diff --git a/src/contracts/src/Bus/Dispatcher.php b/src/contracts/src/Bus/Dispatcher.php index e6e142323..ddbc9769f 100644 --- a/src/contracts/src/Bus/Dispatcher.php +++ b/src/contracts/src/Bus/Dispatcher.php @@ -45,11 +45,15 @@ public function getCommandHandler(mixed $command): mixed; /** * Set the pipes commands should be piped through before dispatching. + * + * @return $this */ public function pipeThrough(array $pipes): static; /** * Map a command to a handler. + * + * @return $this */ public function map(array $map): static; } diff --git a/src/contracts/src/Queue/Queue.php b/src/contracts/src/Queue/Queue.php index 2e2930ac9..8c602bacf 100644 --- a/src/contracts/src/Queue/Queue.php +++ b/src/contracts/src/Queue/Queue.php @@ -77,6 +77,8 @@ public function getConnectionName(): string; /** * Set the connection name for the queue. + * + * @return $this */ public function setConnectionName(string $name): static; } diff --git a/src/foundation/src/Bus/PendingChain.php b/src/foundation/src/Bus/PendingChain.php index 8bd4f3f0e..93e64f73b 100644 --- a/src/foundation/src/Bus/PendingChain.php +++ b/src/foundation/src/Bus/PendingChain.php @@ -57,6 +57,8 @@ public function __construct( /** * Set the desired connection for the job. + * + * @return $this */ public function onConnection(UnitEnum|string|null $connection): static { @@ -69,6 +71,8 @@ public function onConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the job. + * + * @return $this */ public function onQueue(UnitEnum|string|null $queue): static { @@ -81,6 +85,8 @@ public function onQueue(UnitEnum|string|null $queue): static /** * Prepend a job to the chain. + * + * @return $this */ public function prepend(mixed $job): static { @@ -101,6 +107,8 @@ public function prepend(mixed $job): static /** * Append a job to the chain. + * + * @return $this */ public function append(mixed $job): static { @@ -119,6 +127,8 @@ public function append(mixed $job): static /** * Set the desired delay in seconds for the chain. + * + * @return $this */ public function delay(DateInterval|DateTimeInterface|int|null $delay): static { @@ -129,6 +139,8 @@ public function delay(DateInterval|DateTimeInterface|int|null $delay): static /** * Add a callback to be executed on job failure. + * + * @return $this */ public function catch(callable $callback): static { diff --git a/src/foundation/src/Bus/PendingClosureDispatch.php b/src/foundation/src/Bus/PendingClosureDispatch.php index 00a7d2e1b..dbe764f67 100644 --- a/src/foundation/src/Bus/PendingClosureDispatch.php +++ b/src/foundation/src/Bus/PendingClosureDispatch.php @@ -10,6 +10,8 @@ class PendingClosureDispatch extends PendingDispatch { /** * Add a callback to be executed if the job fails. + * + * @return $this */ public function catch(Closure $callback): static { diff --git a/src/foundation/src/Bus/PendingDispatch.php b/src/foundation/src/Bus/PendingDispatch.php index 1f0bf3d27..221f43907 100644 --- a/src/foundation/src/Bus/PendingDispatch.php +++ b/src/foundation/src/Bus/PendingDispatch.php @@ -42,6 +42,8 @@ public function __construct( /** * Set the desired connection for the job. + * + * @return $this */ public function onConnection(UnitEnum|string|null $connection): static { @@ -52,6 +54,8 @@ public function onConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the job. + * + * @return $this */ public function onQueue(UnitEnum|string|null $queue): static { @@ -64,6 +68,8 @@ public function onQueue(UnitEnum|string|null $queue): static * Set the desired job message group. * * This feature is only supported by some queues, such as Amazon SQS. + * + * @return $this */ public function onGroup(array|UnitEnum|string|int|null $group): static { @@ -78,6 +84,8 @@ public function onGroup(array|UnitEnum|string|int|null $group): static * Set the desired job deduplicator callback. * * This feature is only supported by some queues, such as Amazon SQS FIFO. + * + * @return $this */ public function withDeduplicator(array|callable|null $deduplicator): static { @@ -88,6 +96,8 @@ public function withDeduplicator(array|callable|null $deduplicator): static /** * Set the desired connection for the chain. + * + * @return $this */ public function allOnConnection(UnitEnum|string|null $connection): static { @@ -98,6 +108,8 @@ public function allOnConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the chain. + * + * @return $this */ public function allOnQueue(UnitEnum|string|null $queue): static { @@ -108,6 +120,8 @@ public function allOnQueue(UnitEnum|string|null $queue): static /** * Set the desired delay in seconds for the job. + * + * @return $this */ public function delay(DateInterval|DateTimeInterface|int|null $delay): static { @@ -118,6 +132,8 @@ public function delay(DateInterval|DateTimeInterface|int|null $delay): static /** * Set the delay for the job to zero seconds. + * + * @return $this */ public function withoutDelay(): static { @@ -128,6 +144,8 @@ public function withoutDelay(): static /** * Indicate that the job should be dispatched after the open parent database transactions have committed. + * + * @return $this */ public function afterCommit(): static { @@ -138,6 +156,8 @@ public function afterCommit(): static /** * Indicate that the job should not wait until database transactions have been committed before dispatching. + * + * @return $this */ public function beforeCommit(): static { @@ -148,6 +168,8 @@ public function beforeCommit(): static /** * Dispatch the job through the transactional outbox. + * + * @return $this */ public function viaOutbox(): static { @@ -158,6 +180,8 @@ public function viaOutbox(): static /** * Dispatch the job without the transactional outbox. + * + * @return $this */ public function withoutOutbox(): static { @@ -168,6 +192,8 @@ public function withoutOutbox(): static /** * Stage the job on the given database connection. + * + * @return $this */ public function onOutboxDatabase(UnitEnum|string $connection): static { @@ -178,6 +204,8 @@ public function onOutboxDatabase(UnitEnum|string $connection): static /** * Set the jobs that should run if this job is successful. + * + * @return $this */ public function chain(array $chain): static { @@ -188,6 +216,8 @@ public function chain(array $chain): static /** * Indicate that the job should be dispatched after the response is sent to the browser. + * + * @return $this */ public function afterResponse(bool $afterResponse = true): static { @@ -265,6 +295,8 @@ protected function acquireDebounceLock(): void /** * Dynamically proxy methods to the underlying job. + * + * @return $this */ public function __call(string $method, array $parameters): static { diff --git a/src/queue/src/CallQueuedClosure.php b/src/queue/src/CallQueuedClosure.php index a8791377a..74ca68821 100644 --- a/src/queue/src/CallQueuedClosure.php +++ b/src/queue/src/CallQueuedClosure.php @@ -63,6 +63,8 @@ public function handle(Container $container): void /** * Add a callback to be executed if the job fails. + * + * @return $this */ public function onFailure(callable $callback): static { @@ -97,6 +99,8 @@ public function displayName(): string /** * Assign a name to the job. + * + * @return $this */ public function name(string $name): static { diff --git a/src/queue/src/InteractsWithQueue.php b/src/queue/src/InteractsWithQueue.php index 050b3c893..21c0fe88c 100644 --- a/src/queue/src/InteractsWithQueue.php +++ b/src/queue/src/InteractsWithQueue.php @@ -68,6 +68,8 @@ public function release(DateInterval|DateTimeInterface|int $delay = 0): void /** * Indicate that queue interactions like fail, delete, and release should be faked. + * + * @return $this */ public function withFakeQueueInteractions(): static { @@ -78,6 +80,8 @@ public function withFakeQueueInteractions(): static /** * Assert that the job was deleted from the queue. + * + * @return $this */ public function assertDeleted(): static { @@ -93,6 +97,8 @@ public function assertDeleted(): static /** * Assert that the job was not deleted from the queue. + * + * @return $this */ public function assertNotDeleted(): static { @@ -108,6 +114,8 @@ public function assertNotDeleted(): static /** * Assert that the job was manually failed. + * + * @return $this */ public function assertFailed(): static { @@ -123,6 +131,8 @@ public function assertFailed(): static /** * Assert that the job was manually failed with a specific exception. + * + * @return $this */ public function assertFailedWith(string|Throwable $exception): static { @@ -165,6 +175,8 @@ public function assertFailedWith(string|Throwable $exception): static /** * Assert that the job was not manually failed. + * + * @return $this */ public function assertNotFailed(): static { @@ -180,6 +192,8 @@ public function assertNotFailed(): static /** * Assert that the job was released back onto the queue. + * + * @return $this */ public function assertReleased(DateInterval|DateTimeInterface|int|null $delay = null): static { @@ -208,6 +222,8 @@ public function assertReleased(DateInterval|DateTimeInterface|int|null $delay = /** * Assert that the job was not released back onto the queue. + * + * @return $this */ public function assertNotReleased(): static { @@ -235,6 +251,8 @@ private function ensureQueueInteractionsHaveBeenFaked(): void /** * Set the base queue job instance. + * + * @return $this */ public function setJob(JobContract $job): static { diff --git a/src/queue/src/Middleware/RateLimited.php b/src/queue/src/Middleware/RateLimited.php index a0c72d474..52eba1afd 100644 --- a/src/queue/src/Middleware/RateLimited.php +++ b/src/queue/src/Middleware/RateLimited.php @@ -105,6 +105,8 @@ protected function handleJob(mixed $job, callable $next, array $limits, Limiter /** * Set the delay (in seconds) to release the job back to the queue. + * + * @return $this */ public function releaseAfter(DateTimeInterface|int $releaseAfter): static { @@ -115,6 +117,8 @@ public function releaseAfter(DateTimeInterface|int $releaseAfter): static /** * Set the extra seconds added to the rate limiter's retry delay. + * + * @return $this */ public function releasePadding(int $seconds): static { @@ -129,6 +133,8 @@ public function releasePadding(int $seconds): static /** * Do not release the job back to the queue if the limit is exceeded. + * + * @return $this */ public function dontRelease(): static { @@ -141,6 +147,8 @@ public function dontRelease(): static // instead of exposing Laravel's Redis-only queue middleware and connection(). /** * Specify the rate limiter store that should be used. + * + * @return $this */ public function store(UnitEnum|string $store): static { diff --git a/src/queue/src/Middleware/ThrottlesExceptions.php b/src/queue/src/Middleware/ThrottlesExceptions.php index fa352698f..7e853f8f3 100644 --- a/src/queue/src/Middleware/ThrottlesExceptions.php +++ b/src/queue/src/Middleware/ThrottlesExceptions.php @@ -146,6 +146,8 @@ public function handle(mixed $job, callable $next): mixed /** * Specify a callback that should determine if rate limiting behavior should apply. + * + * @return $this */ public function when(callable $callback): static { @@ -156,6 +158,8 @@ public function when(callable $callback): static /** * Add a callback that should determine if the job should be deleted. + * + * @return $this */ public function deleteWhen(callable|string $callback): static { @@ -168,6 +172,8 @@ public function deleteWhen(callable|string $callback): static /** * Add a callback that should determine if the job should be failed. + * + * @return $this */ public function failWhen(callable|string $callback): static { @@ -210,6 +216,8 @@ protected function shouldFail(Throwable $throwable): bool /** * Set the prefix of the rate limiter key. + * + * @return $this */ public function withPrefix(string $prefix): static { @@ -222,6 +230,8 @@ public function withPrefix(string $prefix): static // instead of exposing Laravel's Redis-only queue middleware and connection(). /** * Specify the rate limiter store that should be used. + * + * @return $this */ public function store(UnitEnum|string $store): static { @@ -234,6 +244,7 @@ public function store(UnitEnum|string $store): static * Specify the number of minutes a job should be delayed when it is released (before it has reached its max exceptions). * * @param (Closure(Throwable): int)|int $backoff + * @return $this */ public function backoff(Closure|int $backoff): static { @@ -276,6 +287,8 @@ protected function getKey(mixed $job): string /** * Set the value that the rate limiter should be keyed by. + * + * @return $this */ public function by(string $key): static { @@ -286,6 +299,8 @@ public function by(string $key): static /** * Indicate that the throttle key should use the job's UUID. + * + * @return $this */ public function byJob(): static { @@ -296,6 +311,8 @@ public function byJob(): static /** * Report exceptions and optionally specify a callback that determines if the exception should be reported. + * + * @return $this */ public function report(?callable $callback = null): static { diff --git a/src/queue/src/Middleware/WithoutOverlapping.php b/src/queue/src/Middleware/WithoutOverlapping.php index c159ffe3a..78de0e410 100644 --- a/src/queue/src/Middleware/WithoutOverlapping.php +++ b/src/queue/src/Middleware/WithoutOverlapping.php @@ -85,6 +85,8 @@ public function handle(mixed $job, callable $next): mixed /** * Set the delay (in seconds) to release the job back to the queue. + * + * @return $this */ public function releaseAfter(DateTimeInterface|int $releaseAfter): static { @@ -95,6 +97,8 @@ public function releaseAfter(DateTimeInterface|int $releaseAfter): static /** * Do not release the job back to the queue if no lock can be acquired. + * + * @return $this */ public function dontRelease(): static { @@ -105,6 +109,8 @@ public function dontRelease(): static /** * Set the maximum number of seconds that can elapse before the lock is released. + * + * @return $this */ public function expireAfter(DateInterval|DateTimeInterface|int $expiresAfter): static { @@ -115,6 +121,8 @@ public function expireAfter(DateInterval|DateTimeInterface|int $expiresAfter): s /** * Set the prefix of the lock key. + * + * @return $this */ public function withPrefix(string $prefix): static { @@ -125,6 +133,8 @@ public function withPrefix(string $prefix): static /** * Indicate that the lock key may be shared across jobs belonging to different classes. + * + * @return $this */ public function shared(): static { @@ -135,6 +145,8 @@ public function shared(): static /** * Specify the cache store that should hold the lock. + * + * @return $this */ public function store(UnitEnum|string $store): static { diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 44a1db830..0c6ebfba1 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -425,6 +425,7 @@ protected function withCreatePayloadHooks(?string $queue, array $payload): array * Set the dispatcher for after-commit queue operations. * * @param null|Closure(Closure(Queue): mixed): mixed $dispatcher + * @return $this */ public function setAfterCommitDispatcher(?Closure $dispatcher): static { @@ -926,6 +927,8 @@ public function getConnectionName(): string * Boot or tests only. Queue connection instances are cached on * QueueManager; runtime use races across coroutines and changes every * concurrent dispatch through this connection. + * + * @return $this */ public function setConnectionName(string $name): static { @@ -948,6 +951,8 @@ public function getConfig(): array * Boot or tests only. Queue connection instances are cached on * QueueManager; runtime use races across coroutines and changes every * concurrent dispatch through this connection. + * + * @return $this */ public function setConfig(array $config): static { diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index a46d82190..13266d457 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -641,6 +641,8 @@ public function getApplication(): Container * Tests only. Swaps the container reference on the singleton and on every * cached queue connection; per-request use races across coroutines and * breaks every concurrent dispatch. + * + * @return $this */ public function setApplication(Container $app): static { diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index 088bc0e82..49fb1018f 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -1987,6 +1987,8 @@ public function sleep(float|int $seconds): void * * Boot-only. The repository persists on the singleton worker for the * worker lifetime and is read by every subsequent pause or restart check. + * + * @return $this */ public function setCache(CacheContract $cache): static { @@ -2000,6 +2002,8 @@ public function setCache(CacheContract $cache): static * * Boot-only. The name persists on the singleton worker for the worker * lifetime and selects the static pop callback used by every job pop. + * + * @return $this */ public function setName(string $name): static { From 1a77ad91d38e1bf59aeb7d46c3479c67d31d6016 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 133/303] Restore fluent cache and rate limiter contracts Restore receiver identity for cache configuration and Redis limiter builders, including the relocated RateLimiter registration method. Immutable admission policies keep their existing return contracts. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/cache/src/CacheManager.php | 5 +++++ src/cache/src/DatabaseStore.php | 4 ++++ src/cache/src/Events/CacheEvent.php | 2 ++ src/cache/src/Events/CacheFlushFailed.php | 2 ++ src/cache/src/Events/CacheFlushed.php | 2 ++ src/cache/src/Events/CacheFlushing.php | 2 ++ src/cache/src/FileStore.php | 4 ++++ src/cache/src/Limiters/ConcurrencyLimiterBuilder.php | 8 ++++++++ src/cache/src/Lock.php | 2 ++ src/cache/src/RedisStore.php | 4 ++++ src/cache/src/Repository.php | 1 + src/rate-limiter/src/RateLimiter.php | 2 ++ src/redis/src/Limiters/ConcurrencyLimiterBuilder.php | 8 ++++++++ src/redis/src/Limiters/DurationLimiterBuilder.php | 8 ++++++++ 14 files changed, 54 insertions(+) diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php index 8c2e8aa53..b88cf6d6b 100644 --- a/src/cache/src/CacheManager.php +++ b/src/cache/src/CacheManager.php @@ -515,6 +515,8 @@ public function setDefaultDriver(UnitEnum|string $name): void * Boot or tests only. Mutates the singleton's store cache; concurrent * coroutines may already hold a reference to the store and next resolution * will rebuild with fresh connections. + * + * @return $this */ public function forgetDriver(array|UnitEnum|string|null $name = null): static { @@ -584,6 +586,8 @@ public function extend(string $driver, Closure $callback): static * Tests only. Swaps the singleton's container reference; per-request use * races across coroutines and breaks every concurrent request resolving * stores through this manager. + * + * @return $this */ public function setApplication(Container $app): static { @@ -602,6 +606,7 @@ public function setApplication(Container $app): static * contributions without memoizing them. * * @param Closure(): array $resolver + * @return $this * * @throws LogicException */ diff --git a/src/cache/src/DatabaseStore.php b/src/cache/src/DatabaseStore.php index 5cb4a8aa1..154bf7011 100644 --- a/src/cache/src/DatabaseStore.php +++ b/src/cache/src/DatabaseStore.php @@ -447,6 +447,8 @@ public function getConnection(): ConnectionInterface * * Boot-only. Persists on the cached store for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function setConnection(string $connectionName): static { @@ -468,6 +470,8 @@ public function getLockConnection(): ConnectionInterface * * Boot-only. Persists on the cached store for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function setLockConnection(string $connectionName): static { diff --git a/src/cache/src/Events/CacheEvent.php b/src/cache/src/Events/CacheEvent.php index 2f5df69de..c29c20090 100644 --- a/src/cache/src/Events/CacheEvent.php +++ b/src/cache/src/Events/CacheEvent.php @@ -37,6 +37,8 @@ public function __construct(?string $storeName, UnitEnum|string $key, array $tag /** * Set the tags for the cache event. + * + * @return $this */ public function setTags(array $tags): static { diff --git a/src/cache/src/Events/CacheFlushFailed.php b/src/cache/src/Events/CacheFlushFailed.php index b3613a8df..e3321d2f4 100644 --- a/src/cache/src/Events/CacheFlushFailed.php +++ b/src/cache/src/Events/CacheFlushFailed.php @@ -22,6 +22,8 @@ public function __construct( /** * Set the tags for the cache event. + * + * @return $this */ public function setTags(array $tags): static { diff --git a/src/cache/src/Events/CacheFlushed.php b/src/cache/src/Events/CacheFlushed.php index 7c85541a5..6f386acc4 100644 --- a/src/cache/src/Events/CacheFlushed.php +++ b/src/cache/src/Events/CacheFlushed.php @@ -17,6 +17,8 @@ public function __construct( /** * Set the tags for the cache event. + * + * @return $this */ public function setTags(array $tags): static { diff --git a/src/cache/src/Events/CacheFlushing.php b/src/cache/src/Events/CacheFlushing.php index 17ad94290..3b07baf01 100644 --- a/src/cache/src/Events/CacheFlushing.php +++ b/src/cache/src/Events/CacheFlushing.php @@ -17,6 +17,8 @@ public function __construct( /** * Set the tags for the cache event. + * + * @return $this */ public function setTags(array $tags): static { diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index 662f57e02..fa7e5429b 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -426,6 +426,8 @@ public function getDirectory(): string * * Boot-only. Persists on the cached store for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function setDirectory(string $directory): static { @@ -439,6 +441,8 @@ public function setDirectory(string $directory): static * * Boot-only. Persists on the cached store for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function setLockDirectory(?string $lockDirectory): static { diff --git a/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php b/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php index da8de9a7f..c63fc4b5b 100644 --- a/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php +++ b/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php @@ -54,6 +54,8 @@ public function __construct( /** * Set the maximum number of locks that can be obtained per time window. + * + * @return $this */ public function limit(int $maxLocks): static { @@ -64,6 +66,8 @@ public function limit(int $maxLocks): static /** * Set the number of seconds until the lock will be released. + * + * @return $this */ public function releaseAfter(DateInterval|DateTimeInterface|int $releaseAfter): static { @@ -74,6 +78,8 @@ public function releaseAfter(DateInterval|DateTimeInterface|int $releaseAfter): /** * Set the number of seconds to block until a lock is available. + * + * @return $this */ public function block(int $timeout): static { @@ -84,6 +90,8 @@ public function block(int $timeout): static /** * The number of milliseconds to wait between lock acquisition attempts. + * + * @return $this */ public function sleep(int $sleep): static { diff --git a/src/cache/src/Lock.php b/src/cache/src/Lock.php index b626ef017..38c149a77 100644 --- a/src/cache/src/Lock.php +++ b/src/cache/src/Lock.php @@ -192,6 +192,8 @@ public function isOwnedBy(?string $owner): bool /** * Specify the number of milliseconds to sleep in between blocked lock acquisition attempts. + * + * @return $this */ public function betweenBlockedAttemptsSleepFor(int $milliseconds): static { diff --git a/src/cache/src/RedisStore.php b/src/cache/src/RedisStore.php index 7e7d6ed44..297c0485e 100644 --- a/src/cache/src/RedisStore.php +++ b/src/cache/src/RedisStore.php @@ -331,6 +331,8 @@ public function flushStaleTags(): ?array * * Boot-only. Mutates state on a per-worker singleton; runtime mutation * races across coroutines. + * + * @return $this */ public function setTagMode(TagMode|string $mode): static { @@ -404,6 +406,8 @@ public function setConnection(?string $connection): void * * Boot-only. Persists on the cached store for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function setLockConnection(string $connection): static { diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 7bad2ff81..e150eb2ac 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -1089,6 +1089,7 @@ public function getName(): ?string * and queued unique-lock cleanup in every coroutine using that repository. * * @internal + * @return $this */ public function setName(string $name): static { diff --git a/src/rate-limiter/src/RateLimiter.php b/src/rate-limiter/src/RateLimiter.php index 9e0ee4304..c4abc7e4c 100644 --- a/src/rate-limiter/src/RateLimiter.php +++ b/src/rate-limiter/src/RateLimiter.php @@ -65,6 +65,8 @@ public function store(UnitEnum|string|null $name = null): Limiter * * Boot-only. The callback and store selection persist on the singleton * manager for the worker lifetime and affect every subsequent request. + * + * @return $this */ public function for( UnitEnum|string $name, diff --git a/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php b/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php index 5ed9d89b3..67f2ab83f 100644 --- a/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php +++ b/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php @@ -50,6 +50,8 @@ public function __construct( /** * Set the maximum number of locks that can be obtained per time window. + * + * @return $this */ public function limit(int $maxLocks): static { @@ -60,6 +62,8 @@ public function limit(int $maxLocks): static /** * Set the number of seconds until the lock will be released. + * + * @return $this */ public function releaseAfter(DateTimeInterface|DateInterval|int $releaseAfter): static { @@ -70,6 +74,8 @@ public function releaseAfter(DateTimeInterface|DateInterval|int $releaseAfter): /** * Set the amount of time to block until a lock is available. + * + * @return $this */ public function block(int $timeout): static { @@ -80,6 +86,8 @@ public function block(int $timeout): static /** * Set the number of milliseconds to wait between lock acquisition attempts. + * + * @return $this */ public function sleep(int $sleep): static { diff --git a/src/redis/src/Limiters/DurationLimiterBuilder.php b/src/redis/src/Limiters/DurationLimiterBuilder.php index ff3e0872a..86da35917 100644 --- a/src/redis/src/Limiters/DurationLimiterBuilder.php +++ b/src/redis/src/Limiters/DurationLimiterBuilder.php @@ -48,6 +48,8 @@ public function __construct( /** * Set the maximum number of locks that can be obtained per time window. + * + * @return $this */ public function allow(int $maxLocks): static { @@ -58,6 +60,8 @@ public function allow(int $maxLocks): static /** * Set the amount of time the lock window is maintained. + * + * @return $this */ public function every(DateTimeInterface|DateInterval|int $decay): static { @@ -68,6 +72,8 @@ public function every(DateTimeInterface|DateInterval|int $decay): static /** * Set the amount of time to block until a lock is available. + * + * @return $this */ public function block(int $timeout): static { @@ -78,6 +84,8 @@ public function block(int $timeout): static /** * Set the number of milliseconds to wait between lock acquisition attempts. + * + * @return $this */ public function sleep(int $sleep): static { From 6a7b36230769037e7a49ad948caf017c543e6cdb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 134/303] Restore fluent collection and support contracts Restore identity annotations on collection iteration and tap contracts, support utilities and framework fakes. Preserve conditional callback returns, proxy state transitions, immutable string operations and arbitrary macro results. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/collections/src/Enumerable.php | 6 ++++ .../src/Traits/EnumeratesValues.php | 6 ++++ .../src/HigherOrderWhenProxy.php | 2 ++ src/support/src/Composer.php | 2 ++ src/support/src/Defer/DeferredCallback.php | 4 +++ .../src/Defer/DeferredCallbackCollection.php | 2 ++ src/support/src/Fluent.php | 3 ++ src/support/src/Lottery.php | 4 +++ src/support/src/Sleep.php | 28 +++++++++++++++++++ src/support/src/Stringable.php | 2 ++ src/support/src/Testing/Fakes/BusFake.php | 12 ++++++++ src/support/src/Testing/Fakes/EventFake.php | 2 ++ .../Testing/Fakes/ExceptionHandlerFake.php | 6 ++++ src/support/src/Testing/Fakes/MailFake.php | 2 ++ .../src/Testing/Fakes/NotificationFake.php | 4 +++ src/support/src/Testing/Fakes/QueueFake.php | 10 +++++++ src/support/src/Timebox.php | 4 +++ src/support/src/Traits/Dumpable.php | 2 ++ src/support/src/Uri.php | 2 ++ src/support/src/ValidatedInput.php | 2 ++ src/support/src/ViewErrorBag.php | 2 ++ 21 files changed, 107 insertions(+) diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php index 405fecfca..a00d9563d 100644 --- a/src/collections/src/Enumerable.php +++ b/src/collections/src/Enumerable.php @@ -168,6 +168,8 @@ public function dd(mixed ...$args): never; /** * Dump the collection. + * + * @return $this */ public function dump(mixed ...$args): static; @@ -240,6 +242,7 @@ public function duplicatesStrict(callable|string|null $callback = null): Collect * Execute a callback over each item. * * @param callable(TValue, TKey): mixed $callback + * @return $this */ public function each(callable $callback): static; @@ -1001,6 +1004,7 @@ public function takeWhile(mixed $value): static; * Pass the collection to the given callback and then return it. * * @param callable($this): mixed $callback + * @return $this */ public function tap(callable $callback): static; @@ -1169,6 +1173,8 @@ public function __toString(): string; /** * Indicate that the model's string representation should be escaped when __toString is invoked. + * + * @return $this */ public function escapeWhenCastingToString(bool $escape = true): static; diff --git a/src/collections/src/Traits/EnumeratesValues.php b/src/collections/src/Traits/EnumeratesValues.php index 498e47d8d..3ac00baed 100644 --- a/src/collections/src/Traits/EnumeratesValues.php +++ b/src/collections/src/Traits/EnumeratesValues.php @@ -248,6 +248,8 @@ public function dd(mixed ...$args): never /** * Dump the items. + * + * @return $this */ public function dump(mixed ...$args): static { @@ -260,6 +262,7 @@ public function dump(mixed ...$args): static * Execute a callback over each item. * * @param callable(TValue, TKey): mixed $callback + * @return $this */ public function each(callable $callback): static { @@ -882,6 +885,7 @@ public function chunkBy(callable|string $key): static * Pass the collection to the given callback and then return it. * * @param callable($this): mixed $callback + * @return $this */ public function tap(callable $callback): static { @@ -999,6 +1003,8 @@ public function __toString(): string /** * Indicate that the model's string representation should be escaped when __toString is invoked. + * + * @return $this */ public function escapeWhenCastingToString(bool $escape = true): static { diff --git a/src/conditionable/src/HigherOrderWhenProxy.php b/src/conditionable/src/HigherOrderWhenProxy.php index a4616c0e9..8b449066a 100644 --- a/src/conditionable/src/HigherOrderWhenProxy.php +++ b/src/conditionable/src/HigherOrderWhenProxy.php @@ -52,6 +52,8 @@ public function condition(mixed $condition): static /** * Indicate that the condition should be negated. + * + * @return $this */ public function negateConditionOnCapture(): static { diff --git a/src/support/src/Composer.php b/src/support/src/Composer.php index b19255535..e7e3f3521 100644 --- a/src/support/src/Composer.php +++ b/src/support/src/Composer.php @@ -233,6 +233,8 @@ protected function getProcess(array $command, array $env = []): Process /** * Set the working path used by the class. + * + * @return $this */ public function setWorkingPath(string $path): static { diff --git a/src/support/src/Defer/DeferredCallback.php b/src/support/src/Defer/DeferredCallback.php index 202839972..ae4efd6c1 100644 --- a/src/support/src/Defer/DeferredCallback.php +++ b/src/support/src/Defer/DeferredCallback.php @@ -28,6 +28,8 @@ public function __construct( /** * Specify the callback name. + * + * @return $this */ public function name(string $name): static { @@ -38,6 +40,8 @@ public function name(string $name): static /** * Indicate that the callback should run on unsuccessful requests and jobs. + * + * @return $this */ public function always(bool $always = true): static { diff --git a/src/support/src/Defer/DeferredCallbackCollection.php b/src/support/src/Defer/DeferredCallbackCollection.php index 6975daa50..e4808d7a9 100644 --- a/src/support/src/Defer/DeferredCallbackCollection.php +++ b/src/support/src/Defer/DeferredCallbackCollection.php @@ -89,6 +89,8 @@ public function forget(string $name): void /** * Remove any duplicate callbacks. + * + * @return $this */ protected function forgetDuplicates(): static { diff --git a/src/support/src/Fluent.php b/src/support/src/Fluent.php index 22ee3ce70..8564f24db 100644 --- a/src/support/src/Fluent.php +++ b/src/support/src/Fluent.php @@ -73,6 +73,8 @@ public function get(string|int|null $key = null, mixed $default = null): mixed /** * Set an attribute on the fluent instance using "dot" notation. + * + * @return $this */ public function set(string|int $key, mixed $value): static { @@ -85,6 +87,7 @@ public function set(string|int $key, mixed $value): static * Fill the fluent instance with an array of attributes. * * @param array|object $attributes + * @return $this */ public function fill(array|object $attributes): static { diff --git a/src/support/src/Lottery.php b/src/support/src/Lottery.php index 58a0a1dcb..a653d59ed 100644 --- a/src/support/src/Lottery.php +++ b/src/support/src/Lottery.php @@ -71,6 +71,8 @@ public static function odds(int|float $chances, ?int $outOf = null): static /** * Set the winner callback. + * + * @return $this */ public function winner(callable $callback): static { @@ -81,6 +83,8 @@ public function winner(callable $callback): static /** * Set the loser callback. + * + * @return $this */ public function loser(callable $callback): static { diff --git a/src/support/src/Sleep.php b/src/support/src/Sleep.php index 37804a0c3..42849eb28 100644 --- a/src/support/src/Sleep.php +++ b/src/support/src/Sleep.php @@ -110,6 +110,8 @@ public static function sleep(float|int $duration): static /** * Sleep for the given duration. Replaces any previously defined duration. + * + * @return $this */ protected function duration(DateInterval|float|int $duration): static { @@ -133,6 +135,8 @@ protected function duration(DateInterval|float|int $duration): static /** * Sleep for the given number of minutes. + * + * @return $this */ public function minutes(): static { @@ -144,6 +148,8 @@ public function minutes(): static /** * Sleep for one minute. + * + * @return $this */ public function minute(): static { @@ -152,6 +158,8 @@ public function minute(): static /** * Sleep for the given number of seconds. + * + * @return $this */ public function seconds(): static { @@ -162,6 +170,8 @@ public function seconds(): static /** * Sleep for one second. + * + * @return $this */ public function second(): static { @@ -170,6 +180,8 @@ public function second(): static /** * Sleep for the given number of milliseconds. + * + * @return $this */ public function milliseconds(): static { @@ -180,6 +192,8 @@ public function milliseconds(): static /** * Sleep for one millisecond. + * + * @return $this */ public function millisecond(): static { @@ -188,6 +202,8 @@ public function millisecond(): static /** * Sleep for the given number of microseconds. + * + * @return $this */ public function microseconds(): static { @@ -198,6 +214,8 @@ public function microseconds(): static /** * Sleep for one microsecond. + * + * @return $this */ public function microsecond(): static { @@ -206,6 +224,8 @@ public function microsecond(): static /** * Add additional time to sleep for. + * + * @return $this */ public function and(float|int $duration): static { @@ -216,6 +236,8 @@ public function and(float|int $duration): static /** * Sleep while a given callback returns "true". + * + * @return $this */ public function while(Closure $callback): static { @@ -426,6 +448,8 @@ public static function assertInsomniac(): void /** * Indicate that the instance should not sleep. + * + * @return $this */ protected function shouldNotSleep(): static { @@ -436,6 +460,8 @@ protected function shouldNotSleep(): static /** * Only sleep when the given condition is true. + * + * @return $this */ public function when(bool|Closure $condition): static { @@ -446,6 +472,8 @@ public function when(bool|Closure $condition): static /** * Don't sleep when the given condition is true. + * + * @return $this */ public function unless(bool|Closure $condition): static { diff --git a/src/support/src/Stringable.php b/src/support/src/Stringable.php index 988943421..531e6b3fd 100644 --- a/src/support/src/Stringable.php +++ b/src/support/src/Stringable.php @@ -1117,6 +1117,8 @@ public function decrypt(bool $serialize = false): static /** * Dump the string. + * + * @return $this */ public function dump(mixed ...$args): static { diff --git a/src/support/src/Testing/Fakes/BusFake.php b/src/support/src/Testing/Fakes/BusFake.php index b771f001f..4b0af6a83 100644 --- a/src/support/src/Testing/Fakes/BusFake.php +++ b/src/support/src/Testing/Fakes/BusFake.php @@ -80,6 +80,8 @@ public function __construct( /** * Specify the jobs that should be dispatched instead of faked. + * + * @return $this */ public function except(array|string $jobsToDispatch): static { @@ -749,6 +751,8 @@ protected function shouldDispatchCommand(mixed $command): bool /** * Specify if commands should be serialized and restored when dispatched or batched. + * + * @return $this */ public function serializeAndRestore(bool $serializeAndRestore = true): static { @@ -775,6 +779,8 @@ protected function getCommandRepresentation(mixed $command): mixed /** * Set the pipes commands should be piped through before dispatching. + * + * @return $this */ public function pipeThrough(array $pipes): static { @@ -801,6 +807,8 @@ public function getCommandHandler(mixed $command): mixed /** * Map a command to a handler. + * + * @return $this */ public function map(array $map): static { @@ -813,6 +821,8 @@ public function map(array $map): static * Allow dispatching after responses. * * Tests only. Changes the underlying dispatcher for all coroutines until the setting is restored. + * + * @return $this */ public function withDispatchingAfterResponses(): static { @@ -825,6 +835,8 @@ public function withDispatchingAfterResponses(): static * Disable dispatching after responses. * * Tests only. Changes the underlying dispatcher for all coroutines until the setting is restored. + * + * @return $this */ public function withoutDispatchingAfterResponses(): static { diff --git a/src/support/src/Testing/Fakes/EventFake.php b/src/support/src/Testing/Fakes/EventFake.php index 6f71457a1..c5c739fcb 100644 --- a/src/support/src/Testing/Fakes/EventFake.php +++ b/src/support/src/Testing/Fakes/EventFake.php @@ -55,6 +55,8 @@ public function __construct(DispatcherContract $dispatcher, array|string $events /** * Specify the events that should be dispatched instead of faked. + * + * @return $this */ public function except(array|string $eventsToDispatch): static { diff --git a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php index 118e55397..3d91adbd6 100644 --- a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php +++ b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php @@ -215,6 +215,8 @@ public function afterResponse(callable $callback): void /** * Throw exceptions when they are reported. + * + * @return $this */ public function throwOnReport(): static { @@ -226,6 +228,8 @@ public function throwOnReport(): static /** * Throw the first reported exception. * + * @return $this + * * @throws Throwable */ public function throwFirstReported(): static @@ -249,6 +253,8 @@ public function reported(): array /** * Set the "original" handler that should be used by the fake. + * + * @return $this */ public function setHandler(ExceptionHandler $handler): static { diff --git a/src/support/src/Testing/Fakes/MailFake.php b/src/support/src/Testing/Fakes/MailFake.php index 62cdd984b..628634a66 100644 --- a/src/support/src/Testing/Fakes/MailFake.php +++ b/src/support/src/Testing/Fakes/MailFake.php @@ -591,6 +591,8 @@ protected function pullCurrentMailer(): string /** * Forget all of the resolved mailer instances. + * + * @return $this */ public function forgetMailers(): static { diff --git a/src/support/src/Testing/Fakes/NotificationFake.php b/src/support/src/Testing/Fakes/NotificationFake.php index 0795a3aa4..2b27daa91 100644 --- a/src/support/src/Testing/Fakes/NotificationFake.php +++ b/src/support/src/Testing/Fakes/NotificationFake.php @@ -358,6 +358,8 @@ public function channel(UnitEnum|string|null $name = null): mixed /** * Set the locale of notifications. + * + * @return $this */ public function locale(string $locale): static { @@ -368,6 +370,8 @@ public function locale(string $locale): static /** * Specify if notification should be serialized and restored when being "pushed" to the queue. + * + * @return $this */ public function serializeAndRestore(bool $serializeAndRestore = true): static { diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php index fe5e6bea4..1efef3885 100644 --- a/src/support/src/Testing/Fakes/QueueFake.php +++ b/src/support/src/Testing/Fakes/QueueFake.php @@ -110,6 +110,8 @@ public function __construct(Container $app, array|string $jobsToFake = [], ?Fact /** * Specify the jobs that should be queued instead of faked. + * + * @return $this */ public function except(array|string $jobsToBeQueued): static { @@ -792,6 +794,8 @@ public function rawPushes(): array /** * Specify if jobs should be serialized and restored when being "pushed" to the queue. + * + * @return $this */ public function serializeAndRestore(bool $serializeAndRestore = true): static { @@ -830,6 +834,8 @@ public function clearReserved(): void /** * Register a callback to be invoked before pushing a job. + * + * @return $this */ public function beforePushing(callable $callback): static { @@ -840,6 +846,8 @@ public function beforePushing(callable $callback): static /** * Register a callback to be invoked after pushing a job. + * + * @return $this */ public function afterPushing(callable $callback): static { @@ -866,6 +874,8 @@ public function getConnectionName(): string /** * Set the connection name for the queue. + * + * @return $this */ public function setConnectionName(string $name): static { diff --git a/src/support/src/Timebox.php b/src/support/src/Timebox.php index c3e0b001a..9a0b89960 100644 --- a/src/support/src/Timebox.php +++ b/src/support/src/Timebox.php @@ -55,6 +55,8 @@ public function call(callable $callback, int $microseconds): mixed /** * Indicate that the timebox can return early. + * + * @return $this */ public function returnEarly(): static { @@ -65,6 +67,8 @@ public function returnEarly(): static /** * Indicate that the timebox cannot return early. + * + * @return $this */ public function dontReturnEarly(): static { diff --git a/src/support/src/Traits/Dumpable.php b/src/support/src/Traits/Dumpable.php index a77ceab17..443099451 100644 --- a/src/support/src/Traits/Dumpable.php +++ b/src/support/src/Traits/Dumpable.php @@ -16,6 +16,8 @@ public function dd(mixed ...$args): never /** * Dump the given arguments. + * + * @return $this */ public function dump(mixed ...$args): static { diff --git a/src/support/src/Uri.php b/src/support/src/Uri.php index 7a155f477..1704e4c40 100644 --- a/src/support/src/Uri.php +++ b/src/support/src/Uri.php @@ -412,6 +412,8 @@ public function isNotEmpty(): bool /** * Dump the string representation of the URI. + * + * @return $this */ public function dump(mixed ...$args): static { diff --git a/src/support/src/ValidatedInput.php b/src/support/src/ValidatedInput.php index 3d7f7d068..235ae224f 100644 --- a/src/support/src/ValidatedInput.php +++ b/src/support/src/ValidatedInput.php @@ -92,6 +92,8 @@ public function file(string $key, mixed $default = null): mixed /** * Dump the items. + * + * @return $this */ public function dump(mixed ...$keys): static { diff --git a/src/support/src/ViewErrorBag.php b/src/support/src/ViewErrorBag.php index 8e270125c..b371d2137 100644 --- a/src/support/src/ViewErrorBag.php +++ b/src/support/src/ViewErrorBag.php @@ -49,6 +49,8 @@ public function getBags(): array /** * Add a new MessageBag instance to the bags. + * + * @return $this */ public function put(string $key, MessageBagContract $bag): static { From 4e798ff53958369658452f597fc70762a11ad50c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 135/303] Restore fluent console and scheduling contracts Restore receiver identity through schedule frequency chains, callback registration, mutex configuration and console helpers. Preserve the throwing background-execution boundary and existing callback behavior. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/console/src/CacheCommandMutex.php | 2 + src/console/src/Concerns/InteractsWithIO.php | 2 + src/console/src/GeneratorCommand.php | 2 + src/console/src/Scheduling/CacheAware.php | 2 + .../src/Scheduling/CacheEventMutex.php | 2 + .../src/Scheduling/CacheSchedulingMutex.php | 2 + src/console/src/Scheduling/CallbackEvent.php | 4 + src/console/src/Scheduling/Event.php | 48 +++++++++ .../src/Scheduling/ManagesAttributes.php | 22 ++++ .../src/Scheduling/ManagesFrequencies.php | 102 ++++++++++++++++++ .../src/Scheduling/PendingEventAttributes.php | 2 + src/console/src/Scheduling/Schedule.php | 2 + 12 files changed, 192 insertions(+) diff --git a/src/console/src/CacheCommandMutex.php b/src/console/src/CacheCommandMutex.php index d13d2809d..796a6d995 100644 --- a/src/console/src/CacheCommandMutex.php +++ b/src/console/src/CacheCommandMutex.php @@ -107,6 +107,8 @@ protected function shouldUseLocks(Store $store): bool * * Boot-only. Mutates the shared command mutex instance for the worker * lifetime; per-request use races across coroutines. + * + * @return $this */ public function useStore(?string $store): static { diff --git a/src/console/src/Concerns/InteractsWithIO.php b/src/console/src/Concerns/InteractsWithIO.php index 55fc3ce60..36c9f7a12 100644 --- a/src/console/src/Concerns/InteractsWithIO.php +++ b/src/console/src/Concerns/InteractsWithIO.php @@ -328,6 +328,8 @@ public function alert(string $string, int|string|null $verbosity = null): void /** * Write a blank line. + * + * @return $this */ public function newLine(int $count = 1): static { diff --git a/src/console/src/GeneratorCommand.php b/src/console/src/GeneratorCommand.php index e50b55e74..8b60b5829 100644 --- a/src/console/src/GeneratorCommand.php +++ b/src/console/src/GeneratorCommand.php @@ -376,6 +376,8 @@ protected function buildClass(string $name): string /** * Replace the namespace for the given stub. + * + * @return $this */ protected function replaceNamespace(string &$stub, string $name): static { diff --git a/src/console/src/Scheduling/CacheAware.php b/src/console/src/Scheduling/CacheAware.php index 742bd7890..986c21f81 100644 --- a/src/console/src/Scheduling/CacheAware.php +++ b/src/console/src/Scheduling/CacheAware.php @@ -11,6 +11,8 @@ interface CacheAware * * Boot-only. Mutates the shared mutex instance for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function useStore(string $store): static; } diff --git a/src/console/src/Scheduling/CacheEventMutex.php b/src/console/src/Scheduling/CacheEventMutex.php index e3fb8bc54..a1f9a4e1a 100644 --- a/src/console/src/Scheduling/CacheEventMutex.php +++ b/src/console/src/Scheduling/CacheEventMutex.php @@ -97,6 +97,8 @@ protected function shouldUseLocks(Store $store): bool * * Boot-only. Mutates the shared mutex instance for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function useStore(?string $store): static { diff --git a/src/console/src/Scheduling/CacheSchedulingMutex.php b/src/console/src/Scheduling/CacheSchedulingMutex.php index d9c1529a2..68e9abf5c 100644 --- a/src/console/src/Scheduling/CacheSchedulingMutex.php +++ b/src/console/src/Scheduling/CacheSchedulingMutex.php @@ -83,6 +83,8 @@ protected function shouldUseLocks(Store $store): bool * * Boot-only. Mutates the shared mutex instance for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function useStore(?string $store): static { diff --git a/src/console/src/Scheduling/CallbackEvent.php b/src/console/src/Scheduling/CallbackEvent.php index 0a398cef2..2ed6a5e13 100644 --- a/src/console/src/Scheduling/CallbackEvent.php +++ b/src/console/src/Scheduling/CallbackEvent.php @@ -151,6 +151,8 @@ protected function execute(Container $container): int * * The expiration time of the underlying cache lock may be specified in minutes. * + * @return $this + * * @throws LogicException */ public function withoutOverlapping(int $expiresAt = 1440, bool $releaseOnTerminationSignals = true): static @@ -167,6 +169,8 @@ public function withoutOverlapping(int $expiresAt = 1440, bool $releaseOnTermina /** * Allow the event to only run on one server for each cron expression. * + * @return $this + * * @throws LogicException */ public function onOneServer(): static diff --git a/src/console/src/Scheduling/Event.php b/src/console/src/Scheduling/Event.php index dbf9545da..24887d62e 100644 --- a/src/console/src/Scheduling/Event.php +++ b/src/console/src/Scheduling/Event.php @@ -443,6 +443,8 @@ public function filtersPass(ApplicationContract $app): bool /** * Ensure that the output is stored on disk in a log file. + * + * @return $this */ public function storeOutput(): static { @@ -453,6 +455,8 @@ public function storeOutput(): static /** * Send the output of the command to a given location. + * + * @return $this */ public function sendOutputTo(string $location, bool $append = false): static { @@ -500,6 +504,8 @@ public function getOutput(Container $container): ?string /** * Append the output of the command to a given location. + * + * @return $this */ public function appendOutputTo(string $location): static { @@ -509,6 +515,8 @@ public function appendOutputTo(string $location): static /** * E-mail the results of the scheduled operation. * + * @return $this + * * @throws LogicException */ public function emailOutputTo(mixed $addresses, bool $onlyIfOutputExists = true): static @@ -525,6 +533,8 @@ public function emailOutputTo(mixed $addresses, bool $onlyIfOutputExists = true) /** * E-mail the results of the scheduled operation if it produces output. * + * @return $this + * * @throws LogicException */ public function emailWrittenOutputTo(mixed $addresses): static @@ -534,6 +544,8 @@ public function emailWrittenOutputTo(mixed $addresses): static /** * E-mail the results of the scheduled operation if it fails. + * + * @return $this */ public function emailOutputOnFailure(mixed $addresses): static { @@ -591,6 +603,8 @@ protected function getEmailSubject(): string /** * Register a callback to ping a given URL before the job runs. + * + * @return $this */ public function pingBefore(string $url): static { @@ -599,6 +613,8 @@ public function pingBefore(string $url): static /** * Register a callback to ping a given URL before the job runs if the given condition is true. + * + * @return $this */ public function pingBeforeIf(bool $value, string $url): static { @@ -607,6 +623,8 @@ public function pingBeforeIf(bool $value, string $url): static /** * Register a callback to ping a given URL after the job runs. + * + * @return $this */ public function thenPing(string $url): static { @@ -615,6 +633,8 @@ public function thenPing(string $url): static /** * Register a callback to ping a given URL after the job runs if the given condition is true. + * + * @return $this */ public function thenPingIf(bool $value, string $url): static { @@ -623,6 +643,8 @@ public function thenPingIf(bool $value, string $url): static /** * Register a callback to ping a given URL if the operation succeeds. + * + * @return $this */ public function pingOnSuccess(string $url): static { @@ -631,6 +653,8 @@ public function pingOnSuccess(string $url): static /** * Register a callback to ping a given URL if the operation succeeds and if the given condition is true. + * + * @return $this */ public function pingOnSuccessIf(bool $value, string $url): static { @@ -639,6 +663,8 @@ public function pingOnSuccessIf(bool $value, string $url): static /** * Register a callback to ping a given URL if the operation fails. + * + * @return $this */ public function pingOnFailure(string $url): static { @@ -647,6 +673,8 @@ public function pingOnFailure(string $url): static /** * Register a callback to ping a given URL if the operation fails and if the given condition is true. + * + * @return $this */ public function pingOnFailureIf(bool $value, string $url): static { @@ -685,6 +713,8 @@ protected function getHttpClient(Container $container): HttpClientInterface /** * Register a callback to be called before the operation. + * + * @return $this */ public function before(Closure $callback): static { @@ -695,6 +725,8 @@ public function before(Closure $callback): static /** * Register a callback to be called after the operation. + * + * @return $this */ public function after(Closure $callback): static { @@ -703,6 +735,8 @@ public function after(Closure $callback): static /** * Register a callback to be called after the operation. + * + * @return $this */ public function then(Closure $callback): static { @@ -719,6 +753,8 @@ public function then(Closure $callback): static /** * Register a callback that uses the output after the job runs. + * + * @return $this */ public function thenWithOutput(Closure $callback, bool $onlyIfOutputExists = false): static { @@ -729,6 +765,8 @@ public function thenWithOutput(Closure $callback, bool $onlyIfOutputExists = fal /** * Register a callback to be called if the operation succeeds. + * + * @return $this */ public function onSuccess(Closure $callback): static { @@ -747,6 +785,8 @@ public function onSuccess(Closure $callback): static /** * Register a callback that uses the output if the operation succeeds. + * + * @return $this */ public function onSuccessWithOutput(Closure $callback, bool $onlyIfOutputExists = false): static { @@ -757,6 +797,8 @@ public function onSuccessWithOutput(Closure $callback, bool $onlyIfOutputExists /** * Register a callback to be called if the operation fails. + * + * @return $this */ public function onFailure(Closure $callback): static { @@ -775,6 +817,8 @@ public function onFailure(Closure $callback): static /** * Register a callback that uses the output if the operation fails. + * + * @return $this */ public function onFailureWithOutput(Closure $callback, bool $onlyIfOutputExists = false): static { @@ -875,6 +919,8 @@ public function getExpression(): string /** * Set the event mutex implementation to be used. + * + * @return $this */ public function preventOverlapsUsing(EventMutex $mutex): static { @@ -900,6 +946,8 @@ public function mutexName(): string /** * Set the mutex name or name resolver callback. + * + * @return $this */ public function createMutexNameUsing(Closure|string $mutexName): static { diff --git a/src/console/src/Scheduling/ManagesAttributes.php b/src/console/src/Scheduling/ManagesAttributes.php index a5a6f3f9f..ff3e32a2c 100644 --- a/src/console/src/Scheduling/ManagesAttributes.php +++ b/src/console/src/Scheduling/ManagesAttributes.php @@ -91,6 +91,8 @@ trait ManagesAttributes /** * Limit the environments the command should run in. + * + * @return $this */ public function environments(UnitEnum|array|string $environments): static { @@ -104,6 +106,8 @@ enum_value(...), /** * State that the command should run even in maintenance mode. + * + * @return $this */ public function evenInMaintenanceMode(): static { @@ -114,6 +118,8 @@ public function evenInMaintenanceMode(): static /** * State that the command should run even when the scheduler is paused. + * + * @return $this */ public function evenWhenPaused(): static { @@ -126,6 +132,8 @@ public function evenWhenPaused(): static * Do not allow the event to overlap each other. * * The expiration time of the underlying cache lock may be specified in minutes. + * + * @return $this */ public function withoutOverlapping(int $expiresAt = 1440, bool $releaseOnTerminationSignals = true): static { @@ -142,6 +150,8 @@ public function withoutOverlapping(int $expiresAt = 1440, bool $releaseOnTermina /** * Allow the event to only run on one server for each cron expression. + * + * @return $this */ public function onOneServer(): static { @@ -152,6 +162,8 @@ public function onOneServer(): static /** * State that the command should run in the background. + * + * @return $this */ public function runInBackground(): static { @@ -162,6 +174,8 @@ public function runInBackground(): static /** * Register a callback to further filter the schedule. + * + * @return $this */ public function when(bool|callable $callback): static { @@ -174,6 +188,8 @@ public function when(bool|callable $callback): static /** * Register a callback to further filter the schedule. + * + * @return $this */ public function skip(bool|callable $callback): static { @@ -186,6 +202,8 @@ public function skip(bool|callable $callback): static /** * Set the human-friendly description of the event. + * + * @return $this */ public function name(string $description): static { @@ -194,6 +212,8 @@ public function name(string $description): static /** * Set the human-friendly description of the event. + * + * @return $this */ public function description(string $description): static { @@ -204,6 +224,8 @@ public function description(string $description): static /** * Set arbitrary attributes to store with the event. + * + * @return $this */ public function withAttributes(array $attributes): static { diff --git a/src/console/src/Scheduling/ManagesFrequencies.php b/src/console/src/Scheduling/ManagesFrequencies.php index db2e9535a..622efed26 100644 --- a/src/console/src/Scheduling/ManagesFrequencies.php +++ b/src/console/src/Scheduling/ManagesFrequencies.php @@ -16,6 +16,8 @@ trait ManagesFrequencies { /** * The Cron expression representing the event's frequency. + * + * @return $this */ public function cron(string $expression): static { @@ -26,6 +28,8 @@ public function cron(string $expression): static /** * Schedule the event to run between start and end time. + * + * @return $this */ public function between(string $startTime, string $endTime): static { @@ -34,6 +38,8 @@ public function between(string $startTime, string $endTime): static /** * Schedule the event to not run between start and end time. + * + * @return $this */ public function unlessBetween(string $startTime, string $endTime): static { @@ -66,6 +72,8 @@ private function inTimeInterval(string $startTime, string $endTime): Closure /** * Schedule the event to run every second. + * + * @return $this */ public function everySecond(): static { @@ -74,6 +82,8 @@ public function everySecond(): static /** * Schedule the event to run every two seconds. + * + * @return $this */ public function everyTwoSeconds(): static { @@ -82,6 +92,8 @@ public function everyTwoSeconds(): static /** * Schedule the event to run every five seconds. + * + * @return $this */ public function everyFiveSeconds(): static { @@ -90,6 +102,8 @@ public function everyFiveSeconds(): static /** * Schedule the event to run every ten seconds. + * + * @return $this */ public function everyTenSeconds(): static { @@ -98,6 +112,8 @@ public function everyTenSeconds(): static /** * Schedule the event to run every fifteen seconds. + * + * @return $this */ public function everyFifteenSeconds(): static { @@ -106,6 +122,8 @@ public function everyFifteenSeconds(): static /** * Schedule the event to run every twenty seconds. + * + * @return $this */ public function everyTwentySeconds(): static { @@ -114,6 +132,8 @@ public function everyTwentySeconds(): static /** * Schedule the event to run every thirty seconds. + * + * @return $this */ public function everyThirtySeconds(): static { @@ -124,6 +144,7 @@ public function everyThirtySeconds(): static * Schedule the event to run multiple times per minute. * * @param int<1, 59> $seconds + * @return $this * * @throws InvalidArgumentException */ @@ -144,6 +165,8 @@ protected function repeatEvery(int $seconds): static /** * Schedule the event to run every minute. + * + * @return $this */ public function everyMinute(): static { @@ -152,6 +175,8 @@ public function everyMinute(): static /** * Schedule the event to run every two minutes. + * + * @return $this */ public function everyTwoMinutes(): static { @@ -160,6 +185,8 @@ public function everyTwoMinutes(): static /** * Schedule the event to run every three minutes. + * + * @return $this */ public function everyThreeMinutes(): static { @@ -168,6 +195,8 @@ public function everyThreeMinutes(): static /** * Schedule the event to run every four minutes. + * + * @return $this */ public function everyFourMinutes(): static { @@ -176,6 +205,8 @@ public function everyFourMinutes(): static /** * Schedule the event to run every five minutes. + * + * @return $this */ public function everyFiveMinutes(): static { @@ -184,6 +215,8 @@ public function everyFiveMinutes(): static /** * Schedule the event to run every ten minutes. + * + * @return $this */ public function everyTenMinutes(): static { @@ -192,6 +225,8 @@ public function everyTenMinutes(): static /** * Schedule the event to run every fifteen minutes. + * + * @return $this */ public function everyFifteenMinutes(): static { @@ -200,6 +235,8 @@ public function everyFifteenMinutes(): static /** * Schedule the event to run every thirty minutes. + * + * @return $this */ public function everyThirtyMinutes(): static { @@ -208,6 +245,8 @@ public function everyThirtyMinutes(): static /** * Schedule the event to run hourly. + * + * @return $this */ public function hourly(): static { @@ -218,6 +257,7 @@ public function hourly(): static * Schedule the event to run hourly at a given offset in the hour. * * @param array|int<0, 59>|int<0, 59>[]|string $offset + * @return $this */ public function hourlyAt(array|int|string $offset): static { @@ -226,6 +266,8 @@ public function hourlyAt(array|int|string $offset): static /** * Schedule the event to run every odd hour. + * + * @return $this */ public function everyOddHour(array|int|string $offset = 0): static { @@ -234,6 +276,8 @@ public function everyOddHour(array|int|string $offset = 0): static /** * Schedule the event to run every two hours. + * + * @return $this */ public function everyTwoHours(array|int|string $offset = 0): static { @@ -242,6 +286,8 @@ public function everyTwoHours(array|int|string $offset = 0): static /** * Schedule the event to run every three hours. + * + * @return $this */ public function everyThreeHours(array|int|string $offset = 0): static { @@ -250,6 +296,8 @@ public function everyThreeHours(array|int|string $offset = 0): static /** * Schedule the event to run every four hours. + * + * @return $this */ public function everyFourHours(array|int|string $offset = 0): static { @@ -258,6 +306,8 @@ public function everyFourHours(array|int|string $offset = 0): static /** * Schedule the event to run every six hours. + * + * @return $this */ public function everySixHours(array|int|string $offset = 0): static { @@ -266,6 +316,8 @@ public function everySixHours(array|int|string $offset = 0): static /** * Schedule the event to run daily. + * + * @return $this */ public function daily(): static { @@ -274,6 +326,8 @@ public function daily(): static /** * Schedule the command at a given time. + * + * @return $this */ public function at(string $time): static { @@ -282,6 +336,8 @@ public function at(string $time): static /** * Schedule the event to run daily at a given time (10:00, 19:30, etc). + * + * @return $this */ public function dailyAt(string $time): static { @@ -298,6 +354,7 @@ public function dailyAt(string $time): static * * @param int<0, 23> $first * @param int<0, 23> $second + * @return $this */ public function twiceDaily(int $first = 1, int $second = 13): static { @@ -310,6 +367,7 @@ public function twiceDaily(int $first = 1, int $second = 13): static * @param int<0, 23> $first * @param int<0, 23> $second * @param int<0, 59> $offset + * @return $this */ public function twiceDailyAt(int $first = 1, int $second = 13, int $offset = 0): static { @@ -323,6 +381,7 @@ public function twiceDailyAt(int $first = 1, int $second = 13, int $offset = 0): * * @param array|int<0, 59>|string $minutes * @param array|int<0, 23>|string $hours + * @return $this */ protected function hourBasedSchedule(array|int|string $minutes, array|int|string $hours): static { @@ -336,6 +395,8 @@ protected function hourBasedSchedule(array|int|string $minutes, array|int|string /** * Schedule the event to run only on weekdays. + * + * @return $this */ public function weekdays(): static { @@ -344,6 +405,8 @@ public function weekdays(): static /** * Schedule the event to run only on weekends. + * + * @return $this */ public function weekends(): static { @@ -352,6 +415,8 @@ public function weekends(): static /** * Schedule the event to run only on Mondays. + * + * @return $this */ public function mondays(): static { @@ -360,6 +425,8 @@ public function mondays(): static /** * Schedule the event to run only on Tuesdays. + * + * @return $this */ public function tuesdays(): static { @@ -368,6 +435,8 @@ public function tuesdays(): static /** * Schedule the event to run only on Wednesdays. + * + * @return $this */ public function wednesdays(): static { @@ -376,6 +445,8 @@ public function wednesdays(): static /** * Schedule the event to run only on Thursdays. + * + * @return $this */ public function thursdays(): static { @@ -384,6 +455,8 @@ public function thursdays(): static /** * Schedule the event to run only on Fridays. + * + * @return $this */ public function fridays(): static { @@ -392,6 +465,8 @@ public function fridays(): static /** * Schedule the event to run only on Saturdays. + * + * @return $this */ public function saturdays(): static { @@ -400,6 +475,8 @@ public function saturdays(): static /** * Schedule the event to run only on Sundays. + * + * @return $this */ public function sundays(): static { @@ -408,6 +485,8 @@ public function sundays(): static /** * Schedule the event to run weekly. + * + * @return $this */ public function weekly(): static { @@ -418,6 +497,8 @@ public function weekly(): static /** * Schedule the event to run weekly on a given day and time. + * + * @return $this */ public function weeklyOn(mixed $dayOfWeek, string $time = '0:0'): static { @@ -428,6 +509,8 @@ public function weeklyOn(mixed $dayOfWeek, string $time = '0:0'): static /** * Schedule the event to run monthly. + * + * @return $this */ public function monthly(): static { @@ -440,6 +523,7 @@ public function monthly(): static * Schedule the event to run monthly on a given day and time. * * @param int<1, 31> $dayOfMonth + * @return $this */ public function monthlyOn(int $dayOfMonth = 1, string $time = '0:0'): static { @@ -453,6 +537,7 @@ public function monthlyOn(int $dayOfMonth = 1, string $time = '0:0'): static * * @param int<1, 31> $first * @param int<1, 31> $second + * @return $this */ public function twiceMonthly(int $first = 1, int $second = 16, string $time = '0:0'): static { @@ -465,6 +550,8 @@ public function twiceMonthly(int $first = 1, int $second = 16, string $time = '0 /** * Schedule the event to run on the last day of the month. + * + * @return $this */ public function lastDayOfMonth(string $time = '0:0'): static { @@ -477,6 +564,7 @@ public function lastDayOfMonth(string $time = '0:0'): static * Schedule the event to run on specific days of the month. * * @param array>|int<1, 31> ...$days + * @return $this */ public function daysOfMonth(int|array ...$days): static { @@ -490,6 +578,8 @@ public function daysOfMonth(int|array ...$days): static /** * Schedule the event to run quarterly. + * + * @return $this */ public function quarterly(): static { @@ -501,6 +591,8 @@ public function quarterly(): static /** * Schedule the event to run quarterly on a given day and time. + * + * @return $this */ public function quarterlyOn(int $dayOfQuarter = 1, string $time = '0:0'): static { @@ -512,6 +604,8 @@ public function quarterlyOn(int $dayOfQuarter = 1, string $time = '0:0'): static /** * Schedule the event to run yearly. + * + * @return $this */ public function yearly(): static { @@ -523,6 +617,8 @@ public function yearly(): static /** * Schedule the event to run yearly on a given month, day, and time. + * + * @return $this */ public function yearlyOn(int $month = 1, int|string $dayOfMonth = 1, string $time = '0:0'): static { @@ -534,6 +630,8 @@ public function yearlyOn(int $month = 1, int|string $dayOfMonth = 1, string $tim /** * Set the days of the week the command should run on. + * + * @return $this */ public function days(mixed $days): static { @@ -544,6 +642,8 @@ public function days(mixed $days): static /** * Set the timezone the date should be evaluated on. + * + * @return $this */ public function timezone(DateTimeZone|UnitEnum|string $timezone): static { @@ -556,6 +656,8 @@ public function timezone(DateTimeZone|UnitEnum|string $timezone): static /** * Splice the given value into the given position of the expression. + * + * @return $this */ protected function spliceIntoPosition(int $position, int|string $value): static { diff --git a/src/console/src/Scheduling/PendingEventAttributes.php b/src/console/src/Scheduling/PendingEventAttributes.php index f568eb73c..3e83a9113 100644 --- a/src/console/src/Scheduling/PendingEventAttributes.php +++ b/src/console/src/Scheduling/PendingEventAttributes.php @@ -60,6 +60,8 @@ public function __construct( * Do not allow the event to overlap each other. * * The expiration time of the underlying cache lock may be specified in minutes. + * + * @return $this */ public function withoutOverlapping(int $expiresAt = 1440, bool $releaseOnTerminationSignals = true): static { diff --git a/src/console/src/Scheduling/Schedule.php b/src/console/src/Scheduling/Schedule.php index 9a995b59a..45733335a 100644 --- a/src/console/src/Scheduling/Schedule.php +++ b/src/console/src/Scheduling/Schedule.php @@ -455,6 +455,8 @@ public function eventsForEnvironments(array $environments): array * * Boot-only. Mutates the shared mutex instances used by this worker's * schedule; per-request use races across coroutines. + * + * @return $this */ public function useCache(UnitEnum|string|null $store): static { From 299d8d75a80ad49c9c95d891f1d88063b4e396ec Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 136/303] Restore fluent container and pipeline contracts Restore receiver identity on contextual binding and pipeline configuration, including their interfaces. Delegated configuration methods keep the same builder or pipeline instance. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/container/src/ContextualBindingBuilder.php | 8 ++++++++ .../src/Container/ContextualBindingBuilder.php | 8 ++++++++ src/contracts/src/Pipeline/Pipeline.php | 6 ++++++ src/pipeline/src/Hub.php | 2 ++ src/pipeline/src/Pipeline.php | 14 ++++++++++++++ 5 files changed, 38 insertions(+) diff --git a/src/container/src/ContextualBindingBuilder.php b/src/container/src/ContextualBindingBuilder.php index de1093f34..1ad384c69 100644 --- a/src/container/src/ContextualBindingBuilder.php +++ b/src/container/src/ContextualBindingBuilder.php @@ -25,6 +25,8 @@ public function __construct( /** * Define the abstract target that depends on the context. + * + * @return $this */ public function needs(string $abstract): static { @@ -35,6 +37,8 @@ public function needs(string $abstract): static /** * Define the implementation for the contextual binding. + * + * @return $this */ public function give(mixed $implementation): static { @@ -47,6 +51,8 @@ public function give(mixed $implementation): static /** * Define tagged services to be used as the implementation for the contextual binding. + * + * @return $this */ public function giveTagged(string $tag): static { @@ -59,6 +65,8 @@ public function giveTagged(string $tag): static /** * Specify the configuration item to bind as a primitive. + * + * @return $this */ public function giveConfig(string $key, mixed $default = null): static { diff --git a/src/contracts/src/Container/ContextualBindingBuilder.php b/src/contracts/src/Container/ContextualBindingBuilder.php index 7cee39cdb..53cb7154c 100644 --- a/src/contracts/src/Container/ContextualBindingBuilder.php +++ b/src/contracts/src/Container/ContextualBindingBuilder.php @@ -8,21 +8,29 @@ interface ContextualBindingBuilder { /** * Define the abstract target that depends on the context. + * + * @return $this */ public function needs(string $abstract): static; /** * Define the implementation for the contextual binding. + * + * @return $this */ public function give(mixed $implementation): static; /** * Define tagged services to be used as the implementation for the contextual binding. + * + * @return $this */ public function giveTagged(string $tag): static; /** * Specify the configuration item to bind as a primitive. + * + * @return $this */ public function giveConfig(string $key, mixed $default = null): static; } diff --git a/src/contracts/src/Pipeline/Pipeline.php b/src/contracts/src/Pipeline/Pipeline.php index 08b426eea..eb96bf2c0 100644 --- a/src/contracts/src/Pipeline/Pipeline.php +++ b/src/contracts/src/Pipeline/Pipeline.php @@ -10,16 +10,22 @@ interface Pipeline { /** * Set the object being sent through the pipeline. + * + * @return $this */ public function send(mixed $passable): static; /** * Set the array of pipes. + * + * @return $this */ public function through(mixed $pipes): static; /** * Set the method to call on the pipes. + * + * @return $this */ public function via(string $method): static; diff --git a/src/pipeline/src/Hub.php b/src/pipeline/src/Hub.php index e1bfca19e..a1feccc83 100644 --- a/src/pipeline/src/Hub.php +++ b/src/pipeline/src/Hub.php @@ -86,6 +86,8 @@ public function getContainer(): ?Container * Tests only. Swaps the container on the singleton Hub used by every * coroutine; per-request use races across coroutines and breaks every * concurrent pipeline dispatch. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/pipeline/src/Pipeline.php b/src/pipeline/src/Pipeline.php index 5196137cc..94210cc26 100644 --- a/src/pipeline/src/Pipeline.php +++ b/src/pipeline/src/Pipeline.php @@ -59,6 +59,8 @@ public function __construct(?Container $container = null) /** * Set the object being sent through the pipeline. + * + * @return $this */ public function send(mixed $passable): static { @@ -69,6 +71,8 @@ public function send(mixed $passable): static /** * Set the array of pipes. + * + * @return $this */ public function through(mixed $pipes): static { @@ -79,6 +83,8 @@ public function through(mixed $pipes): static /** * Push additional pipes onto the pipeline. + * + * @return $this */ public function pipe(mixed $pipes): static { @@ -89,6 +95,8 @@ public function pipe(mixed $pipes): static /** * Set the method to call on the pipes. + * + * @return $this */ public function via(string $method): static { @@ -146,6 +154,8 @@ public function thenReturn(): mixed /** * Set a final callback to be executed after the pipeline ends regardless of the outcome. + * + * @return $this */ public function finally(Closure $callback): static { @@ -249,6 +259,8 @@ protected function pipes(): array /** * Execute each pipeline step within a database transaction. + * + * @return $this */ public function withinTransaction(string|UnitEnum|false|null $withinTransaction = null): static { @@ -271,6 +283,8 @@ protected function getContainer(): Container /** * Set the container instance. + * + * @return $this */ public function setContainer(Container $container): static { From 27dfe01109b816d3e7ad4846e98f312dbac1e615 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 137/303] Restore fluent database configuration and model contracts Restore identity annotations on model mutation, eager loading, connections, schema definitions and database helpers. Refine ForeignKeyDefinition magic setter annotations to preserve the receiver. Keep clone-returning model and factory operations unchanged, and leave the cursor-page builder hook untagged because its Eloquent override can return a scoped clone. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- .../Eloquent/SupportsPartialRelations.php | 2 ++ .../src/Database/ModelIdentifier.php | 1 + src/database/src/Connection.php | 26 +++++++++++++++ src/database/src/DatabaseManager.php | 2 ++ .../BroadcastableModelEventOccurred.php | 2 ++ src/database/src/Eloquent/Builder.php | 2 ++ src/database/src/Eloquent/Collection.php | 23 +++++++++++++ .../Eloquent/Concerns/GuardsAttributes.php | 4 +++ .../src/Eloquent/Concerns/HasAttributes.php | 24 ++++++++++++++ .../Eloquent/Concerns/HasRelationships.php | 2 ++ src/database/src/Eloquent/Model.php | 32 +++++++++++++++++++ .../src/Eloquent/ModelNotFoundException.php | 1 + src/database/src/Migrations/Migrator.php | 2 ++ src/database/src/MySqlConnection.php | 2 ++ src/database/src/PdoConnection.php | 6 ++++ src/database/src/Query/Builder.php | 6 ++++ src/database/src/Schema/Blueprint.php | 2 ++ .../src/Schema/ForeignKeyDefinition.php | 32 ++++++++++++++----- src/database/src/Schema/SchemaState.php | 4 +++ src/database/src/Seeder.php | 5 +++ .../UniqueConstraintViolationException.php | 3 ++ 21 files changed, 175 insertions(+), 8 deletions(-) diff --git a/src/contracts/src/Database/Eloquent/SupportsPartialRelations.php b/src/contracts/src/Database/Eloquent/SupportsPartialRelations.php index 09a569608..c29e76dd8 100644 --- a/src/contracts/src/Database/Eloquent/SupportsPartialRelations.php +++ b/src/contracts/src/Database/Eloquent/SupportsPartialRelations.php @@ -11,6 +11,8 @@ interface SupportsPartialRelations { /** * Indicate that the relation is a single result of a larger one-to-many relationship. + * + * @return $this */ public function ofMany(array|string|null $column = 'id', string|Closure|null $aggregate = 'MAX', ?string $relation = null): static; diff --git a/src/contracts/src/Database/ModelIdentifier.php b/src/contracts/src/Database/ModelIdentifier.php index 3cfcad86c..7c6385349 100644 --- a/src/contracts/src/Database/ModelIdentifier.php +++ b/src/contracts/src/Database/ModelIdentifier.php @@ -77,6 +77,7 @@ public function __construct(?string $class, mixed $id, array $relations, ?string * Specify the collection class that should be used when serializing / restoring collections. * * @param null|class-string $collectionClass + * @return $this */ public function useCollectionClass(?string $collectionClass): static { diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 6c647f512..827b3f5bb 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -1128,6 +1128,8 @@ public function isReusable(): bool /** * Register a hook to be run just before a database transaction is started. + * + * @return $this */ public function beforeStartingTransaction(Closure $callback): static { @@ -1138,6 +1140,8 @@ public function beforeStartingTransaction(Closure $callback): static /** * Register a hook to be run just before a database query is executed. + * + * @return $this */ public function beforeExecuting(Closure $callback): static { @@ -1404,6 +1408,8 @@ public function recordsHaveBeenModified(bool $value = true): void /** * Set the record modification state. + * + * @return $this */ public function setRecordModificationState(bool $value): static { @@ -1422,6 +1428,8 @@ public function forgetRecordModificationState(): void /** * Indicate that the connection should use the write connection for reads. + * + * @return $this */ public function useWriteConnectionWhenReading(bool $value = true): static { @@ -1492,6 +1500,8 @@ public function executeSessionStatement(string $sql): void /** * Set the reconnect instance on the connection. + * + * @return $this */ public function setReconnector(callable $reconnector): static { @@ -1614,6 +1624,8 @@ public function getQueryGrammar(): QueryGrammar /** * Set the query grammar used by the connection. + * + * @return $this */ public function setQueryGrammar(Query\Grammars\Grammar $grammar): static { @@ -1632,6 +1644,8 @@ public function getSchemaGrammar(): ?Schema\Grammars\Grammar /** * Set the schema grammar used by the connection. + * + * @return $this */ public function setSchemaGrammar(Schema\Grammars\Grammar $grammar): static { @@ -1650,6 +1664,8 @@ public function getPostProcessor(): Processor /** * Set the query post processor used by the connection. + * + * @return $this */ public function setPostProcessor(Processor $processor): static { @@ -1668,6 +1684,8 @@ public function getEventDispatcher(): ?Dispatcher /** * Set the event dispatcher instance on the connection. + * + * @return $this */ public function setEventDispatcher(Dispatcher $events): static { @@ -1748,6 +1766,8 @@ public function hasPhysicalTransaction(): bool /** * Set the transaction manager instance on the connection. + * + * @return $this */ public function setTransactionManager(DatabaseTransactionsManager $manager): static { @@ -1849,6 +1869,8 @@ public function getDatabaseName(): string /** * Set the name of the connected database. + * + * @return $this */ public function setDatabaseName(string $database): static { @@ -1859,6 +1881,8 @@ public function setDatabaseName(string $database): static /** * Set the read / write type of the connection. + * + * @return $this */ public function setReadWriteType(?string $readWriteType): static { @@ -1887,6 +1911,8 @@ public function getTablePrefix(): string /** * Set the table prefix in use by the connection. + * + * @return $this */ public function setTablePrefix(string $prefix): static { diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php index ab5000549..d69271f58 100755 --- a/src/database/src/DatabaseManager.php +++ b/src/database/src/DatabaseManager.php @@ -827,6 +827,8 @@ public function setReconnector(callable $reconnector): void * * Tests only. Swaps the singleton's application reference; per-request use * races across coroutines and breaks every concurrent database operation. + * + * @return $this */ public function setApplication(Application $app): static { diff --git a/src/database/src/Eloquent/BroadcastableModelEventOccurred.php b/src/database/src/Eloquent/BroadcastableModelEventOccurred.php index 0783bf753..2e35f8aa9 100644 --- a/src/database/src/Eloquent/BroadcastableModelEventOccurred.php +++ b/src/database/src/Eloquent/BroadcastableModelEventOccurred.php @@ -85,6 +85,8 @@ public function broadcastWith(): ?array /** * Manually specify the channels the event should broadcast on. + * + * @return $this */ public function onChannels(array $channels): static { diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index f3b1544d7..36fdc62b2 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -1645,6 +1645,8 @@ public function applyScopes(): static * Keep existing and callback-added WHERE constraints logically separate * so conditions in either group cannot change the meaning of the other, * including when later constraints are added. + * + * @return $this */ public function applyScopeCallback(callable $scope): static { diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index d6da643cd..1885e372e 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -109,6 +109,7 @@ public function findOrFail(mixed $key): Model|static * Load a set of relationships onto the collection. * * @param array): mixed)|string>|string $relations + * @return $this */ public function load(array|string $relations): static { @@ -129,6 +130,7 @@ public function load(array|string $relations): static * Load a set of aggregations over relationship's column onto the collection. * * @param array): mixed)|string>|string $relations + * @return $this * * @throws MissingAttributeException */ @@ -191,6 +193,7 @@ public function loadAggregate(array|string $relations, Expression|string $column * Load a set of relationship counts onto the collection. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadCount(array|string $relations): static { @@ -201,6 +204,7 @@ public function loadCount(array|string $relations): static * Load a set of relationship's max column values onto the collection. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadMax(array|string $relations, Expression|string $column): static { @@ -211,6 +215,7 @@ public function loadMax(array|string $relations, Expression|string $column): sta * Load a set of relationship's min column values onto the collection. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadMin(array|string $relations, Expression|string $column): static { @@ -221,6 +226,7 @@ public function loadMin(array|string $relations, Expression|string $column): sta * Load a set of relationship's column summations onto the collection. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadSum(array|string $relations, Expression|string $column): static { @@ -231,6 +237,7 @@ public function loadSum(array|string $relations, Expression|string $column): sta * Load a set of relationship's average column values onto the collection. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadAvg(array|string $relations, Expression|string $column): static { @@ -241,6 +248,7 @@ public function loadAvg(array|string $relations, Expression|string $column): sta * Load a set of related existences onto the collection. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadExists(array|string $relations): static { @@ -251,6 +259,7 @@ public function loadExists(array|string $relations): static * Load a set of relationships onto the collection if they are not already eager loaded. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadMissing(array|string $relations): static { @@ -353,6 +362,7 @@ function (BaseCollection $models) use ($path): void { * Load a set of relationships onto the mixed relationship collection. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorph(string $relation, array $relations): static { @@ -370,6 +380,7 @@ public function loadMorph(string $relation, array $relations): static * Load a set of relationship counts onto the mixed relationship collection. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphCount(string $relation, array $relations): static { @@ -657,6 +668,7 @@ public function except($keys): static * Make the given, typically visible, attributes hidden across the entire collection. * * @param array|string $attributes + * @return $this */ public function makeHidden(array|string $attributes): static { @@ -668,6 +680,7 @@ public function makeHidden(array|string $attributes): static * Merge the given, typically visible, attributes hidden across the entire collection. * * @param array|string $attributes + * @return $this */ public function mergeHidden(array|string $attributes): static { @@ -679,6 +692,7 @@ public function mergeHidden(array|string $attributes): static * Set the hidden attributes across the entire collection. * * @param array $hidden + * @return $this */ public function setHidden(array $hidden): static { @@ -690,6 +704,7 @@ public function setHidden(array $hidden): static * Make the given, typically hidden, attributes visible across the entire collection. * * @param array|string $attributes + * @return $this */ public function makeVisible(array|string $attributes): static { @@ -701,6 +716,7 @@ public function makeVisible(array|string $attributes): static * Merge the given, typically hidden, attributes visible across the entire collection. * * @param array|string $attributes + * @return $this */ public function mergeVisible(array|string $attributes): static { @@ -712,6 +728,7 @@ public function mergeVisible(array|string $attributes): static * Set the visible attributes across the entire collection. * * @param array $visible + * @return $this */ public function setVisible(array $visible): static { @@ -723,6 +740,7 @@ public function setVisible(array $visible): static * Append an attribute across the entire collection. * * @param array|string $attributes + * @return $this */ public function append(array|string $attributes): static { @@ -734,6 +752,7 @@ public function append(array|string $attributes): static * Sets the appends on every element of the collection, overwriting the existing appends for each. * * @param array $appends + * @return $this */ public function setAppends(array $appends): static { @@ -743,6 +762,8 @@ public function setAppends(array $appends): static /** * Remove appended properties from every element in the collection. + * + * @return $this */ public function withoutAppends(): static { @@ -932,6 +953,8 @@ protected function duplicateComparator(bool $strict): callable /** * Enable relationship autoloading for all models in this collection. + * + * @return $this */ public function withRelationshipAutoloading(): static { diff --git a/src/database/src/Eloquent/Concerns/GuardsAttributes.php b/src/database/src/Eloquent/Concerns/GuardsAttributes.php index 229622b29..3c7f9fb42 100644 --- a/src/database/src/Eloquent/Concerns/GuardsAttributes.php +++ b/src/database/src/Eloquent/Concerns/GuardsAttributes.php @@ -155,6 +155,7 @@ public function getFillable(): array * Set the fillable attributes for the model. * * @param array $fillable + * @return $this */ public function fillable(array $fillable): static { @@ -167,6 +168,7 @@ public function fillable(array $fillable): static * Merge new fillable attributes with existing fillable attributes on the model. * * @param array $fillable + * @return $this */ public function mergeFillable(array $fillable): static { @@ -195,6 +197,7 @@ public function getGuarded(): array * Set the guarded attributes for the model. * * @param array $guarded + * @return $this */ public function guard(array $guarded): static { @@ -207,6 +210,7 @@ public function guard(array $guarded): static * Merge new guarded attributes with existing guarded attributes on the model. * * @param array $guarded + * @return $this */ public function mergeGuarded(array $guarded): static { diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index aa3536702..e67cad986 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -779,6 +779,8 @@ protected function mutateAttributeForArray(string $key, mixed $value): mixed /** * Merge new casts with existing casts on the model. + * + * @return $this */ public function mergeCasts(array $casts): static { @@ -1221,6 +1223,8 @@ protected function isDateAttribute(string $key): bool /** * Set a given JSON attribute on the model. + * + * @return $this */ public function fillJsonAttribute(string $key, mixed $value): static { @@ -1623,6 +1627,8 @@ public function getDateFormat(): string /** * Set the date format used by the model. + * + * @return $this */ public function setDateFormat(string $format): static { @@ -2092,6 +2098,8 @@ public function encodeBinaryAttributeForDatabase(string $key, mixed $value): mix /** * Set the array of model attributes. No checking is done. + * + * @return $this */ public function setRawAttributes(array $attributes, bool $sync = false): static { @@ -2191,6 +2199,8 @@ public function except(mixed $attributes): array /** * Sync the original attributes with the current. + * + * @return $this */ public function syncOriginal(): static { @@ -2201,6 +2211,8 @@ public function syncOriginal(): static /** * Sync a single original attribute with its current value. + * + * @return $this */ public function syncOriginalAttribute(string $attribute): static { @@ -2211,6 +2223,7 @@ public function syncOriginalAttribute(string $attribute): static * Sync multiple original attribute with their current values. * * @param array|string $attributes + * @return $this */ public function syncOriginalAttributes(array|string $attributes): static { @@ -2227,6 +2240,8 @@ public function syncOriginalAttributes(array|string $attributes): static /** * Sync the changed attributes. + * + * @return $this */ public function syncChanges(): static { @@ -2242,6 +2257,7 @@ public function syncChanges(): static * Sync the supplied changed attributes. * * @param array $changes + * @return $this */ private function syncChangesFrom(array $changes): static { @@ -2281,6 +2297,8 @@ public function isClean(array|string|null $attributes = null): bool /** * Discard attribute changes and reset the attributes to their original state. + * + * @return $this */ public function discardChanges(): static { @@ -2548,6 +2566,7 @@ protected function transformModelValue(string $key, mixed $value): mixed * Append attributes to query when building a query. * * @param array|string $attributes + * @return $this */ public function append(array|string $attributes): static { @@ -2568,6 +2587,8 @@ public function getAppends(): array /** * Set the accessors to append to model arrays. + * + * @return $this */ public function setAppends(array $appends): static { @@ -2580,6 +2601,7 @@ public function setAppends(array $appends): static * Merge new appended attributes with existing appended attributes on the model. * * @param array $appends + * @return $this */ public function mergeAppends(array $appends): static { @@ -2602,6 +2624,8 @@ public function hasAppended(string $attribute): bool /** * Remove all appended properties from the model. + * + * @return $this */ public function withoutAppends(): static { diff --git a/src/database/src/Eloquent/Concerns/HasRelationships.php b/src/database/src/Eloquent/Concerns/HasRelationships.php index cb21554ca..dd39b64cd 100644 --- a/src/database/src/Eloquent/Concerns/HasRelationships.php +++ b/src/database/src/Eloquent/Concerns/HasRelationships.php @@ -124,6 +124,8 @@ public function hasRelationAutoloadCallback(): bool /** * Define an automatic relationship autoloader callback for this model and its relations. + * + * @return $this */ public function autoloadRelationsUsing(Closure $callback, mixed $context = null): static { diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 3fb21f140..b023b0e98 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -814,6 +814,7 @@ public static function isBroadcasting(): bool * Fill the model with an array of attributes. * * @param array $attributes + * @return $this * * @throws MassAssignmentException */ @@ -868,6 +869,7 @@ public function fill(array $attributes): static * Fill the model with an array of attributes. Force mass assignment. * * @param array $attributes + * @return $this */ public function forceFill(array $attributes): static { @@ -997,6 +999,7 @@ public static function with(array|string $relations): Builder * Eager load relations on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function load(array|string $relations): static { @@ -1013,6 +1016,7 @@ public function load(array|string $relations): static * Eager load relationships on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorph(string $relation, array $relations): static { @@ -1031,6 +1035,7 @@ public function loadMorph(string $relation, array $relations): static * Eager load relations on the model if they are not already eager loaded. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadMissing(array|string $relations): static { @@ -1045,6 +1050,7 @@ public function loadMissing(array|string $relations): static * Eager load relation's column aggregations on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadAggregate(array|string $relations, Expression|string $column, ?string $function = null): static { @@ -1057,6 +1063,7 @@ public function loadAggregate(array|string $relations, Expression|string $column * Eager load relation counts on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadCount(array|string $relations): static { @@ -1069,6 +1076,7 @@ public function loadCount(array|string $relations): static * Eager load relation max column values on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadMax(array|string $relations, Expression|string $column): static { @@ -1079,6 +1087,7 @@ public function loadMax(array|string $relations, Expression|string $column): sta * Eager load relation min column values on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadMin(array|string $relations, Expression|string $column): static { @@ -1089,6 +1098,7 @@ public function loadMin(array|string $relations, Expression|string $column): sta * Eager load relation's column summations on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadSum(array|string $relations, Expression|string $column): static { @@ -1099,6 +1109,7 @@ public function loadSum(array|string $relations, Expression|string $column): sta * Eager load relation average column values on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadAvg(array|string $relations, Expression|string $column): static { @@ -1109,6 +1120,7 @@ public function loadAvg(array|string $relations, Expression|string $column): sta * Eager load related model existence values on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadExists(array|string $relations): static { @@ -1119,6 +1131,7 @@ public function loadExists(array|string $relations): static * Eager load relationship column aggregation on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphAggregate(string $relation, array $relations, Expression|string $column, ?string $function = null): static { @@ -1137,6 +1150,7 @@ public function loadMorphAggregate(string $relation, array $relations, Expressio * Eager load relationship counts on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphCount(string $relation, array $relations): static { @@ -1147,6 +1161,7 @@ public function loadMorphCount(string $relation, array $relations): static * Eager load relationship max column values on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphMax(string $relation, array $relations, Expression|string $column): static { @@ -1157,6 +1172,7 @@ public function loadMorphMax(string $relation, array $relations, Expression|stri * Eager load relationship min column values on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphMin(string $relation, array $relations, Expression|string $column): static { @@ -1167,6 +1183,7 @@ public function loadMorphMin(string $relation, array $relations, Expression|stri * Eager load relationship column summations on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphSum(string $relation, array $relations, Expression|string $column): static { @@ -1177,6 +1194,7 @@ public function loadMorphSum(string $relation, array $relations, Expression|stri * Eager load relationship average column values on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphAvg(string $relation, array $relations, Expression|string $column): static { @@ -2497,6 +2515,8 @@ public function getConnectionName(): ?string /** * Set the connection associated with the model. + * + * @return $this */ public function setConnection(UnitEnum|string|null $name): static { @@ -2661,6 +2681,8 @@ public function getTable(): string /** * Set the table associated with the model. + * + * @return $this */ public function setTable(string $table): static { @@ -2679,6 +2701,8 @@ public function getKeyName(): ?string /** * Set the primary key for the model. + * + * @return $this */ public function setKeyName(?string $key): static { @@ -2707,6 +2731,8 @@ public function getKeyType(): string /** * Set the data type for the primary key. + * + * @return $this */ public function setKeyType(string $type): static { @@ -2728,6 +2754,8 @@ public function getIncrementing(): bool /** * Set whether IDs are incrementing. + * + * @return $this */ public function setIncrementing(bool $value): static { @@ -2944,6 +2972,8 @@ public function getPerPage(): int /** * Set the number of models to return per page. + * + * @return $this */ public function setPerPage(int $perPage): static { @@ -3235,6 +3265,8 @@ public function __toString(): string /** * Indicate that the object's string representation should be escaped when __toString is invoked. + * + * @return $this */ public function escapeWhenCastingToString(bool $escape = true): static { diff --git a/src/database/src/Eloquent/ModelNotFoundException.php b/src/database/src/Eloquent/ModelNotFoundException.php index 075776306..8e9fe4ec9 100755 --- a/src/database/src/Eloquent/ModelNotFoundException.php +++ b/src/database/src/Eloquent/ModelNotFoundException.php @@ -34,6 +34,7 @@ class ModelNotFoundException extends RecordsNotFoundException * * @param class-string $model * @param null|array|int|string|UnitEnum $ids + * @return $this */ public function setModel(string $model, array|int|string|UnitEnum|null $ids = []): static { diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index 20922b379..fdda98541 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -910,6 +910,8 @@ public function getFilesystem(): Filesystem /** * Set the output implementation that should be used by the console. + * + * @return $this */ public function setOutput(OutputStyle $output): static { diff --git a/src/database/src/MySqlConnection.php b/src/database/src/MySqlConnection.php index c99a9e9de..63f98403c 100755 --- a/src/database/src/MySqlConnection.php +++ b/src/database/src/MySqlConnection.php @@ -258,6 +258,8 @@ public function getLastInsertId(?string $sequence = null): int|string /** * Set the PDO connection. + * + * @return $this */ public function setPdo(PDO|Closure|null $pdo): static { diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php index 4d884c4dc..1c2fe5948 100755 --- a/src/database/src/PdoConnection.php +++ b/src/database/src/PdoConnection.php @@ -722,6 +722,8 @@ protected function hasDriverResources(): bool /** * Set the PDO connection. + * + * @return $this */ public function setPdo(PDO|Closure|null $pdo): static { @@ -737,6 +739,8 @@ public function setPdo(PDO|Closure|null $pdo): static /** * Set the PDO connection used for reading. + * + * @return $this */ public function setReadPdo(PDO|Closure|null $pdo): static { @@ -748,6 +752,8 @@ public function setReadPdo(PDO|Closure|null $pdo): static /** * Set the read PDO connection configuration. + * + * @return $this */ public function setReadPdoConfig(array $config): static { diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 40b7df610..af0b5c465 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -3283,6 +3283,8 @@ public function sharedLock(): static /** * Skip rows locked by another transaction when supported by the server. + * + * @return $this */ public function skipLocked(): static { @@ -3741,6 +3743,7 @@ public function restoreKeysetOrdersFrom(self $source): bool * Sort an ID iteration by its paging column before any other order. * * @internal + * @return $this */ public function prioritizeKeysetOrders(bool $limited = false): static { @@ -3794,6 +3797,7 @@ public function ensureLimitedIterationOrdersById(string $column, bool $descendin * * @param list $orders * @param array $bindings + * @return $this * @internal */ public function addCursorPaginationConditions(array $orders, array $bindings): static @@ -4192,6 +4196,8 @@ protected function applyUnionKeysetConditions(self $root, array $conditions, arr /** * Append comparisons using the member's source columns or isolated output names. + * + * @return $this */ protected function appendKeysetConditions(array $conditions, array $mapping = [], bool $output = false): static { diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php index 3f85db2e4..9a57451d2 100755 --- a/src/database/src/Schema/Blueprint.php +++ b/src/database/src/Schema/Blueprint.php @@ -1707,6 +1707,8 @@ public function after(string $column, Closure $callback): void /** * Remove a column from the schema blueprint. + * + * @return $this */ public function removeColumn(string $name): static { diff --git a/src/database/src/Schema/ForeignKeyDefinition.php b/src/database/src/Schema/ForeignKeyDefinition.php index 394635e7b..1d5d7b2e4 100644 --- a/src/database/src/Schema/ForeignKeyDefinition.php +++ b/src/database/src/Schema/ForeignKeyDefinition.php @@ -7,19 +7,21 @@ use Hypervel\Support\Fluent; /** - * @method ForeignKeyDefinition deferrable(bool $value = true) Set the foreign key as deferrable (PostgreSQL) - * @method ForeignKeyDefinition initiallyImmediate(bool $value = true) Set the default time to check the constraint (PostgreSQL) - * @method ForeignKeyDefinition inplace(bool $value = true) Specify that algorithm=inplace should be used for the foreign key operation, which requires foreign key checks to be disabled (MySQL) - * @method ForeignKeyDefinition lock(('default'|'exclusive'|'none'|'shared') $value) Specify the DDL lock mode for the foreign key operation (MySQL) - * @method ForeignKeyDefinition on(string $table) Specify the referenced table - * @method ForeignKeyDefinition onDelete(string $action) Add an ON DELETE action - * @method ForeignKeyDefinition onUpdate(string $action) Add an ON UPDATE action - * @method ForeignKeyDefinition references(string|string[] $columns) Specify the referenced column(s) + * @method $this deferrable(bool $value = true) Set the foreign key as deferrable (PostgreSQL) + * @method $this initiallyImmediate(bool $value = true) Set the default time to check the constraint (PostgreSQL) + * @method $this inplace(bool $value = true) Specify that algorithm=inplace should be used for the foreign key operation, which requires foreign key checks to be disabled (MySQL) + * @method $this lock(('default'|'exclusive'|'none'|'shared') $value) Specify the DDL lock mode for the foreign key operation (MySQL) + * @method $this on(string $table) Specify the referenced table + * @method $this onDelete(string $action) Add an ON DELETE action + * @method $this onUpdate(string $action) Add an ON UPDATE action + * @method $this references(string|string[] $columns) Specify the referenced column(s) */ class ForeignKeyDefinition extends Fluent { /** * Indicate that updates should cascade. + * + * @return $this */ public function cascadeOnUpdate(): self { @@ -28,6 +30,8 @@ public function cascadeOnUpdate(): self /** * Indicate that updates should be restricted. + * + * @return $this */ public function restrictOnUpdate(): self { @@ -36,6 +40,8 @@ public function restrictOnUpdate(): self /** * Indicate that updates should set the foreign key value to null. + * + * @return $this */ public function nullOnUpdate(): self { @@ -44,6 +50,8 @@ public function nullOnUpdate(): self /** * Indicate that updates should have "no action". + * + * @return $this */ public function noActionOnUpdate(): self { @@ -52,6 +60,8 @@ public function noActionOnUpdate(): self /** * Indicate that deletes should cascade. + * + * @return $this */ public function cascadeOnDelete(): self { @@ -60,6 +70,8 @@ public function cascadeOnDelete(): self /** * Indicate that deletes should be restricted. + * + * @return $this */ public function restrictOnDelete(): self { @@ -68,6 +80,8 @@ public function restrictOnDelete(): self /** * Indicate that deletes should set the foreign key value to null. + * + * @return $this */ public function nullOnDelete(): self { @@ -76,6 +90,8 @@ public function nullOnDelete(): self /** * Indicate that deletes should have "no action". + * + * @return $this */ public function noActionOnDelete(): self { diff --git a/src/database/src/Schema/SchemaState.php b/src/database/src/Schema/SchemaState.php index 8f3f01896..4edfef552 100644 --- a/src/database/src/Schema/SchemaState.php +++ b/src/database/src/Schema/SchemaState.php @@ -102,6 +102,8 @@ protected function getMigrationTable(): string /** * Specify the name of the application's migration table. + * + * @return $this */ public function withMigrationTable(?string $table): static { @@ -112,6 +114,8 @@ public function withMigrationTable(?string $table): static /** * Specify the callback that should be used to handle process output. + * + * @return $this */ public function handleOutputUsing(callable $output): static { diff --git a/src/database/src/Seeder.php b/src/database/src/Seeder.php index be25fac6a..b507cbba9 100755 --- a/src/database/src/Seeder.php +++ b/src/database/src/Seeder.php @@ -40,6 +40,7 @@ abstract class Seeder implements Transient * Run the given seeder class. * * @param array>|class-string $class + * @return $this */ public function call(array|string $class, bool $silent = false, array $parameters = []): static { @@ -147,6 +148,8 @@ protected function resolve(string $class): Seeder /** * Set the IoC container instance. + * + * @return $this */ public function setContainer(Container $container): static { @@ -157,6 +160,8 @@ public function setContainer(Container $container): static /** * Set the console command instance. + * + * @return $this */ public function setCommand(Command $command): static { diff --git a/src/database/src/UniqueConstraintViolationException.php b/src/database/src/UniqueConstraintViolationException.php index 75112882d..9ef6c68a1 100644 --- a/src/database/src/UniqueConstraintViolationException.php +++ b/src/database/src/UniqueConstraintViolationException.php @@ -20,6 +20,8 @@ class UniqueConstraintViolationException extends QueryException /** * Set the unique index which caused the violation. + * + * @return $this */ public function setIndex(?string $index): self { @@ -32,6 +34,7 @@ public function setIndex(?string $index): self * Set the columns that caused the violation. * * @param list $columns + * @return $this */ public function setColumns(array $columns): self { From 32f478128e6fc93a3b2a587dbf153dfb735faa32 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 138/303] Restore fluent storage and request state configuration Restore receiver identity for filesystem configuration, lockable files, encryption keys, cookies and session handler configuration. The change only describes existing mutation and chaining behavior. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/cookie/src/CookieJar.php | 4 ++++ src/encryption/src/Encrypter.php | 2 ++ src/filesystem/src/FilesystemAdapter.php | 10 ++++++++++ src/filesystem/src/FilesystemManager.php | 5 +++++ src/filesystem/src/LocalFilesystemAdapter.php | 6 ++++++ src/filesystem/src/LockableFile.php | 6 ++++++ src/session/src/DatabaseSessionHandler.php | 8 ++++++++ 7 files changed, 41 insertions(+) diff --git a/src/cookie/src/CookieJar.php b/src/cookie/src/CookieJar.php index c6ce2b801..967affd38 100644 --- a/src/cookie/src/CookieJar.php +++ b/src/cookie/src/CookieJar.php @@ -206,6 +206,8 @@ protected function getPathAndDomain(?string $path, ?string $domain, ?bool $secur * Boot-only. The defaults persist on the worker-lifetime singleton; * per-request mutation races across coroutines and affects every * subsequent request. + * + * @return $this */ public function setDefaultPathAndDomain(string $path, ?string $domain, ?bool $secure = false, ?string $sameSite = null): static { @@ -226,6 +228,8 @@ public function getQueuedCookies(): array /** * Flush the cookies which have been queued for the next request. + * + * @return $this */ public function flushQueuedCookies(): static { diff --git a/src/encryption/src/Encrypter.php b/src/encryption/src/Encrypter.php index fb703bee1..6b6c9bda6 100644 --- a/src/encryption/src/Encrypter.php +++ b/src/encryption/src/Encrypter.php @@ -340,6 +340,8 @@ public function getPreviousKeys(): array * * Boot-only. Mutates the previous-key list; the encrypter is registered as * a container singleton, so per-request use races across coroutines. + * + * @return $this */ public function previousKeys(#[SensitiveParameter] array $keys): static { diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index 814b65e61..064e8fa9c 100644 --- a/src/filesystem/src/FilesystemAdapter.php +++ b/src/filesystem/src/FilesystemAdapter.php @@ -126,6 +126,8 @@ public function __construct( /** * Assert that the given file or directory exists. + * + * @return $this */ public function assertExists(array|string $path, ?string $content = null): static { @@ -155,6 +157,8 @@ public function assertExists(array|string $path, ?string $content = null): stati /** * Assert that the number of files in path equals the expected count. + * + * @return $this */ public function assertCount(string $path, int $count, bool $recursive = false): static { @@ -173,6 +177,8 @@ public function assertCount(string $path, int $count, bool $recursive = false): /** * Assert that the given file or directory does not exist. + * + * @return $this */ public function assertMissing(array|string $path): static { @@ -192,6 +198,8 @@ public function assertMissing(array|string $path): static /** * Assert that the given directory is empty. + * + * @return $this */ public function assertDirectoryEmpty(string $path): static { @@ -205,6 +213,8 @@ public function assertDirectoryEmpty(string $path): static /** * Assert that the disk contains no files. + * + * @return $this */ public function assertEmpty(): static { diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index e19ecd484..80e8552fb 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -990,6 +990,8 @@ protected function createFlysystem(FlysystemAdapter $adapter, array $config): Fi * coroutines may already hold a reference to the prior disk and next * resolution will return the replacement. Any shared pool remains * available until its idle TTL expires or purge() invalidates it. + * + * @return $this */ public function set(UnitEnum|string $name, mixed $disk): static { @@ -1037,6 +1039,7 @@ public function getDefaultDriver(): string * TTL expires or purge() deliberately invalidates them. * * @param array|string|UnitEnum $disk + * @return $this */ public function forgetDisk(UnitEnum|array|string $disk): static { @@ -1109,6 +1112,8 @@ public function extend(string $driver, Closure $callback, bool $poolable = false * * Tests only. Swaps the singleton's container reference; per-request use * races across coroutines and breaks every concurrent filesystem operation. + * + * @return $this */ public function setApplication(Container $app): static { diff --git a/src/filesystem/src/LocalFilesystemAdapter.php b/src/filesystem/src/LocalFilesystemAdapter.php index 709d766d7..6a681e32b 100644 --- a/src/filesystem/src/LocalFilesystemAdapter.php +++ b/src/filesystem/src/LocalFilesystemAdapter.php @@ -146,6 +146,8 @@ private function unsupportedTemporaryUrlException(bool $upload = false): Runtime /** * Specify the name of the disk the adapter is managing. + * + * @return $this */ public function diskName(string $disk): static { @@ -159,6 +161,8 @@ public function diskName(string $disk): static * * Boot-only. The route metadata persists on the cached adapter for the * worker lifetime and affects every subsequent signed URL request. + * + * @return $this */ public function servingRoute(?string $disk, string $prefix = ''): static { @@ -175,6 +179,8 @@ public function servingRoute(?string $disk, string $prefix = ''): static * Boot-only. The flag and resolver persist on the cached local disk adapter * for the worker lifetime and apply to every subsequent signed URL request * for that disk. + * + * @return $this */ public function shouldServeSignedUrls(bool $serve = true, ?Closure $urlGeneratorResolver = null): static { diff --git a/src/filesystem/src/LockableFile.php b/src/filesystem/src/LockableFile.php index f1d637998..d580b7ee9 100644 --- a/src/filesystem/src/LockableFile.php +++ b/src/filesystem/src/LockableFile.php @@ -110,6 +110,8 @@ public function wasDeleted(): bool /** * Write to the file. + * + * @return $this */ public function write(string $contents): static { @@ -132,6 +134,8 @@ public function write(string $contents): static /** * Truncate the file. + * + * @return $this */ public function truncate(): static { @@ -184,6 +188,8 @@ public function getExclusiveLock(bool $block = false): static /** * Release the lock on the file. + * + * @return $this */ public function releaseLock(): static { diff --git a/src/session/src/DatabaseSessionHandler.php b/src/session/src/DatabaseSessionHandler.php index 61da5f9fb..b43c6d870 100644 --- a/src/session/src/DatabaseSessionHandler.php +++ b/src/session/src/DatabaseSessionHandler.php @@ -199,6 +199,8 @@ protected function getDefaultPayload(string $sessionId, string $data): array /** * Add the request information to the session payload. + * + * @return $this */ protected function addRequestInformation(array &$payload): static { @@ -355,6 +357,8 @@ public function connection(): ConnectionInterface * Boot or tests only. Mutating the container on a shared handler during * request handling can expose the wrong request or authentication state * to concurrent coroutines. + * + * @return $this */ public function setContainer(Container $container): static { @@ -365,6 +369,8 @@ public function setContainer(Container $container): static /** * Set the existence state for the session. + * + * @return $this */ public function setExists(bool $value): static { @@ -395,6 +401,8 @@ protected function existenceState(): ?bool /** * Set whether the current session record is expired. + * + * @return $this */ protected function setExpired(bool $value): static { From 6ef062449ee2b81e8973f04f6200b4748fb77d28 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 139/303] Restore fluent application configuration contracts Restore receiver identity across application configuration, exception handling, Vite and testing helpers. Preserve callback return behavior and the existing per-request and worker-lifetime state boundaries. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/foundation/src/Application.php | 14 +++++ .../src/Configuration/ApplicationBuilder.php | 32 ++++++++++ .../src/Configuration/Exceptions.php | 31 +++++++++ .../src/Configuration/Middleware.php | 63 +++++++++++++++++++ src/foundation/src/Console/ClosureCommand.php | 4 ++ src/foundation/src/Console/Kernel.php | 7 +++ src/foundation/src/Events/PublishingStubs.php | 2 + src/foundation/src/Exceptions/Handler.php | 26 ++++++++ src/foundation/src/Http/FormRequest.php | 6 ++ .../PrecognitionControllerDispatcher.php | 2 + .../Concerns/InteractsWithAuthentication.php | 16 +++++ .../Testing/Concerns/InteractsWithConsole.php | 2 + .../Concerns/InteractsWithContainer.php | 4 ++ .../Concerns/InteractsWithDatabase.php | 11 ++++ .../InteractsWithDeprecationHandling.php | 4 ++ .../InteractsWithExceptionHandling.php | 9 +++ .../Testing/Concerns/InteractsWithSession.php | 8 +++ .../Testing/Concerns/MakesHttpRequests.php | 40 ++++++++++++ src/foundation/src/Vite.php | 27 ++++++++ 19 files changed, 308 insertions(+) diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 77b50829f..e1be94cdd 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -429,6 +429,8 @@ public function path(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useAppPath(string $path): static { @@ -460,6 +462,8 @@ public function bootstrapPath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useBootstrapPath(string $path): static { @@ -491,6 +495,8 @@ public function configPath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useConfigPath(string $path): static { @@ -514,6 +520,8 @@ public function databasePath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useDatabasePath(string $path): static { @@ -537,6 +545,8 @@ public function langPath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useLangPath(string $path): static { @@ -560,6 +570,8 @@ public function publicPath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function usePublicPath(string $path): static { @@ -616,6 +628,8 @@ public function storagePath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useStoragePath(string $path): static { diff --git a/src/foundation/src/Configuration/ApplicationBuilder.php b/src/foundation/src/Configuration/ApplicationBuilder.php index 6ef826b47..f7bb0873c 100644 --- a/src/foundation/src/Configuration/ApplicationBuilder.php +++ b/src/foundation/src/Configuration/ApplicationBuilder.php @@ -47,6 +47,8 @@ public function __construct(protected Application $app) * Boot-only. Re-registering the kernels replaces shared bindings and can * discard middleware or console configuration used by subsequent requests * and commands. + * + * @return $this */ public function withKernels(): static { @@ -71,6 +73,8 @@ public function withKernels(): static /** * Register additional service providers. + * + * @return $this */ public function withProviders(array $providers = [], bool $withBootstrapProviders = true): static { @@ -88,6 +92,7 @@ public function withProviders(array $providers = [], bool $withBootstrapProvider * Register the core event service provider for the application. * * @param bool|iterable $discover + * @return $this */ public function withEvents(iterable|bool $discover = true): static { @@ -112,6 +117,8 @@ public function withEvents(iterable|bool $discover = true): static /** * Register the broadcasting services for the application. + * + * @return $this */ public function withBroadcasting(string $channels, array $attributes = []): static { @@ -128,6 +135,8 @@ public function withBroadcasting(string $channels, array $attributes = []): stat /** * Register the routing services for the application. + * + * @return $this */ public function withRouting( ?Closure $using = null, @@ -217,6 +226,8 @@ protected function buildRoutingCallback( /** * Register the global middleware, middleware groups, and middleware aliases for the application. + * + * @return $this */ public function withMiddleware(?callable $callback = null): static { @@ -260,6 +271,8 @@ public function withMiddleware(?callable $callback = null): static /** * Register additional Artisan commands with the application. + * + * @return $this */ public function withCommands(array $commands = []): static { @@ -283,6 +296,8 @@ public function withCommands(array $commands = []): static /** * Register additional Artisan route paths. + * + * @return $this */ protected function withCommandRouting(array $paths): static { @@ -295,6 +310,8 @@ protected function withCommandRouting(array $paths): static /** * Register the scheduled tasks for the application. + * + * @return $this */ public function withSchedule(callable $callback): static { @@ -316,6 +333,7 @@ public function withSchedule(callable $callback): static * in the application container and affect subsequent requests. * * @param null|(callable(Exceptions): mixed) $using + * @return $this */ public function withExceptions(?callable $using = null): static { @@ -337,6 +355,8 @@ public function withExceptions(?callable $using = null): static /** * Register an array of container bindings to be bound when the application is booting. + * + * @return $this */ public function withBindings(array $bindings): static { @@ -349,6 +369,8 @@ public function withBindings(array $bindings): static /** * Register an array of singleton container bindings to be bound when the application is booting. + * + * @return $this */ public function withSingletons(array $singletons): static { @@ -365,6 +387,8 @@ public function withSingletons(array $singletons): static /** * Register an array of scoped singleton container bindings to be bound when the application is booting. + * + * @return $this */ public function withScopedSingletons(array $scopedSingletons): static { @@ -381,6 +405,8 @@ public function withScopedSingletons(array $scopedSingletons): static /** * Globally prefer JSON responses when the incoming "Accept" header is broad. + * + * @return $this */ public function prefersJsonResponses(bool $prefer = true): static { @@ -397,6 +423,8 @@ public function prefersJsonResponses(bool $prefer = true): static /** * Register a callback to be invoked when the application's service providers are registered. + * + * @return $this */ public function registered(callable $callback): static { @@ -407,6 +435,8 @@ public function registered(callable $callback): static /** * Register a callback to be invoked when the application is "booting". + * + * @return $this */ public function booting(callable $callback): static { @@ -417,6 +447,8 @@ public function booting(callable $callback): static /** * Register a callback to be invoked when the application is "booted". + * + * @return $this */ public function booted(callable $callback): static { diff --git a/src/foundation/src/Configuration/Exceptions.php b/src/foundation/src/Configuration/Exceptions.php index 29f88678a..5c80b32e9 100644 --- a/src/foundation/src/Configuration/Exceptions.php +++ b/src/foundation/src/Configuration/Exceptions.php @@ -51,6 +51,8 @@ public function reportable(callable $reportUsing): ReportableHandler * * Boot-only. The callback persists on the shared handler and affects exception * rendering for every subsequent request in the worker. + * + * @return $this */ public function render(callable $using): static { @@ -64,6 +66,8 @@ public function render(callable $using): static * * Boot-only. The callback persists on the shared handler and affects exception * rendering for every subsequent request in the worker. + * + * @return $this */ public function renderable(callable $renderUsing): static { @@ -77,6 +81,8 @@ public function renderable(callable $renderUsing): static * * Boot-only. The callback replaces the shared handler's response callback for * every subsequent request in the worker. + * + * @return $this */ public function respond(callable $using): static { @@ -92,6 +98,7 @@ public function respond(callable $using): static * Callback return values are ignored. * * @param callable(Response, Throwable, Request): mixed $callback + * @return $this */ public function beforeResponding(callable $callback): static { @@ -105,6 +112,8 @@ public function beforeResponding(callable $callback): static * * Boot-only. The callback persists on the shared handler and affects exception * reporting for every subsequent request and job in the worker. + * + * @return $this */ public function throttle(callable $throttleUsing): static { @@ -119,6 +128,8 @@ public function throttle(callable $throttleUsing): static * Boot-only. The mapping persists on the shared handler and affects exception * reporting and rendering for every subsequent request and job in the worker. * + * @return $this + * * @throws InvalidArgumentException */ public function map(Closure|string $from, Closure|string|null $to = null): static @@ -136,6 +147,7 @@ public function map(Closure|string $from, Closure|string|null $to = null): stati * * @param class-string $type * @param LogLevel::* $level + * @return $this */ public function level(string $type, string $level): static { @@ -149,6 +161,8 @@ public function level(string $type, string $level): static * * Boot-only. The closure persists on the shared handler and runs for every * subsequently logged exception in the worker. + * + * @return $this */ public function context(Closure $contextCallback): static { @@ -162,6 +176,8 @@ public function context(Closure $contextCallback): static * * Boot-only. The exception types persist on the shared handler and affect * exception reporting for every subsequent request and job in the worker. + * + * @return $this */ public function dontReport(array|string $class): static { @@ -177,6 +193,8 @@ public function dontReport(array|string $class): static * * Boot-only. The exception types persist on the shared handler and affect * every subsequently failed job in the worker. + * + * @return $this */ public function dontRetry(array|string $class): static { @@ -194,6 +212,7 @@ public function dontRetry(array|string $class): static * reporting for every subsequent request and job in the worker. * * @param (Closure(Throwable): bool) $dontReportWhen + * @return $this */ public function dontReportWhen(Closure $dontReportWhen): static { @@ -211,6 +230,7 @@ public function dontReportWhen(Closure $dontReportWhen): static * @template TException of Throwable * * @param Closure(TException): bool $dontRetryWhen + * @return $this */ public function dontRetryWhen(Closure $dontRetryWhen): static { @@ -224,6 +244,8 @@ public function dontRetryWhen(Closure $dontRetryWhen): static * * Boot-only. The setting persists on the shared handler and applies to every * subsequent request and job in the worker. + * + * @return $this */ public function dontReportDuplicates(): static { @@ -237,6 +259,8 @@ public function dontReportDuplicates(): static * * Boot-only. The attributes persist on the shared handler and are omitted * from every subsequent validation redirect in the worker. + * + * @return $this */ public function dontFlash(array|string $attributes): static { @@ -250,6 +274,8 @@ public function dontFlash(array|string $attributes): static * * Boot-only. The callable replaces the shared handler's JSON check for every * subsequent request in the worker. + * + * @return $this */ public function shouldRenderJsonWhen(callable $callback): static { @@ -265,6 +291,7 @@ public function shouldRenderJsonWhen(callable $callback): static * exception reporting for every subsequent request and job in the worker. * * @param array>|class-string $class + * @return $this */ public function stopIgnoring(array|string $class): static { @@ -278,6 +305,8 @@ public function stopIgnoring(array|string $class): static * * Boot-only. The global default persists for the worker lifetime across all * coroutines; per-request truncation settings take precedence. + * + * @return $this */ public function truncateRequestExceptionsAt(int $length): static { @@ -291,6 +320,8 @@ public function truncateRequestExceptionsAt(int $length): static * * Boot-only. The global default persists for the worker lifetime across all * coroutines; per-request truncation settings take precedence. + * + * @return $this */ public function dontTruncateRequestExceptions(): static { diff --git a/src/foundation/src/Configuration/Middleware.php b/src/foundation/src/Configuration/Middleware.php index 53c2e9432..334c4a31c 100644 --- a/src/foundation/src/Configuration/Middleware.php +++ b/src/foundation/src/Configuration/Middleware.php @@ -113,6 +113,8 @@ class Middleware /** * Prepend middleware to the application's global middleware stack. + * + * @return $this */ public function prepend(array|string $middleware): static { @@ -126,6 +128,8 @@ public function prepend(array|string $middleware): static /** * Append middleware to the application's global middleware stack. + * + * @return $this */ public function append(array|string $middleware): static { @@ -139,6 +143,8 @@ public function append(array|string $middleware): static /** * Remove middleware from the application's global middleware stack. + * + * @return $this */ public function remove(array|string $middleware): static { @@ -152,6 +158,8 @@ public function remove(array|string $middleware): static /** * Specify a middleware that should be replaced with another middleware. + * + * @return $this */ public function replace(string $search, array|string $replace): static { @@ -162,6 +170,8 @@ public function replace(string $search, array|string $replace): static /** * Insert middleware before the given middleware in the global middleware stack. + * + * @return $this */ public function insertBefore(string $search, array|string $middleware): static { @@ -173,6 +183,8 @@ public function insertBefore(string $search, array|string $middleware): static /** * Insert middleware after the given middleware in the global middleware stack. + * + * @return $this */ public function insertAfter(string $search, array|string $middleware): static { @@ -184,6 +196,8 @@ public function insertAfter(string $search, array|string $middleware): static /** * Define the global middleware for the application. + * + * @return $this */ public function use(array $middleware): static { @@ -194,6 +208,8 @@ public function use(array $middleware): static /** * Define a middleware group. + * + * @return $this */ public function group(string $group, array $middleware): static { @@ -204,6 +220,8 @@ public function group(string $group, array $middleware): static /** * Prepend the given middleware to the specified group. + * + * @return $this */ public function prependToGroup(string $group, array|string $middleware): static { @@ -217,6 +235,8 @@ public function prependToGroup(string $group, array|string $middleware): static /** * Append the given middleware to the specified group. + * + * @return $this */ public function appendToGroup(string $group, array|string $middleware): static { @@ -230,6 +250,8 @@ public function appendToGroup(string $group, array|string $middleware): static /** * Remove the given middleware from the specified group. + * + * @return $this */ public function removeFromGroup(string $group, array|string $middleware): static { @@ -243,6 +265,8 @@ public function removeFromGroup(string $group, array|string $middleware): static /** * Replace the given middleware in the specified group with another middleware. + * + * @return $this */ public function replaceInGroup(string $group, string $search, array|string $replace): static { @@ -253,6 +277,8 @@ public function replaceInGroup(string $group, string $search, array|string $repl /** * Insert middleware before the given middleware in the specified group. + * + * @return $this */ public function insertBeforeInGroup(string $group, string $search, array|string $middleware): static { @@ -264,6 +290,8 @@ public function insertBeforeInGroup(string $group, string $search, array|string /** * Insert middleware after the given middleware in the specified group. + * + * @return $this */ public function insertAfterInGroup(string $group, string $search, array|string $middleware): static { @@ -275,6 +303,8 @@ public function insertAfterInGroup(string $group, string $search, array|string $ /** * Modify the middleware in the "web" group. + * + * @return $this */ public function web(array|string $append = [], array|string $prepend = [], array|string $remove = [], array $replace = []): static { @@ -283,6 +313,8 @@ public function web(array|string $append = [], array|string $prepend = [], array /** * Modify the middleware in the "api" group. + * + * @return $this */ public function api(array|string $append = [], array|string $prepend = [], array|string $remove = [], array $replace = []): static { @@ -291,6 +323,8 @@ public function api(array|string $append = [], array|string $prepend = [], array /** * Modify the middleware in the given group. + * + * @return $this */ protected function modifyGroup(string $group, array|string $append, array|string $prepend, array|string $remove, array $replace): static { @@ -319,6 +353,8 @@ protected function modifyGroup(string $group, array|string $append, array|string /** * Register additional middleware aliases. + * + * @return $this */ public function alias(array $aliases): static { @@ -329,6 +365,8 @@ public function alias(array $aliases): static /** * Define the middleware priority for the application. + * + * @return $this */ public function priority(array $priority): static { @@ -339,6 +377,8 @@ public function priority(array $priority): static /** * Prepend middleware to the priority middleware. + * + * @return $this */ public function prependToPriorityList(array|string $before, string $prepend): static { @@ -349,6 +389,8 @@ public function prependToPriorityList(array|string $before, string $prepend): st /** * Append middleware to the priority middleware. + * + * @return $this */ public function appendToPriorityList(array|string $after, string $append): static { @@ -444,6 +486,8 @@ public function getMiddlewareGroups(): array * Boot-only. The callback persists in authentication middleware and * exception static properties for the worker lifetime and affects every * subsequent unauthenticated or session-mismatch request. + * + * @return $this */ public function redirectGuestsTo(callable|string|null $redirect): static { @@ -458,6 +502,8 @@ public function redirectGuestsTo(callable|string|null $redirect): static * Boot-only. The callback persists in the guest middleware static property * for the worker lifetime and affects every subsequent already-authenticated * guest-route request. + * + * @return $this */ public function redirectUsersTo(callable|string $redirect): static { @@ -470,6 +516,8 @@ public function redirectUsersTo(callable|string $redirect): static * Boot-only. The callbacks persist in authentication middleware and * exception static properties for the worker lifetime and affect every * subsequent matching request. + * + * @return $this */ public function redirectTo(callable|string|null $guests = null, callable|string|null $users = null): static { @@ -483,6 +531,7 @@ public function redirectTo(callable|string|null $guests = null, callable|string| * * @param array $except * @param array $only + * @return $this */ public function encryptCookies(array $except = [], array $only = []): static { @@ -499,6 +548,8 @@ public function encryptCookies(array $except = [], array $only = []): static /** * Configure the request forgery prevention middleware. + * + * @return $this */ public function preventRequestForgery(array $except = [], bool $originOnly = false, bool $allowSameSite = false): static { @@ -519,6 +570,7 @@ public function preventRequestForgery(array $except = [], bool $originOnly = fal * Configure the URL signature validation middleware. * * @param array $except + * @return $this */ public function validateSignatures(array $except = []): static { @@ -531,6 +583,7 @@ public function validateSignatures(array $except = []): static * Configure the empty string conversion middleware. * * @param array $except + * @return $this */ public function convertEmptyStringsToNull(array $except = []): static { @@ -543,6 +596,7 @@ public function convertEmptyStringsToNull(array $except = []): static * Configure the string trimming middleware. * * @param array $except + * @return $this */ public function trimStrings(array $except = []): static { @@ -559,6 +613,7 @@ public function trimStrings(array $except = []): static * Indicate that the trusted host middleware should be enabled. * * @param null|array|(callable(): array) $at + * @return $this */ public function trustHosts(array|callable|null $at = null, bool $subdomains = true): static { @@ -575,6 +630,7 @@ public function trustHosts(array|callable|null $at = null, bool $subdomains = tr * Configure the trusted proxies for the application. * * @param null|array|string $at + * @return $this */ public function trustProxies(array|string|null $at = null, ?int $headers = null): static { @@ -593,6 +649,7 @@ public function trustProxies(array|string|null $at = null, ?int $headers = null) * Configure the middleware that prevents requests during maintenance mode. * * @param array $except + * @return $this */ public function preventRequestsDuringMaintenance(array $except = []): static { @@ -603,6 +660,8 @@ public function preventRequestsDuringMaintenance(array $except = []): static /** * Indicate that Sanctum's frontend state middleware should be enabled. + * + * @return $this */ public function statefulApi(): static { @@ -613,6 +672,8 @@ public function statefulApi(): static /** * Indicate that the API middleware group's throttling middleware should be enabled. + * + * @return $this */ public function throttleApi(string $limiter = 'api'): static { @@ -626,6 +687,8 @@ public function throttleApi(string $limiter = 'api'): static /** * Indicate that sessions should be authenticated for the "web" middleware group. + * + * @return $this */ public function authenticateSessions(): static { diff --git a/src/foundation/src/Console/ClosureCommand.php b/src/foundation/src/Console/ClosureCommand.php index f82510f57..59359d55e 100644 --- a/src/foundation/src/Console/ClosureCommand.php +++ b/src/foundation/src/Console/ClosureCommand.php @@ -54,6 +54,8 @@ public function handle(): int /** * Set the description for the command. + * + * @return $this */ public function purpose(string $description): static { @@ -62,6 +64,8 @@ public function purpose(string $description): static /** * Set the description for the command. + * + * @return $this */ public function describe(string $description): static { diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index 9b6115f1b..95a09d7de 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -130,6 +130,7 @@ public function __construct( * Re-route the Symfony command events to their Hypervel counterparts. * * @internal + * @return $this */ public function rerouteSymfonyCommandEvents(): static { @@ -592,6 +593,8 @@ public function setArtisan(?ApplicationContract $artisan): void /** * Set the Artisan commands provided by the application. + * + * @return $this */ public function addCommands(array $commands): static { @@ -606,6 +609,8 @@ public function addCommands(array $commands): static /** * Set the paths that should have their Artisan commands automatically discovered. + * + * @return $this */ public function addCommandPaths(array $paths): static { @@ -616,6 +621,8 @@ public function addCommandPaths(array $paths): static /** * Set the paths that should have their Artisan "routes" automatically discovered. + * + * @return $this */ public function addCommandRoutePaths(array $paths): static { diff --git a/src/foundation/src/Events/PublishingStubs.php b/src/foundation/src/Events/PublishingStubs.php index 42c0396db..4785bab09 100644 --- a/src/foundation/src/Events/PublishingStubs.php +++ b/src/foundation/src/Events/PublishingStubs.php @@ -23,6 +23,8 @@ public function __construct(array $stubs) /** * Add a new stub to be published. + * + * @return $this */ public function add(string $path, string $name): static { diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index 1289039c0..0e913dda0 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -252,6 +252,8 @@ public function reportable(callable $reportUsing): ReportableHandler * * Boot-only. The callback persists on the shared handler and affects exception * rendering for every subsequent request in the worker. + * + * @return $this */ public function renderable(callable $renderUsing): static { @@ -270,6 +272,8 @@ public function renderable(callable $renderUsing): static * Boot-only. The mapping persists on the shared handler and affects exception * reporting and rendering for every subsequent request and job in the worker. * + * @return $this + * * @throws InvalidArgumentException */ public function map(callable|string $from, Closure|string|null $to = null): static @@ -298,6 +302,8 @@ public function map(callable|string $from, Closure|string|null $to = null): stat * * Boot-only. The exception types persist on the shared handler and affect * exception reporting for every subsequent request and job in the worker. + * + * @return $this */ public function dontReport(array|string $exceptions): static { @@ -311,6 +317,7 @@ public function dontReport(array|string $exceptions): static * reporting for every subsequent request and job in the worker. * * @param (callable(Throwable): bool) $dontReportWhen + * @return $this */ public function dontReportWhen(callable $dontReportWhen): static { @@ -328,6 +335,8 @@ public function dontReportWhen(callable $dontReportWhen): static * * Boot-only. The exception types persist on the shared handler and affect * exception reporting for every subsequent request and job in the worker. + * + * @return $this */ public function ignore(array|string $exceptions): static { @@ -343,6 +352,8 @@ public function ignore(array|string $exceptions): static * * Boot-only. The exception types persist on the shared handler and affect * every subsequently failed job in the worker. + * + * @return $this */ public function dontRetry(array|string $exceptions): static { @@ -362,6 +373,7 @@ public function dontRetry(array|string $exceptions): static * @template TException of Throwable * * @param callable(TException): bool $dontRetryWhen + * @return $this */ public function dontRetryWhen(callable $dontRetryWhen): static { @@ -436,6 +448,8 @@ public function shouldStopRetries(Throwable $e): bool * * Boot-only. The attributes persist on the shared handler and are omitted * from every subsequent validation redirect in the worker. + * + * @return $this */ public function dontFlash(array|string $attributes): static { @@ -454,6 +468,7 @@ public function dontFlash(array|string $attributes): static * * @param class-string $type * @param LogLevel::* $level + * @return $this */ public function level(string $type, string $level): static { @@ -709,6 +724,8 @@ protected function throttle(Throwable $e): Lottery|AdmissionPolicy|null * * Boot-only. The callback persists on the shared handler and affects exception * reporting for every subsequent request and job in the worker. + * + * @return $this */ public function throttleUsing(callable $throttleUsing): static { @@ -726,6 +743,8 @@ public function throttleUsing(callable $throttleUsing): static * * Boot-only. The exception lists persist on the shared handler and affect * exception reporting for every subsequent request and job in the worker. + * + * @return $this */ public function stopIgnoring(array|string $exceptions): static { @@ -820,6 +839,7 @@ protected function context(): array * subsequently logged exception in the worker. * * @param Closure(Throwable, array): array $contextCallback + * @return $this */ public function buildContextUsing(Closure $contextCallback): static { @@ -896,6 +916,7 @@ protected function finalizeRenderedResponse(Request $request, SymfonyResponse $r * Callback return values are ignored. * * @param callable(SymfonyResponse, Throwable, Request): mixed $callback + * @return $this */ public function beforeResponding(callable $callback): static { @@ -930,6 +951,8 @@ protected function afterResponseCallbacks(): array * * Boot-only. The callback replaces the shared handler's response callback for * every subsequent request in the worker. + * + * @return $this */ public function respondUsing(callable $callback): static { @@ -1086,6 +1109,7 @@ protected function shouldReturnJson(Request $request, Throwable $e): bool * subsequent request in the worker. * * @param callable(Request $request, Throwable): bool $callback + * @return $this */ public function shouldRenderJsonWhen(callable $callback): static { @@ -1315,6 +1339,8 @@ public function renderForConsole(OutputInterface $output, Throwable $e): void * * Boot-only. The setting persists on the shared handler and applies to every * subsequent request and job in the worker. + * + * @return $this */ public function dontReportDuplicates(): static { diff --git a/src/foundation/src/Http/FormRequest.php b/src/foundation/src/Http/FormRequest.php index 415f835e7..27718a250 100644 --- a/src/foundation/src/Http/FormRequest.php +++ b/src/foundation/src/Http/FormRequest.php @@ -417,6 +417,8 @@ public static function failOnUnknownFields(bool $value = true): void /** * Set the Validator instance. + * + * @return $this */ public function setValidator(Validator $validator): static { @@ -427,6 +429,8 @@ public function setValidator(Validator $validator): static /** * Set the Redirector instance. + * + * @return $this */ public function setRedirector(Redirector $redirector): static { @@ -437,6 +441,8 @@ public function setRedirector(Redirector $redirector): static /** * Set the container implementation. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/foundation/src/Routing/PrecognitionControllerDispatcher.php b/src/foundation/src/Routing/PrecognitionControllerDispatcher.php index 73dc1ec93..c1e07f2eb 100644 --- a/src/foundation/src/Routing/PrecognitionControllerDispatcher.php +++ b/src/foundation/src/Routing/PrecognitionControllerDispatcher.php @@ -25,6 +25,8 @@ public function dispatch(Route $route, mixed $controller, string $method): mixed /** * Ensure that the given method exists on the controller. * + * @return $this + * * @throws RuntimeException */ protected function ensureMethodExists(object $controller, string $method): static diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php b/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php index 3dd3e56f6..1ce2d72ff 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php @@ -10,6 +10,8 @@ trait InteractsWithAuthentication { /** * Set the currently logged in user for the application. + * + * @return $this */ public function actingAs(UserContract $user, ?string $guard = null): static { @@ -18,6 +20,8 @@ public function actingAs(UserContract $user, ?string $guard = null): static /** * Clear the currently logged in user for the application. + * + * @return $this */ public function actingAsGuest(?string $guard = null): static { @@ -33,6 +37,8 @@ public function actingAsGuest(?string $guard = null): static /** * Set the currently logged in user for the application. + * + * @return $this */ public function be(UserContract $user, ?string $guard = null): static { @@ -51,6 +57,8 @@ public function be(UserContract $user, ?string $guard = null): static /** * Assert that the user is authenticated. + * + * @return $this */ public function assertAuthenticated(?string $guard = null): static { @@ -61,6 +69,8 @@ public function assertAuthenticated(?string $guard = null): static /** * Assert that the user is not authenticated. + * + * @return $this */ public function assertGuest(?string $guard = null): static { @@ -79,6 +89,8 @@ protected function isAuthenticated(?string $guard = null): bool /** * Assert that the user is authenticated as the given user. + * + * @return $this */ public function assertAuthenticatedAs(UserContract $user, ?string $guard = null): static { @@ -103,6 +115,8 @@ public function assertAuthenticatedAs(UserContract $user, ?string $guard = null) /** * Assert that the given credentials are valid. + * + * @return $this */ public function assertCredentials(array $credentials, ?string $guard = null): static { @@ -116,6 +130,8 @@ public function assertCredentials(array $credentials, ?string $guard = null): st /** * Assert that the given credentials are invalid. + * + * @return $this */ public function assertInvalidCredentials(array $credentials, ?string $guard = null): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithConsole.php b/src/foundation/src/Testing/Concerns/InteractsWithConsole.php index 91828273f..83a6eb537 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithConsole.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithConsole.php @@ -82,6 +82,8 @@ public function realArtisan(string $command, array $parameters = []): int * * When using this with traits like DatabaseMigrations, call this in setUp() * before parent::setUp() to ensure mock output is never bound. + * + * @return $this */ protected function withoutMockingConsoleOutput(): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithContainer.php b/src/foundation/src/Testing/Concerns/InteractsWithContainer.php index ce96bed04..4411204f2 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithContainer.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithContainer.php @@ -249,6 +249,8 @@ protected function withVite(): static /** * Execute deferred callbacks immediately. + * + * @return $this */ protected function withoutDefer(): static { @@ -273,6 +275,8 @@ public function offsetSet(mixed $offset, mixed $value): void /** * Restore deferred callbacks. + * + * @return $this */ protected function withDefer(): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php index afb039411..b517a42cd 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php @@ -26,6 +26,7 @@ trait InteractsWithDatabase * * @param class-string|iterable|Model|string>|Model|string $table * @param array|list> $data + * @return $this */ protected function assertDatabaseHas(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null): static { @@ -65,6 +66,7 @@ protected function assertDatabaseHas(iterable|Model|string $table, array $data = * * @param class-string|iterable|Model|string>|Model|string $table * @param array|list> $data + * @return $this */ protected function assertDatabaseMissing(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null): static { @@ -104,6 +106,7 @@ protected function assertDatabaseMissing(iterable|Model|string $table, array $da * Assert the count of table entries. * * @param class-string|iterable|string, int>|Model|string $table + * @return $this */ protected function assertDatabaseCount(iterable|Model|string $table, ?int $count = null, UnitEnum|string|null $connection = null): static { @@ -127,6 +130,7 @@ protected function assertDatabaseCount(iterable|Model|string $table, ?int $count * Assert that the given table or tables has no entries. * * @param class-string|iterable|Model|string>|Model|string $table + * @return $this */ protected function assertDatabaseEmpty(iterable|Model|string $table, UnitEnum|string|null $connection = null): static { @@ -151,6 +155,7 @@ protected function assertDatabaseEmpty(iterable|Model|string $table, UnitEnum|st * * @param class-string|iterable|Model|string>|Model|string $table * @param array|list> $data + * @return $this */ protected function assertSoftDeleted(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null, ?string $deletedAtColumn = 'deleted_at'): static { @@ -197,6 +202,7 @@ protected function assertSoftDeleted(iterable|Model|string $table, array $data = * * @param class-string|iterable|Model|string>|Model|string $table * @param array|list> $data + * @return $this */ protected function assertNotSoftDeleted(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null, ?string $deletedAtColumn = 'deleted_at'): static { @@ -242,6 +248,7 @@ protected function assertNotSoftDeleted(iterable|Model|string $table, array $dat * Assert the given model exists in the database. * * @param class-string|iterable|Model|string>|Model|string $model + * @return $this */ protected function assertModelExists(iterable|Model|string $model): static { @@ -252,6 +259,7 @@ protected function assertModelExists(iterable|Model|string $model): static * Assert the given model does not exist in the database. * * @param class-string|iterable|Model|string>|Model|string $model + * @return $this */ protected function assertModelMissing(iterable|Model|string $model): static { @@ -260,6 +268,8 @@ protected function assertModelMissing(iterable|Model|string $model): static /** * Specify the number of database queries that should occur throughout the test. + * + * @return $this */ public function expectsDatabaseQueryCount(int $expected, UnitEnum|string|null $connection = null): static { @@ -388,6 +398,7 @@ protected function newModelFor(Model|string|null $table): ?Model * Seed a given database connection. * * @param class-string|list|string $class + * @return $this */ public function seed(array|string $class = 'Database\Seeders\DatabaseSeeder'): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDeprecationHandling.php b/src/foundation/src/Testing/Concerns/InteractsWithDeprecationHandling.php index 4a8ca3e0f..5e2ad8181 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDeprecationHandling.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDeprecationHandling.php @@ -15,6 +15,8 @@ trait InteractsWithDeprecationHandling /** * Restore deprecation handling. + * + * @return $this */ protected function withDeprecationHandling(): static { @@ -28,6 +30,8 @@ protected function withDeprecationHandling(): static /** * Disable deprecation handling for the test. * + * @return $this + * * @throws ErrorException */ protected function withoutDeprecationHandling(): static diff --git a/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php b/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php index f6853b8e3..5b630f6ac 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php @@ -29,6 +29,8 @@ trait InteractsWithExceptionHandling /** * Restore exception handling. + * + * @return $this */ protected function withExceptionHandling(): static { @@ -47,6 +49,7 @@ protected function withExceptionHandling(): static * Only handle the given exceptions via the exception handler. * * @param list> $exceptions + * @return $this */ protected function handleExceptions(array $exceptions): static { @@ -55,6 +58,8 @@ protected function handleExceptions(array $exceptions): static /** * Only handle validation exceptions via the exception handler. + * + * @return $this */ protected function handleValidationExceptions(): static { @@ -65,6 +70,7 @@ protected function handleValidationExceptions(): static * Disable exception handling for the test. * * @param list> $except + * @return $this */ protected function withoutExceptionHandling(array $except = []): static { @@ -178,6 +184,7 @@ public function afterResponse(callable $callback): void * Assert that the given callback throws an exception with the given message when invoked. * * @param class-string|Closure(Throwable): bool $expectedClass + * @return $this */ protected function assertThrows(Closure $test, string|Closure $expectedClass = Throwable::class, ?string $expectedMessage = null): static { @@ -212,6 +219,8 @@ protected function assertThrows(Closure $test, string|Closure $expectedClass = T /** * Assert that the given callback does not throw an exception. + * + * @return $this */ protected function assertDoesntThrow(Closure $test): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithSession.php b/src/foundation/src/Testing/Concerns/InteractsWithSession.php index 6f15d4f18..605731d0f 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithSession.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithSession.php @@ -8,6 +8,8 @@ trait InteractsWithSession { /** * Set the session to the given array. + * + * @return $this */ public function withSession(array $data): static { @@ -18,6 +20,8 @@ public function withSession(array $data): static /** * Set the session to the given array. + * + * @return $this */ public function session(array $data): static { @@ -33,6 +37,8 @@ public function session(array $data): static /** * Start the session for the application. + * + * @return $this */ protected function startSession(): static { @@ -47,6 +53,8 @@ protected function startSession(): static /** * Flush all of the current session data. + * + * @return $this */ public function flushSession(): static { diff --git a/src/foundation/src/Testing/Concerns/MakesHttpRequests.php b/src/foundation/src/Testing/Concerns/MakesHttpRequests.php index 94e057ce2..b9566f666 100644 --- a/src/foundation/src/Testing/Concerns/MakesHttpRequests.php +++ b/src/foundation/src/Testing/Concerns/MakesHttpRequests.php @@ -74,6 +74,8 @@ trait MakesHttpRequests /** * Define additional headers to be sent with the request. + * + * @return $this */ public function withHeaders(array $headers): static { @@ -84,6 +86,8 @@ public function withHeaders(array $headers): static /** * Add a header to be sent with the request. + * + * @return $this */ public function withHeader(string $name, string $value): static { @@ -94,6 +98,8 @@ public function withHeader(string $name, string $value): static /** * Remove a header from the request. + * + * @return $this */ public function withoutHeader(string $name): static { @@ -104,6 +110,8 @@ public function withoutHeader(string $name): static /** * Remove headers from the request. + * + * @return $this */ public function withoutHeaders(array $headers): static { @@ -116,6 +124,8 @@ public function withoutHeaders(array $headers): static /** * Add an authorization token for the request. + * + * @return $this */ public function withToken(#[SensitiveParameter] string $token, string $type = 'Bearer'): static { @@ -124,6 +134,8 @@ public function withToken(#[SensitiveParameter] string $token, string $type = 'B /** * Add a basic authentication header to the request with the given credentials. + * + * @return $this */ public function withBasicAuth(string $username, #[SensitiveParameter] string $password): static { @@ -132,6 +144,8 @@ public function withBasicAuth(string $username, #[SensitiveParameter] string $pa /** * Remove the authorization token from the request. + * + * @return $this */ public function withoutToken(): static { @@ -140,6 +154,8 @@ public function withoutToken(): static /** * Flush all the configured headers. + * + * @return $this */ public function flushHeaders(): static { @@ -150,6 +166,8 @@ public function flushHeaders(): static /** * Define a set of server variables to be sent with the requests. + * + * @return $this */ public function withServerVariables(array $server): static { @@ -162,6 +180,7 @@ public function withServerVariables(array $server): static * Disable middleware for the test. * * @param null|array|string $middleware + * @return $this */ public function withoutMiddleware($middleware = null): static { @@ -182,6 +201,7 @@ public function withoutMiddleware($middleware = null): static * Enable the given middleware for the test. * * @param null|array|string $middleware + * @return $this */ public function withMiddleware($middleware = null): static { @@ -200,6 +220,8 @@ public function withMiddleware($middleware = null): static /** * Define additional cookies to be sent with the request. + * + * @return $this */ public function withCookies(array $cookies): static { @@ -210,6 +232,8 @@ public function withCookies(array $cookies): static /** * Add a cookie to be sent with the request. + * + * @return $this */ public function withCookie(string $name, string $value): static { @@ -220,6 +244,8 @@ public function withCookie(string $name, string $value): static /** * Define additional cookies that will not be encrypted before sending with the request. + * + * @return $this */ public function withUnencryptedCookies(array $cookies): static { @@ -230,6 +256,8 @@ public function withUnencryptedCookies(array $cookies): static /** * Add a cookie that will not be encrypted before sending with the request. + * + * @return $this */ public function withUnencryptedCookie(string $name, string $value): static { @@ -240,6 +268,8 @@ public function withUnencryptedCookie(string $name, string $value): static /** * Automatically follow any redirects returned from the response. + * + * @return $this */ public function followingRedirects(): static { @@ -250,6 +280,8 @@ public function followingRedirects(): static /** * Include cookies and authorization headers for JSON requests. + * + * @return $this */ public function withCredentials(): static { @@ -260,6 +292,8 @@ public function withCredentials(): static /** * Disable automatic encryption of cookie values. + * + * @return $this */ public function disableCookieEncryption(): static { @@ -270,6 +304,8 @@ public function disableCookieEncryption(): static /** * Set the referer header and previous URL session value in order to simulate a previous request. + * + * @return $this */ public function from(string $url): static { @@ -280,6 +316,8 @@ public function from(string $url): static /** * Set the referer header and previous URL session value from a given route in order to simulate a previous request. + * + * @return $this */ public function fromRoute(BackedEnum|string $name, mixed $parameters = []): static { @@ -288,6 +326,8 @@ public function fromRoute(BackedEnum|string $name, mixed $parameters = []): stat /** * Set the Precognition header to "true". + * + * @return $this */ public function withPrecognition(): static { diff --git a/src/foundation/src/Vite.php b/src/foundation/src/Vite.php index f3edf171d..c94623446 100644 --- a/src/foundation/src/Vite.php +++ b/src/foundation/src/Vite.php @@ -141,6 +141,8 @@ public function useCspNonce(?string $nonce = null): string * * Boot-only. The key is stored on the shared Vite instance and affects * every subsequent asset render by this worker. + * + * @return $this */ public function useIntegrityKey(string|false $key): static { @@ -151,6 +153,8 @@ public function useIntegrityKey(string|false $key): static /** * Set the Vite entry points. + * + * @return $this */ public function withEntryPoints(array $entryPoints): static { @@ -161,6 +165,8 @@ public function withEntryPoints(array $entryPoints): static /** * Merge additional Vite entry points with the current set. + * + * @return $this */ public function mergeEntryPoints(array $entryPoints): static { @@ -177,6 +183,8 @@ public function mergeEntryPoints(array $entryPoints): static * * Boot-only. The filename is stored on the shared Vite instance and affects * every subsequent manifest lookup by this worker. + * + * @return $this */ public function useManifestFilename(string $filename): static { @@ -190,6 +198,8 @@ public function useManifestFilename(string $filename): static * * Boot-only. The resolver is stored on the shared Vite instance and affects * every subsequent asset path generated by this worker. + * + * @return $this */ public function createAssetPathsUsing(?callable $resolver): static { @@ -211,6 +221,8 @@ public function hotFile(): string * * Boot-only. The path is stored on the shared Vite instance and affects * every subsequent asset render by this worker. + * + * @return $this */ public function useHotFile(string $path): static { @@ -224,6 +236,8 @@ public function useHotFile(string $path): static * * Boot-only. The directory is stored on the shared Vite instance and * affects every subsequent asset render by this worker. + * + * @return $this */ public function useBuildDirectory(string $path): static { @@ -239,6 +253,7 @@ public function useBuildDirectory(string $path): static * every subsequent asset render by this worker. * * @param array|(callable(string, string, ?array, ?array): array) $attributes + * @return $this */ public function useScriptTagAttributes(callable|array $attributes): static { @@ -258,6 +273,7 @@ public function useScriptTagAttributes(callable|array $attributes): static * every subsequent asset render by this worker. * * @param array|(callable(string, string, ?array, ?array): array) $attributes + * @return $this */ public function useStyleTagAttributes(callable|array $attributes): static { @@ -277,6 +293,7 @@ public function useStyleTagAttributes(callable|array $attributes): static * every subsequent asset render by this worker. * * @param array|(callable(string, string, ?array, ?array): (array|false))|false $attributes + * @return $this */ public function usePreloadTagAttributes(callable|array|false $attributes): static { @@ -294,6 +311,8 @@ public function usePreloadTagAttributes(callable|array|false $attributes): stati * * Boot-only. The strategy is stored on the shared Vite instance and affects * every subsequent asset render by this worker. + * + * @return $this */ public function prefetch(?int $concurrency = null, string $event = 'load'): static { @@ -309,6 +328,8 @@ public function prefetch(?int $concurrency = null, string $event = 'load'): stat * * Boot-only. The strategy is stored on the shared Vite instance and affects * every subsequent asset render by this worker. + * + * @return $this */ public function useWaterfallPrefetching(?int $concurrency = null): static { @@ -322,6 +343,8 @@ public function useWaterfallPrefetching(?int $concurrency = null): static * * Boot-only. The strategy is stored on the shared Vite instance and affects * every subsequent asset render by this worker. + * + * @return $this */ public function useAggressivePrefetching(): static { @@ -334,6 +357,8 @@ public function useAggressivePrefetching(): static * Boot-only. The strategy is stored on the shared Vite instance and affects * every subsequent asset render by this worker. * + * @return $this + * * @throws Exception */ public function usePrefetchStrategy(?string $strategy, array $config = []): static @@ -1104,6 +1129,8 @@ protected function viteFonts(): ViteFonts * * Boot-only. The filename is stored on the shared Vite instance and affects * every subsequent font manifest lookup by this worker. + * + * @return $this */ public function useFontsManifestFilename(string $filename): static { From 21dbd35b99948bd7e5ad8e7a635e757e65e14c20 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 140/303] Restore fluent HTTP and routing contracts Restore identity annotations for HTTP request and response configuration, resource registration and route constraints. Include pending resource where methods so shared constraint helpers preserve their receiver contract. No native signatures, HTTP behavior or route registration logic changes. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/http/src/Client/Factory.php | 24 +++++ src/http/src/Client/PendingRequest.php | 101 ++++++++++++++++++ src/http/src/Client/Request.php | 8 ++ src/http/src/Client/Response.php | 23 ++++ src/http/src/Client/ResponseSequence.php | 13 +++ src/http/src/Concerns/InteractsWithInput.php | 2 + src/http/src/JsonResponse.php | 2 + src/http/src/RedirectResponse.php | 18 ++++ src/http/src/Resources/Json/JsonResource.php | 2 + .../src/Resources/Json/ResourceCollection.php | 4 + .../Concerns/ResolvesJsonApiElements.php | 6 ++ src/http/src/Testing/File.php | 4 + ...eatesRegularExpressionRouteConstraints.php | 14 +++ .../src/PendingResourceRegistration.php | 12 +++ .../PendingSingletonResourceRegistration.php | 14 +++ src/routing/src/Route.php | 55 ++++++++++ src/routing/src/RouteRegistrar.php | 6 ++ 17 files changed, 308 insertions(+) diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php index 7b7e96665..713bc19c1 100644 --- a/src/http/src/Client/Factory.php +++ b/src/http/src/Client/Factory.php @@ -112,6 +112,8 @@ public function __construct(protected ?Dispatcher $dispatcher = null) * * Boot-only. The middleware persists on the factory for the worker lifetime * and affects every subsequently created pending request. + * + * @return $this */ public function globalMiddleware(callable $middleware): static { @@ -125,6 +127,8 @@ public function globalMiddleware(callable $middleware): static * * Boot-only. The middleware persists on the factory for the worker lifetime * and affects every subsequently created pending request. + * + * @return $this */ public function globalRequestMiddleware(callable $middleware): static { @@ -138,6 +142,8 @@ public function globalRequestMiddleware(callable $middleware): static * * Boot-only. The middleware persists on the factory for the worker lifetime * and affects every subsequently created pending request. + * + * @return $this */ public function globalResponseMiddleware(callable $middleware): static { @@ -151,6 +157,8 @@ public function globalResponseMiddleware(callable $middleware): static * * Boot-only. The options persist on the factory for the worker lifetime * and affect every subsequently created pending request. + * + * @return $this */ public function globalOptions(array|Closure $options): static { @@ -330,6 +338,8 @@ public function sequence(array $responses = []): ResponseSequence * * Tests only. Stubs persist on the factory for the worker lifetime * and affect every subsequently created pending request. + * + * @return $this */ public function fake(array|callable|null $callback = null): static { @@ -394,6 +404,8 @@ public function fakeSequence(string $url = '*'): ResponseSequence * Tests only. The stub persists on the factory for the worker lifetime * and affects every subsequently created pending request. * + * @return $this + * * @throws InvalidArgumentException */ public function stubUrl(string $url, array|callable|int|PromiseInterface|Response|string $callback): static @@ -431,6 +443,8 @@ public function stubUrl(string $url, array|callable|int|PromiseInterface|Respons * * Boot or tests only. The policy persists on the factory for the worker lifetime * and affects every subsequently created pending request. + * + * @return $this */ public function preventStrayRequests(bool $prevent = true): static { @@ -452,6 +466,8 @@ public function preventingStrayRequests(): bool * * Boot or tests only. The policy persists on the factory for the worker lifetime * and affects every subsequently created pending request. + * + * @return $this */ public function allowStrayRequests(?array $only = null): static { @@ -470,6 +486,8 @@ public function allowStrayRequests(?array $only = null): static * * Tests only. Recording persists on the factory for the worker lifetime * and retains every subsequently completed request attempt. + * + * @return $this */ public function record(): static { @@ -653,6 +671,8 @@ public function getGlobalMiddleware(): array * * Boot-only. The connection preset and shared transport handler registry * persist on the factory for the worker lifetime. + * + * @return $this */ public function registerConnection(string $name, array $config = []): static { @@ -778,6 +798,8 @@ public function getConnectionOptions(string $name): array * * Boot-only. Reconfiguration replaces the worker-lifetime preset and * invalidates its shared transport handler for subsequent requests. + * + * @return $this */ public function setConnectionConfig(string $name, array $config): static { @@ -794,6 +816,8 @@ public function setConnectionConfig(string $name, array $config): static * * Boot or tests only. Request-time use forces subsequent requests to * rebuild warmed keep-alive, DNS, and TLS session state. + * + * @return $this */ public function forgetConnectionHandlers(): static { diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index af389d999..d7cfac484 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -304,6 +304,8 @@ static function (Request $request, array $options, PendingRequest $pendingReques /** * Set the base URL for the pending request. + * + * @return $this */ public function baseUrl(string $url): static { @@ -316,6 +318,7 @@ public function baseUrl(string $url): static * Attach a raw body to the request. * * @param null|resource|StreamInterface|string|Stringable $content + * @return $this * * @throws InvalidArgumentException */ @@ -338,6 +341,8 @@ public function withBody(mixed $content, ?string $contentType = 'application/jso /** * Indicate the request contains JSON. + * + * @return $this */ public function asJson(): static { @@ -346,6 +351,8 @@ public function asJson(): static /** * Indicate the request contains form parameters. + * + * @return $this */ public function asForm(): static { @@ -356,6 +363,7 @@ public function asForm(): static * Attach a file to the request. * * @param resource|string $contents + * @return $this */ public function attach( array|string $name, @@ -390,6 +398,8 @@ public function attach( /** * Indicate the request is a multi-part form request. + * + * @return $this */ public function asMultipart(): static { @@ -398,6 +408,8 @@ public function asMultipart(): static /** * Specify the body format of the request. + * + * @return $this */ public function bodyFormat(string $format): static { @@ -408,6 +420,8 @@ public function bodyFormat(string $format): static /** * Set the given query parameters in the request URI. + * + * @return $this */ public function withQueryParameters(array $parameters): static { @@ -420,6 +434,8 @@ public function withQueryParameters(array $parameters): static /** * Specify the request's content type. + * + * @return $this */ public function contentType(string $contentType): static { @@ -430,6 +446,8 @@ public function contentType(string $contentType): static /** * Indicate that JSON should be returned by the server. + * + * @return $this */ public function acceptJson(): static { @@ -438,6 +456,8 @@ public function acceptJson(): static /** * Indicate the type of content that should be returned by the server. + * + * @return $this */ public function accept(string $contentType): static { @@ -446,6 +466,8 @@ public function accept(string $contentType): static /** * Add the given headers to the request. + * + * @return $this */ public function withHeaders(array $headers): static { @@ -458,6 +480,8 @@ public function withHeaders(array $headers): static /** * Add the given header to the request. + * + * @return $this */ public function withHeader(string $name, mixed $value): static { @@ -466,6 +490,8 @@ public function withHeader(string $name, mixed $value): static /** * Replace the given headers on the request. + * + * @return $this */ public function replaceHeaders(array $headers): static { @@ -476,6 +502,8 @@ public function replaceHeaders(array $headers): static /** * Specify the basic authentication username and password for the request. + * + * @return $this */ public function withBasicAuth(string $username, #[SensitiveParameter] string $password): static { @@ -486,6 +514,8 @@ public function withBasicAuth(string $username, #[SensitiveParameter] string $pa /** * Specify the digest authentication username and password for the request. + * + * @return $this */ public function withDigestAuth(string $username, #[SensitiveParameter] string $password): static { @@ -498,6 +528,8 @@ public function withDigestAuth(string $username, #[SensitiveParameter] string $p /** * Specify an authorization token for the request. + * + * @return $this */ public function withToken(#[SensitiveParameter] string $token, string $type = 'Bearer'): static { @@ -508,6 +540,8 @@ public function withToken(#[SensitiveParameter] string $token, string $type = 'B /** * Specify the user agent for the request. + * + * @return $this */ public function withUserAgent(bool|string $userAgent): static { @@ -518,6 +552,8 @@ public function withUserAgent(bool|string $userAgent): static /** * Specify the URL parameters that can be substituted into the request URL. + * + * @return $this */ public function withUrlParameters(array $parameters = []): static { @@ -529,6 +565,8 @@ public function withUrlParameters(array $parameters = []): static /** * Specify a cookie and its attributes for the request. + * + * @return $this */ public function withCookie(SetCookie $cookie): static { @@ -547,6 +585,8 @@ public function withCookie(SetCookie $cookie): static /** * Specify the cookies that should be included with the request. + * + * @return $this */ public function withCookies(array $cookies, string $domain): static { @@ -559,6 +599,8 @@ public function withCookies(array $cookies, string $domain): static /** * Specify the maximum number of redirects to allow. + * + * @return $this */ public function maxRedirects(int $max): static { @@ -575,6 +617,8 @@ public function maxRedirects(int $max): static /** * Indicate that redirects should not be followed. + * + * @return $this */ public function withoutRedirecting(): static { @@ -585,6 +629,8 @@ public function withoutRedirecting(): static /** * Indicate that TLS certificates should not be verified. + * + * @return $this */ public function withoutVerifying(): static { @@ -599,6 +645,8 @@ public function withoutVerifying(): static * The policy checks and pins every physical request, including each redirect, * to the addresses it vetted. Faked requests and clients supplied through * setClient() never reach it. + * + * @return $this */ public function withDestinationPolicy(DestinationPolicy $policy): static { @@ -642,6 +690,7 @@ public function resolveDestination(DestinationPolicy $policy, string $url): Reso * Specify the path where the body of the response should be stored. * * @param resource|StreamInterface|string $to + * @return $this */ public function sink($to): static { @@ -652,6 +701,8 @@ public function sink($to): static /** * Specify the timeout (in seconds) for the request. + * + * @return $this */ public function timeout(float|int $seconds): static { @@ -662,6 +713,8 @@ public function timeout(float|int $seconds): static /** * Specify the connect timeout (in seconds) for the request. + * + * @return $this */ public function connectTimeout(float|int $seconds): static { @@ -675,6 +728,7 @@ public function connectTimeout(float|int $seconds): static * * @param (Closure(int, Throwable): int)|int $sleepMilliseconds * @param null|(callable(null|Throwable, static, null|string): bool) $when + * @return $this */ public function retry( array|int $times, @@ -692,6 +746,8 @@ public function retry( /** * Replace the specified options on the request. + * + * @return $this */ public function withOptions(array $options): static { @@ -703,6 +759,8 @@ public function withOptions(array $options): static /** * Add new middleware the client handler stack. + * + * @return $this */ public function withMiddleware(callable $middleware): static { @@ -713,6 +771,8 @@ public function withMiddleware(callable $middleware): static /** * Prepend new middleware to the client handler stack. + * + * @return $this */ public function prependMiddleware(callable $middleware): static { @@ -723,6 +783,8 @@ public function prependMiddleware(callable $middleware): static /** * Add new request middleware the client handler stack. + * + * @return $this */ public function withRequestMiddleware(callable $middleware): static { @@ -733,6 +795,8 @@ public function withRequestMiddleware(callable $middleware): static /** * Add new response middleware the client handler stack. + * + * @return $this */ public function withResponseMiddleware(callable $middleware): static { @@ -743,6 +807,8 @@ public function withResponseMiddleware(callable $middleware): static /** * Set arbitrary attributes to store with the request. + * + * @return $this */ public function withAttributes(array $attributes): static { @@ -761,6 +827,8 @@ public function attributes(): array /** * Indicate that Telescope should not record this request. + * + * @return $this */ public function withoutTelescope(): static { @@ -771,6 +839,7 @@ public function withoutTelescope(): static * Set Telescope tags for this request. * * @param array $tags + * @return $this */ public function withTelescopeTags(array $tags): static { @@ -779,6 +848,8 @@ public function withTelescopeTags(array $tags): static /** * Indicate that OpenTelemetry should trace this request. + * + * @return $this */ public function withTrace(): static { @@ -787,6 +858,8 @@ public function withTrace(): static /** * Indicate that OpenTelemetry should not trace this request. + * + * @return $this */ public function withoutTrace(): static { @@ -797,6 +870,8 @@ public function withoutTrace(): static * Indicate that trace context should not be automatically added to this request. * * The request may still be traced locally. + * + * @return $this */ public function withoutTracePropagation(): static { @@ -805,6 +880,8 @@ public function withoutTracePropagation(): static /** * Add a new "before sending" callback to the request. + * + * @return $this */ public function beforeSending(callable $callback): static { @@ -817,6 +894,7 @@ public function beforeSending(callable $callback): static * Add a new callback to execute after the response is built. * * @param callable(Response, null|Request): mixed $callback + * @return $this */ public function afterResponse(callable $callback): static { @@ -829,6 +907,7 @@ public function afterResponse(callable $callback): static * Throw an exception if a server or client error occurs. * * @param null|(callable(Response, HttpRequestException): mixed) $callback + * @return $this */ public function throw(?callable $callback = null): static { @@ -841,6 +920,7 @@ public function throw(?callable $callback = null): static * Throw an exception if a server or client error occurred and the given condition evaluates to true. * * @param null|(callable(Response, HttpRequestException): mixed) $callback + * @return $this */ public function throwIf(bool|callable $condition, ?callable $callback = null): static { @@ -855,6 +935,7 @@ public function throwIf(bool|callable $condition, ?callable $callback = null): s * Throw an exception if a server or client error occurred and the given condition evaluates to false. * * @param null|(callable(Response, HttpRequestException): mixed) $callback + * @return $this */ public function throwUnless(bool|callable $condition, ?callable $callback = null): static { @@ -867,6 +948,8 @@ public function throwUnless(bool|callable $condition, ?callable $callback = null /** * Dump the request before sending. + * + * @return $this */ public function dump(): static { @@ -881,6 +964,8 @@ public function dump(): static /** * Dump the request before sending and end the script. + * + * @return $this */ public function dd(): static { @@ -2364,6 +2449,8 @@ protected function runAfterResponseCallbacks(Response $response): Response /** * Register a stub callable that will intercept requests and be able to return stub responses. + * + * @return $this */ public function stub(callable|Collection $callback): static { @@ -2376,6 +2463,8 @@ public function stub(callable|Collection $callback): static /** * Indicate that an exception should be thrown if any request is not faked. + * + * @return $this */ public function preventStrayRequests(bool $prevent = true): static { @@ -2386,6 +2475,8 @@ public function preventStrayRequests(bool $prevent = true): static /** * Allow stray, unfaked requests for specific URL patterns. + * + * @return $this */ public function allowStrayRequests(array $only): static { @@ -2473,6 +2564,8 @@ protected function dispatchConnectionFailedEvent(Request $request, ConnectionExc /** * Indicate that request exceptions should be truncated to the given length. + * + * @return $this */ public function truncateExceptionsAt(int $length): static { @@ -2483,6 +2576,8 @@ public function truncateExceptionsAt(int $length): static /** * Indicate that request exceptions should not be truncated. + * + * @return $this */ public function dontTruncateExceptions(): static { @@ -2542,6 +2637,8 @@ protected function marshalAsyncTransportExceptionWithResponse(Throwable $e, Resp * Set the client instance. * * The client owns its whole handler stack, so destination policies do not apply to it. + * + * @return $this */ public function setClient(ClientInterface $client): static { @@ -2554,6 +2651,8 @@ public function setClient(ClientInterface $client): static * Create a new client instance using the given handler. * * Destination-restricted requests reject custom handlers, since their pins only bind the cURL handler. + * + * @return $this */ public function setHandler(callable $handler): static { @@ -2576,6 +2675,8 @@ public function getOptions(): array /** * Set the pending request connection. + * + * @return $this */ public function connection(string $connection, ?array $config = null): static { diff --git a/src/http/src/Client/Request.php b/src/http/src/Client/Request.php index b245090fd..2016a4132 100644 --- a/src/http/src/Client/Request.php +++ b/src/http/src/Client/Request.php @@ -258,6 +258,8 @@ protected function mediaType(): string * Set the logical data on the request. * * This does not mark the body as decoded, so a final JSON or form body may replace it. + * + * @return $this */ public function withData(array $data): static { @@ -278,6 +280,8 @@ public function query(): array /** * Add query parameters to the request. + * + * @return $this */ public function withQuery(array $query = []): static { @@ -290,6 +294,8 @@ public function withQuery(array $query = []): static /** * Remove query parameters from the request. + * + * @return $this */ public function withoutQuery(array|string $keys = []): static { @@ -312,6 +318,8 @@ public function attributes(): array /** * Set the request's attribute data. + * + * @return $this */ public function setRequestAttributes(array $attributes): static { diff --git a/src/http/src/Client/Response.php b/src/http/src/Client/Response.php index ad90119db..6d92faba1 100644 --- a/src/http/src/Client/Response.php +++ b/src/http/src/Client/Response.php @@ -174,6 +174,8 @@ public function object(?int $flags = null): mixed * The callback will be invoked with the following parameters: * - string $body: The raw response body. * - bool $asObject: Whether JSON objects should be decoded as objects. + * + * @return $this */ public function decodeUsing(?Closure $callback): static { @@ -313,6 +315,8 @@ public function serverError(): bool /** * Execute the given callback if there was a server or client error. + * + * @return $this */ public function onError(callable $callback): static { @@ -341,6 +345,8 @@ public function handlerStats(): array /** * Close the stream and any underlying resources. + * + * @return $this */ public function close(): static { @@ -381,6 +387,7 @@ protected function newRequestException(): RequestException * Throw an exception if a server or client error occurred. * * @param null|(callable(Response, RequestException): mixed) $callback + * @return $this * * @throws RequestException */ @@ -401,6 +408,7 @@ public function throw(?callable $callback = null): static * Throw an exception if a server or client error occurred and the given condition evaluates to true. * * @param null|(callable(Response, RequestException): mixed) $callback + * @return $this * * @throws RequestException */ @@ -413,6 +421,7 @@ public function throwIf(bool|Closure $condition, ?callable $callback = null): st * Throw an exception if a server or client error occurred and the given condition evaluates to false. * * @param null|(callable(Response, RequestException): mixed) $callback + * @return $this * * @throws RequestException */ @@ -424,6 +433,8 @@ public function throwUnless(bool|Closure $condition, ?callable $callback = null) /** * Throw an exception if the response status code matches the given code. * + * @return $this + * * @throws RequestException */ public function throwIfStatus(int|callable $statusCode): static @@ -439,6 +450,8 @@ public function throwIfStatus(int|callable $statusCode): static /** * Throw an exception unless the response status code matches the given code. * + * @return $this + * * @throws RequestException */ public function throwUnlessStatus(int|callable $statusCode): static @@ -453,6 +466,8 @@ public function throwUnlessStatus(int|callable $statusCode): static /** * Throw an exception if the response status code is a 4xx level code. * + * @return $this + * * @throws RequestException */ public function throwIfClientError(): static @@ -463,6 +478,8 @@ public function throwIfClientError(): static /** * Throw an exception if the response status code is a 5xx level code. * + * @return $this + * * @throws RequestException */ public function throwIfServerError(): static @@ -472,6 +489,8 @@ public function throwIfServerError(): static /** * Indicate that request exceptions should be truncated to the given length. + * + * @return $this */ public function truncateExceptionsAt(int $length): static { @@ -482,6 +501,8 @@ public function truncateExceptionsAt(int $length): static /** * Indicate that request exceptions should not be truncated. + * + * @return $this */ public function dontTruncateExceptions(): static { @@ -526,6 +547,8 @@ public function dd(?string $key = null): never /** * Dump the headers from the response. + * + * @return $this */ public function dumpHeaders(): static { diff --git a/src/http/src/Client/ResponseSequence.php b/src/http/src/Client/ResponseSequence.php index e7f0006b7..66128b543 100644 --- a/src/http/src/Client/ResponseSequence.php +++ b/src/http/src/Client/ResponseSequence.php @@ -36,6 +36,7 @@ public function __construct( * Push a response to the sequence. * * @param null|array|resource|StreamInterface|string $body + * @return $this */ public function push(mixed $body = null, int $status = 200, array $headers = []): static { @@ -46,6 +47,8 @@ public function push(mixed $body = null, int $status = 200, array $headers = []) /** * Push a response with the given status code to the sequence. + * + * @return $this */ public function pushStatus(int $status, array $headers = []): static { @@ -56,6 +59,8 @@ public function pushStatus(int $status, array $headers = []): static /** * Push a response with the contents of a file as the body to the sequence. + * + * @return $this */ public function pushFile(string $filePath, int $status = 200, array $headers = []): static { @@ -68,6 +73,8 @@ public function pushFile(string $filePath, int $status = 200, array $headers = [ /** * Push a connection exception to the sequence. + * + * @return $this */ public function pushFailedConnection(?string $message = null): static { @@ -78,6 +85,8 @@ public function pushFailedConnection(?string $message = null): static /** * Push a response to the sequence. + * + * @return $this */ public function pushResponse(mixed $response): static { @@ -88,6 +97,8 @@ public function pushResponse(mixed $response): static /** * Make the sequence return a default response when it is empty. + * + * @return $this */ public function whenEmpty(Closure|PromiseInterface $response): static { @@ -99,6 +110,8 @@ public function whenEmpty(Closure|PromiseInterface $response): static /** * Make the sequence return a default response when it is empty. + * + * @return $this */ public function dontFailWhenEmpty(): static { diff --git a/src/http/src/Concerns/InteractsWithInput.php b/src/http/src/Concerns/InteractsWithInput.php index 597720af8..d59f41b1a 100644 --- a/src/http/src/Concerns/InteractsWithInput.php +++ b/src/http/src/Concerns/InteractsWithInput.php @@ -283,6 +283,8 @@ protected function retrieveItem(string $source, ?string $key, mixed $default): m /** * Dump the items. + * + * @return $this */ public function dump(mixed $keys = []): static { diff --git a/src/http/src/JsonResponse.php b/src/http/src/JsonResponse.php index 304c11e8a..58aa08f06 100755 --- a/src/http/src/JsonResponse.php +++ b/src/http/src/JsonResponse.php @@ -97,6 +97,8 @@ public static function fromJsonString(?string $data = null, int $status = 200, a /** * Set the JSONP callback. + * + * @return $this */ public function withCallback(?string $callback = null): static { diff --git a/src/http/src/RedirectResponse.php b/src/http/src/RedirectResponse.php index 8ffc4fbe4..24bd84f40 100755 --- a/src/http/src/RedirectResponse.php +++ b/src/http/src/RedirectResponse.php @@ -36,6 +36,8 @@ class RedirectResponse extends BaseRedirectResponse implements Responsable /** * Flash a piece of data to the session. + * + * @return $this */ public function with(string|array $key, mixed $value = null): static { @@ -50,6 +52,8 @@ public function with(string|array $key, mixed $value = null): static /** * Flash an array of input to the session. + * + * @return $this */ public function withInput(?array $input = null): static { @@ -80,6 +84,8 @@ protected function removeFilesFromInput(array $input): array /** * Flash only the specified input keys to the session. + * + * @return $this */ public function onlyInput(): static { @@ -88,6 +94,8 @@ public function onlyInput(): static /** * Flash all input except the specified keys to the session. + * + * @return $this */ public function exceptInput(): static { @@ -96,6 +104,8 @@ public function exceptInput(): static /** * Flash a container of errors to the session. + * + * @return $this */ public function withErrors(MessageProvider|array|string $provider, string $key = 'default'): static { @@ -129,6 +139,8 @@ protected function parseErrors(MessageProvider|array|string $provider): MessageB /** * Add a fragment identifier to the URL. + * + * @return $this */ public function withFragment(string $fragment): static { @@ -138,6 +150,8 @@ public function withFragment(string $fragment): static /** * Remove any fragment identifier from the response URL. + * + * @return $this */ public function withoutFragment(): static { @@ -182,6 +196,8 @@ public function getRequest(): ?Request /** * Set the request instance. + * + * @return $this */ public function setRequest(Request $request): static { @@ -200,6 +216,8 @@ public function getSession(): ?SessionStore /** * Set the session store instance. + * + * @return $this */ public function setSession(SessionStore $session): static { diff --git a/src/http/src/Resources/Json/JsonResource.php b/src/http/src/Resources/Json/JsonResource.php index c2db04c36..139cc3b42 100644 --- a/src/http/src/Resources/Json/JsonResource.php +++ b/src/http/src/Resources/Json/JsonResource.php @@ -181,6 +181,8 @@ public function with(Request $request): array /** * Add additional metadata to the resource response. + * + * @return $this */ public function additional(array $data): static { diff --git a/src/http/src/Resources/Json/ResourceCollection.php b/src/http/src/Resources/Json/ResourceCollection.php index dd36ac65e..e811428ae 100644 --- a/src/http/src/Resources/Json/ResourceCollection.php +++ b/src/http/src/Resources/Json/ResourceCollection.php @@ -50,6 +50,8 @@ public function __construct(mixed $resource) /** * Indicate that all current query parameters should be appended to pagination links. + * + * @return $this */ public function preserveQuery(): static { @@ -60,6 +62,8 @@ public function preserveQuery(): static /** * Specify the query string parameters that should be present on pagination links. + * + * @return $this */ public function withQuery(array $query): static { diff --git a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php index 69fc92c58..08f00a6af 100644 --- a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php +++ b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php @@ -704,6 +704,8 @@ protected function resolveResourceMetaInformation(JsonApiRequest $request): arra /** * Indicate that attributes should respect the request's sparse fieldsets. + * + * @return $this */ public function respectFieldsAndIncludesInQueryString(bool $value = true): static { @@ -714,6 +716,8 @@ public function respectFieldsAndIncludesInQueryString(bool $value = true): stati /** * Indicate that attributes should ignore the request's sparse fieldsets. + * + * @return $this */ public function ignoreFieldsAndIncludesInQueryString(): static { @@ -722,6 +726,8 @@ public function ignoreFieldsAndIncludesInQueryString(): static /** * Indicate that relationship should include loaded relationships. + * + * @return $this */ public function includePreviouslyLoadedRelationships(): static { diff --git a/src/http/src/Testing/File.php b/src/http/src/Testing/File.php index 0e6260364..0f65fb69f 100644 --- a/src/http/src/Testing/File.php +++ b/src/http/src/Testing/File.php @@ -75,6 +75,8 @@ public static function image(string $name, int $width = 10, int $height = 10): s /** * Set the "size" of the file in kilobytes. + * + * @return $this */ public function size(int $kilobytes): static { @@ -93,6 +95,8 @@ public function getSize(): int /** * Set the MIME type for the file. + * + * @return $this */ public function mimeType(string $mimeType): static { diff --git a/src/routing/src/CreatesRegularExpressionRouteConstraints.php b/src/routing/src/CreatesRegularExpressionRouteConstraints.php index cc43b57a7..49261edf0 100644 --- a/src/routing/src/CreatesRegularExpressionRouteConstraints.php +++ b/src/routing/src/CreatesRegularExpressionRouteConstraints.php @@ -12,6 +12,8 @@ trait CreatesRegularExpressionRouteConstraints { /** * Specify that the given route parameters must be alphabetic. + * + * @return $this */ public function whereAlpha(array|string $parameters): static { @@ -20,6 +22,8 @@ public function whereAlpha(array|string $parameters): static /** * Specify that the given route parameters must be alphanumeric. + * + * @return $this */ public function whereAlphaNumeric(array|string $parameters): static { @@ -28,6 +32,8 @@ public function whereAlphaNumeric(array|string $parameters): static /** * Specify that the given route parameters must be numeric. + * + * @return $this */ public function whereNumber(array|string $parameters): static { @@ -36,6 +42,8 @@ public function whereNumber(array|string $parameters): static /** * Specify that the given route parameters must be ULIDs. + * + * @return $this */ public function whereUlid(array|string $parameters): static { @@ -44,6 +52,8 @@ public function whereUlid(array|string $parameters): static /** * Specify that the given route parameters must be UUIDs. + * + * @return $this */ public function whereUuid(array|string $parameters): static { @@ -52,6 +62,8 @@ public function whereUuid(array|string $parameters): static /** * Specify that the given route parameters must be one of the given values. + * + * @return $this */ public function whereIn(array|string $parameters, array $values): static { @@ -65,6 +77,8 @@ public function whereIn(array|string $parameters, array $values): static /** * Apply the given regular expression to the given parameters. + * + * @return $this */ protected function assignExpressionToParameters(array|string $parameters, string $expression): static { diff --git a/src/routing/src/PendingResourceRegistration.php b/src/routing/src/PendingResourceRegistration.php index 17d0ce636..40d675a57 100644 --- a/src/routing/src/PendingResourceRegistration.php +++ b/src/routing/src/PendingResourceRegistration.php @@ -135,6 +135,8 @@ public function middleware(mixed $middleware): static /** * Specify middleware that should be added to the specified resource routes. + * + * @return $this */ public function middlewareFor(array|string $methods, array|string $middleware): static { @@ -157,6 +159,8 @@ public function middlewareFor(array|string $methods, array|string $middleware): /** * Specify middleware that should be removed from the resource routes. + * + * @return $this */ public function withoutMiddleware(array|string $middleware): static { @@ -170,6 +174,8 @@ public function withoutMiddleware(array|string $middleware): static /** * Specify middleware that should be removed from the specified resource routes. + * + * @return $this */ public function withoutMiddlewareFor(array|string $methods, array|string $middleware): static { @@ -185,6 +191,8 @@ public function withoutMiddlewareFor(array|string $methods, array|string $middle /** * Add "where" constraints to the resource routes. + * + * @return $this */ public function where(mixed $wheres): static { @@ -195,6 +203,8 @@ public function where(mixed $wheres): static /** * Add metadata to the registered resource routes. + * + * @return $this */ public function metadata(array $metadata): static { @@ -218,6 +228,8 @@ public function shallow(bool $shallow = true): static /** * Define the callable that should be invoked on a missing model exception. + * + * @return $this */ public function missing(callable $callback): static { diff --git a/src/routing/src/PendingSingletonResourceRegistration.php b/src/routing/src/PendingSingletonResourceRegistration.php index 5de0ec9dc..179ef0add 100644 --- a/src/routing/src/PendingSingletonResourceRegistration.php +++ b/src/routing/src/PendingSingletonResourceRegistration.php @@ -70,6 +70,8 @@ public function except(array|string $methods): static /** * Indicate that the resource should have creation and storage routes. + * + * @return $this */ public function creatable(): static { @@ -80,6 +82,8 @@ public function creatable(): static /** * Indicate that the resource should have a deletion route. + * + * @return $this */ public function destroyable(): static { @@ -155,6 +159,8 @@ public function middleware(mixed $middleware): static /** * Specify middleware that should be added to the specified resource routes. + * + * @return $this */ public function middlewareFor(array|string $methods, array|string $middleware): static { @@ -177,6 +183,8 @@ public function middlewareFor(array|string $methods, array|string $middleware): /** * Specify middleware that should be removed from the resource routes. + * + * @return $this */ public function withoutMiddleware(array|string $middleware): static { @@ -190,6 +198,8 @@ public function withoutMiddleware(array|string $middleware): static /** * Specify middleware that should be removed from the specified resource routes. + * + * @return $this */ public function withoutMiddlewareFor(array|string $methods, array|string $middleware): static { @@ -205,6 +215,8 @@ public function withoutMiddlewareFor(array|string $methods, array|string $middle /** * Add "where" constraints to the resource routes. + * + * @return $this */ public function where(mixed $wheres): static { @@ -215,6 +227,8 @@ public function where(mixed $wheres): static /** * Add metadata to the registered singleton resource routes. + * + * @return $this */ public function metadata(array $metadata): static { diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index b3bc060c1..45925295b 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -509,6 +509,8 @@ private function originalParametersContextKey(): string * * Parameters are stored in coroutine Context, not on the Route instance, * because Route objects are cached and shared across coroutines. + * + * @return $this */ public function bind(Request $request): static { @@ -525,6 +527,7 @@ public function bind(Request $request): static * @internal * * @param array $parameters + * @return $this */ public function bindFromCompiledMatch(array $parameters, Request $request): static { @@ -547,6 +550,8 @@ public function bindFromCompiledMatch(array $parameters, Request $request): stat /** * Store the route's current and original parameters for this coroutine. + * + * @return $this */ private function storeParameters(array $parameters): static { @@ -714,6 +719,8 @@ public function bindingFields(): array /** * Set the binding fields for the route. + * + * @return $this */ public function setBindingFields(array $bindingFields): static { @@ -739,6 +746,8 @@ public function parentOfParameter(string $parameter): mixed /** * Allow "trashed" models to be retrieved when resolving implicit model bindings for this route. + * + * @return $this */ public function withTrashed(bool $withTrashed = true): static { @@ -757,6 +766,8 @@ public function allowsTrashedBindings(): bool /** * Set a default value for the route. + * + * @return $this */ public function defaults(string $key, mixed $value): static { @@ -767,6 +778,8 @@ public function defaults(string $key, mixed $value): static /** * Set the default values for the route. + * + * @return $this */ public function setDefaults(array $defaults): static { @@ -777,6 +790,8 @@ public function setDefaults(array $defaults): static /** * Set a regular expression requirement on the route. + * + * @return $this */ public function where(array|string $name, ?string $expression = null): static { @@ -797,6 +812,8 @@ protected function parseWhere(array|string $name, ?string $expression): array /** * Set a list of regular expression requirements on the route. + * + * @return $this */ public function setWheres(array $wheres): static { @@ -809,6 +826,8 @@ public function setWheres(array $wheres): static /** * Mark this route as a fallback route. + * + * @return $this */ public function fallback(): static { @@ -819,6 +838,8 @@ public function fallback(): static /** * Set the fallback value. + * + * @return $this */ public function setFallback(bool $isFallback): static { @@ -930,6 +951,8 @@ public function getPrefix(): ?string /** * Add a prefix to the route URI. + * + * @return $this */ public function prefix(?string $prefix): static { @@ -962,6 +985,8 @@ public function uri(): string /** * Set the URI that the route responds to. + * + * @return $this */ public function setUri(string $uri): static { @@ -993,6 +1018,8 @@ public function getName(): ?string /** * Add or change the route name. * + * @return $this + * * @throws InvalidArgumentException */ public function name(BackedEnum|string $name): static @@ -1027,6 +1054,8 @@ public function named(mixed ...$patterns): bool /** * Set the handler for the route. + * + * @return $this */ public function uses(Closure|array|string $action): static { @@ -1082,6 +1111,8 @@ public function getAction(?string $key = null): mixed /** * Set the action array for the route. + * + * @return $this */ public function setAction(array $action): static { @@ -1132,6 +1163,8 @@ public function getMissing(): ?Closure /** * Define the callable that should be invoked on a missing model exception. + * + * @return $this */ public function missing(Closure $missing): static { @@ -1187,6 +1220,8 @@ public function middleware(array|string|Stringable|null $middleware = null): sta /** * Specify that the "Authorize" / "can" middleware should be applied to the route. + * + * @return $this */ public function can(UnitEnum|string $ability, array|string $models = []): static { @@ -1358,6 +1393,8 @@ protected function attributeProvidedControllerMiddlewareExclusions(string $class /** * Specify middleware that should be removed from the given route. + * + * @return $this */ public function withoutMiddleware(array|string $middleware): static { @@ -1382,6 +1419,8 @@ public function excludedMiddleware(): array /** * Indicate that the route should enforce scoping of multiple implicit Eloquent bindings. + * + * @return $this */ public function scopeBindings(): static { @@ -1392,6 +1431,8 @@ public function scopeBindings(): static /** * Indicate that the route should not enforce scoping of multiple implicit Eloquent bindings. + * + * @return $this */ public function withoutScopedBindings(): static { @@ -1418,6 +1459,8 @@ public function preventsScopedBindings(): bool /** * Specify that the route should not allow concurrent requests from the same session. + * + * @return $this */ public function block(?int $lockSeconds = 10, ?int $waitSeconds = 10): static { @@ -1429,6 +1472,8 @@ public function block(?int $lockSeconds = 10, ?int $waitSeconds = 10): static /** * Specify that the route should allow concurrent requests from the same session. + * + * @return $this */ public function withoutBlocking(): static { @@ -1453,6 +1498,8 @@ public function waitsFor(): ?int /** * Specify that the route should read the session without saving it. + * + * @return $this */ public function readOnlySession(bool $readOnly = true): static { @@ -1471,6 +1518,8 @@ public function hasReadOnlySession(): bool /** * Add metadata to the route. + * + * @return $this */ public function metadata(array $metadata): static { @@ -1496,6 +1545,8 @@ public function getMetadata(?string $key = null, mixed $default = null): mixed /** * Set the metadata for the route, replacing any existing metadata. + * + * @return $this */ public function setMetadata(array $metadata): static { @@ -1577,6 +1628,8 @@ public function getCompiled(): ?CompiledRoute /** * Set the router instance on the route. + * + * @return $this */ public function setRouter(Router $router): static { @@ -1587,6 +1640,8 @@ public function setRouter(Router $router): static /** * Set the container instance on the route. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/routing/src/RouteRegistrar.php b/src/routing/src/RouteRegistrar.php index 82db600b6..592abc59a 100644 --- a/src/routing/src/RouteRegistrar.php +++ b/src/routing/src/RouteRegistrar.php @@ -108,6 +108,8 @@ public function __construct(Router $router) /** * Set the value for a given attribute. * + * @return $this + * * @throws InvalidArgumentException */ public function attribute(string $key, mixed $value): static @@ -191,6 +193,8 @@ public function apiSingleton(string $name, string $controller, array $options = /** * Create a route group with shared attributes. + * + * @return $this */ public function group(Closure|array|string $callback): static { @@ -209,6 +213,8 @@ public function match(array|string $methods, string $uri, callable|array|string| /** * Add metadata to routes registered by the registrar. + * + * @return $this */ public function metadata(array $metadata): static { From 77dac9f8b5f2f5082e2d2b5251d788cce9fd6390 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 141/303] Restore fluent mail and notification contracts Restore receiver identity across mail addressing, attachments, message configuration and notification builders. Attachment delegation preserves the supplied mail object, while arbitrary macro return paths retain their broader contracts. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/mail/src/Attachment.php | 4 + src/mail/src/MailManager.php | 4 + src/mail/src/Mailable.php | 82 +++++++++++++++++++ src/mail/src/Mailables/Content.php | 12 +++ src/mail/src/Mailables/Envelope.php | 20 +++++ src/mail/src/Mailables/Headers.php | 6 ++ src/mail/src/Mailer.php | 2 + src/mail/src/Markdown.php | 2 + src/mail/src/Message.php | 30 +++++++ src/mail/src/PendingMail.php | 8 ++ src/notifications/src/AnonymousNotifiable.php | 2 + src/notifications/src/ChannelManager.php | 2 + .../src/Channels/MailChannel.php | 2 + .../src/Messages/BroadcastMessage.php | 2 + .../src/Messages/MailMessage.php | 35 ++++++++ .../src/Messages/SimpleMessage.php | 26 ++++++ src/notifications/src/Notification.php | 2 + .../src/Messages/SlackAttachment.php | 32 ++++++++ .../src/Messages/SlackAttachmentField.php | 6 ++ .../src/Messages/SlackMessage.php | 26 ++++++ 20 files changed, 305 insertions(+) diff --git a/src/mail/src/Attachment.php b/src/mail/src/Attachment.php index 05166d50d..4f4f1a5d8 100644 --- a/src/mail/src/Attachment.php +++ b/src/mail/src/Attachment.php @@ -131,6 +131,8 @@ protected static function getStorageDisk(?string $disk): Filesystem /** * Set the attached file's filename. + * + * @return $this */ public function as(?string $name): static { @@ -141,6 +143,8 @@ public function as(?string $name): static /** * Set the attached file's MIME type. + * + * @return $this */ public function withMime(string $mime): static { diff --git a/src/mail/src/MailManager.php b/src/mail/src/MailManager.php index 8ed77ffb6..877b62fe4 100644 --- a/src/mail/src/MailManager.php +++ b/src/mail/src/MailManager.php @@ -795,6 +795,8 @@ public function getApplication(): Container * Tests only. Swaps the singleton's container and configuration references * without rebuilding resolved mailers; per-request use races across * coroutines and breaks every concurrent mail send. + * + * @return $this */ public function setApplication(Container $app): static { @@ -809,6 +811,8 @@ public function setApplication(Container $app): static * * Boot or tests only. This is cache-only: pooled transports remain shared * resources until purged or reclaimed by their idle TTL. + * + * @return $this */ public function forgetMailers(): static { diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index 8ac9bcd67..8868dd29b 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -409,6 +409,8 @@ protected function markdownTheme(): string /** * Add the sender to the message. + * + * @return $this */ protected function buildFrom(Message $message): static { @@ -421,6 +423,8 @@ protected function buildFrom(Message $message): static /** * Add all of the recipients to the message. + * + * @return $this */ protected function buildRecipients(Message $message): static { @@ -435,6 +439,8 @@ protected function buildRecipients(Message $message): static /** * Set the subject for the message. + * + * @return $this */ protected function buildSubject(Message $message): static { @@ -449,6 +455,8 @@ protected function buildSubject(Message $message): static /** * Add all of the attachments to the message. + * + * @return $this */ protected function buildAttachments(Message $message): static { @@ -501,6 +509,8 @@ protected function buildDiskAttachments(Message $message): void /** * Add all defined tags to the message. + * + * @return $this */ protected function buildTags(Message $message): static { @@ -515,6 +525,8 @@ protected function buildTags(Message $message): static /** * Add all defined metadata to the message. + * + * @return $this */ protected function buildMetadata(Message $message): static { @@ -529,6 +541,8 @@ protected function buildMetadata(Message $message): static /** * Run the callbacks for the message. + * + * @return $this */ protected function runCallbacks(Message $message): static { @@ -569,6 +583,8 @@ public function priority(int $level = 3): static /** * Set the sender of the message. + * + * @return $this */ public function from(array|object|string $address, ?string $name = null): static { @@ -643,6 +659,8 @@ public function hasBcc(array|object|string $address, ?string $name = null): bool /** * Set the "reply to" address of the message. + * + * @return $this */ public function replyTo(array|object|string $address, ?string $name = null): static { @@ -783,6 +801,8 @@ private function hasEnvelopeRecipient(string $address, ?string $name, string $pr /** * Set the subject of the message. + * + * @return $this */ public function subject(string $subject): static { @@ -802,6 +822,8 @@ public function hasSubject(string $subject): bool /** * Set the Markdown template for the message. + * + * @return $this */ public function markdown(string $view, array $data = []): static { @@ -813,6 +835,8 @@ public function markdown(string $view, array $data = []): static /** * Set the view and view data for the message. + * + * @return $this */ public function view(string $view, array $data = []): static { @@ -824,6 +848,8 @@ public function view(string $view, array $data = []): static /** * Set the rendered HTML content for the message. + * + * @return $this */ public function html(string $html): static { @@ -834,6 +860,8 @@ public function html(string $html): static /** * Set the plain text view for the message. + * + * @return $this */ public function text(string $textView, array $data = []): static { @@ -845,6 +873,8 @@ public function text(string $textView, array $data = []): static /** * Set the view data for the message. + * + * @return $this */ public function with(array|string $key, mixed $value = null): static { @@ -859,6 +889,8 @@ public function with(array|string $key, mixed $value = null): static /** * Attach a file to the message. + * + * @return $this */ public function attach(Attachable|Attachment|string $file, array $options = []): static { @@ -880,6 +912,8 @@ public function attach(Attachable|Attachment|string $file, array $options = []): /** * Attach multiple files to the message. + * + * @return $this */ public function attachMany(array $files): static { @@ -948,6 +982,8 @@ private function hasDeclaredAttachment(Attachment $attachment, array $options = /** * Attach a file to the message from storage. + * + * @return $this */ public function attachFromStorage(string $path, ?string $name = null, array $options = []): static { @@ -956,6 +992,8 @@ public function attachFromStorage(string $path, ?string $name = null, array $opt /** * Attach a file to the message from storage. + * + * @return $this */ public function attachFromStorageDisk(?string $disk, string $path, ?string $name = null, array $options = []): static { @@ -994,6 +1032,8 @@ public function hasAttachmentFromStorageDisk(?string $disk, string $path, ?strin /** * Attach in-memory data as an attachment. + * + * @return $this */ public function attachData(string $data, string $name, array $options = []): static { @@ -1019,6 +1059,8 @@ public function hasAttachedData(string $data, ?string $name, array $options = [] /** * Add a tag header to the message when supported by the underlying transport. + * + * @return $this */ public function tag(string $value): static { @@ -1038,6 +1080,8 @@ public function hasTag(string $value): bool /** * Add a metadata header to the message when supported by the underlying transport. + * + * @return $this */ public function metadata(array|string $key, int|string|null $value = null): static { @@ -1061,6 +1105,8 @@ public function hasMetadata(string $key, int|string $value): bool /** * Assert that the mailable is from the given address. + * + * @return $this */ public function assertFrom(array|object|string $address, ?string $name = null): static { @@ -1079,6 +1125,8 @@ public function assertFrom(array|object|string $address, ?string $name = null): /** * Assert that the mailable has the given recipient. + * + * @return $this */ public function assertTo(array|object|string $address, ?string $name = null): static { @@ -1097,6 +1145,8 @@ public function assertTo(array|object|string $address, ?string $name = null): st /** * Assert that the mailable has the given recipient. + * + * @return $this */ public function assertHasTo(array|object|string $address, ?string $name = null): static { @@ -1105,6 +1155,8 @@ public function assertHasTo(array|object|string $address, ?string $name = null): /** * Assert that the mailable has the given recipient. + * + * @return $this */ public function assertHasCc(array|object|string $address, ?string $name = null): static { @@ -1123,6 +1175,8 @@ public function assertHasCc(array|object|string $address, ?string $name = null): /** * Assert that the mailable has the given recipient. + * + * @return $this */ public function assertHasBcc(array|object|string $address, ?string $name = null): static { @@ -1141,6 +1195,8 @@ public function assertHasBcc(array|object|string $address, ?string $name = null) /** * Assert that the mailable has the given "reply to" address. + * + * @return $this */ public function assertHasReplyTo(array|object|string $address, ?string $name = null): static { @@ -1194,6 +1250,8 @@ private function formatActualRecipients(array $recipients): string /** * Assert that the mailable has the given subject. + * + * @return $this */ public function assertHasSubject(string $subject): static { @@ -1212,6 +1270,8 @@ public function assertHasSubject(string $subject): static /** * Assert that the given text is present in the HTML email body. + * + * @return $this */ public function assertSeeInHtml(string $string, bool $escape = true): static { @@ -1230,6 +1290,8 @@ public function assertSeeInHtml(string $string, bool $escape = true): static /** * Assert that the given text is not present in the HTML email body. + * + * @return $this */ public function assertDontSeeInHtml(string $string, bool $escape = true): static { @@ -1250,6 +1312,7 @@ public function assertDontSeeInHtml(string $string, bool $escape = true): static * Assert that the given text strings are present in order in the HTML email body. * * @param list $strings + * @return $this */ public function assertSeeInOrderInHtml(array $strings, bool $escape = true): static { @@ -1266,6 +1329,8 @@ public function assertSeeInOrderInHtml(array $strings, bool $escape = true): sta /** * Assert that the given text is present in the plain-text email body. + * + * @return $this */ public function assertSeeInText(string $string): static { @@ -1282,6 +1347,8 @@ public function assertSeeInText(string $string): static /** * Assert that the given text is not present in the plain-text email body. + * + * @return $this */ public function assertDontSeeInText(string $string): static { @@ -1300,6 +1367,7 @@ public function assertDontSeeInText(string $string): static * Assert that the given text strings are present in order in the plain-text email body. * * @param list $strings + * @return $this */ public function assertSeeInOrderInText(array $strings): static { @@ -1312,6 +1380,8 @@ public function assertSeeInOrderInText(array $strings): static /** * Assert the mailable has no attachments. + * + * @return $this */ public function assertHasNoAttachments(): static { @@ -1352,6 +1422,8 @@ public function assertHasAttachment(Attachable|Attachment|string $file, array $o /** * Assert the mailable has the given data as an attachment. + * + * @return $this */ public function assertHasAttachedData(string $data, string $name, array $options = []): static { @@ -1367,6 +1439,8 @@ public function assertHasAttachedData(string $data, string $name, array $options /** * Assert the mailable has the given attachment from storage. + * + * @return $this */ public function assertHasAttachmentFromStorage(string $path, ?string $name = null, array $options = []): static { @@ -1382,6 +1456,8 @@ public function assertHasAttachmentFromStorage(string $path, ?string $name = nul /** * Assert the mailable has the given attachment from a specific storage disk. + * + * @return $this */ public function assertHasAttachmentFromStorageDisk(string $disk, string $path, ?string $name = null, array $options = []): static { @@ -1397,6 +1473,8 @@ public function assertHasAttachmentFromStorageDisk(string $disk, string $path, ? /** * Assert that the mailable has the given tag. + * + * @return $this */ public function assertHasTag(string $tag): static { @@ -1414,6 +1492,8 @@ public function assertHasTag(string $tag): static /** * Assert that the mailable has the given metadata. + * + * @return $this */ public function assertHasMetadata(string $key, int|string $value): static { @@ -1625,6 +1705,8 @@ public function mailer(?string $mailer): static /** * Register a callback to be called with the Symfony message instance. + * + * @return $this */ public function withSymfonyMessage(callable $callback): static { diff --git a/src/mail/src/Mailables/Content.php b/src/mail/src/Mailables/Content.php index 5d3632125..f120b1574 100644 --- a/src/mail/src/Mailables/Content.php +++ b/src/mail/src/Mailables/Content.php @@ -32,6 +32,8 @@ public function __construct( /** * Set the view for the message. + * + * @return $this */ public function view(string $view): static { @@ -42,6 +44,8 @@ public function view(string $view): static /** * Set the view for the message. + * + * @return $this */ public function html(string $view): static { @@ -50,6 +54,8 @@ public function html(string $view): static /** * Set the plain text view for the message. + * + * @return $this */ public function text(string $view): static { @@ -60,6 +66,8 @@ public function text(string $view): static /** * Set the Markdown view for the message. + * + * @return $this */ public function markdown(string $view): static { @@ -70,6 +78,8 @@ public function markdown(string $view): static /** * Set the pre-rendered HTML for the message. + * + * @return $this */ public function htmlString(string $html): static { @@ -80,6 +90,8 @@ public function htmlString(string $html): static /** * Add a piece of view data to the message. + * + * @return $this */ public function with(array|string $key, mixed $value = null): static { diff --git a/src/mail/src/Mailables/Envelope.php b/src/mail/src/Mailables/Envelope.php index a4c2e24c2..b470ebd89 100644 --- a/src/mail/src/Mailables/Envelope.php +++ b/src/mail/src/Mailables/Envelope.php @@ -96,6 +96,8 @@ protected function normalizeAddresses(array $addresses): array /** * Specify who the message will be "from". + * + * @return $this */ public function from(Address|string $address, ?string $name = null): static { @@ -106,6 +108,8 @@ public function from(Address|string $address, ?string $name = null): static /** * Add a "to" recipient to the message envelope. + * + * @return $this */ public function to(Address|array|string $address, ?string $name = null): static { @@ -118,6 +122,8 @@ public function to(Address|array|string $address, ?string $name = null): static /** * Add a "cc" recipient to the message envelope. + * + * @return $this */ public function cc(Address|array|string $address, ?string $name = null): static { @@ -130,6 +136,8 @@ public function cc(Address|array|string $address, ?string $name = null): static /** * Add a "bcc" recipient to the message envelope. + * + * @return $this */ public function bcc(Address|array|string $address, ?string $name = null): static { @@ -142,6 +150,8 @@ public function bcc(Address|array|string $address, ?string $name = null): static /** * Add a "reply to" recipient to the message envelope. + * + * @return $this */ public function replyTo(Address|array|string $address, ?string $name = null): static { @@ -154,6 +164,8 @@ public function replyTo(Address|array|string $address, ?string $name = null): st /** * Set the subject of the message. + * + * @return $this */ public function subject(string $subject): static { @@ -164,6 +176,8 @@ public function subject(string $subject): static /** * Add "tags" to the message. + * + * @return $this */ public function tags(array $tags): static { @@ -174,6 +188,8 @@ public function tags(array $tags): static /** * Add a "tag" to the message. + * + * @return $this */ public function tag(string $tag): static { @@ -184,6 +200,8 @@ public function tag(string $tag): static /** * Add metadata to the message. + * + * @return $this */ public function metadata(string $key, int|string $value): static { @@ -194,6 +212,8 @@ public function metadata(string $key, int|string $value): static /** * Add a Symfony Message customization callback to the message. + * + * @return $this */ public function using(Closure $callback): static { diff --git a/src/mail/src/Mailables/Headers.php b/src/mail/src/Mailables/Headers.php index a9b811919..8cd09d3f9 100644 --- a/src/mail/src/Mailables/Headers.php +++ b/src/mail/src/Mailables/Headers.php @@ -28,6 +28,8 @@ public function __construct( /** * Set the message ID. + * + * @return $this */ public function messageId(string $messageId): static { @@ -38,6 +40,8 @@ public function messageId(string $messageId): static /** * Set the message IDs referenced by this message. + * + * @return $this */ public function references(array $references): static { @@ -48,6 +52,8 @@ public function references(array $references): static /** * Set the headers for this message. + * + * @return $this */ public function text(array $text): static { diff --git a/src/mail/src/Mailer.php b/src/mail/src/Mailer.php index b1a2ee2b6..9b5dc3324 100644 --- a/src/mail/src/Mailer.php +++ b/src/mail/src/Mailer.php @@ -540,6 +540,8 @@ public function setSymfonyTransport(TransportInterface $transport): void * * Boot-only. Replaces the queue factory on the shared Mailer; per-request * use races across coroutines. + * + * @return $this */ public function setQueue(QueueFactory $queue): static { diff --git a/src/mail/src/Markdown.php b/src/mail/src/Markdown.php index a4c162746..d236ddf50 100644 --- a/src/mail/src/Markdown.php +++ b/src/mail/src/Markdown.php @@ -251,6 +251,8 @@ public function loadComponentsFrom(array $paths = []): void * * Boot-only. Mutates the shared Markdown renderer's default theme; pass a * per-render theme to render() for message-specific themes. + * + * @return $this */ public function theme(string $theme): static { diff --git a/src/mail/src/Message.php b/src/mail/src/Message.php index 43a03669e..b08282142 100644 --- a/src/mail/src/Message.php +++ b/src/mail/src/Message.php @@ -31,6 +31,8 @@ public function __construct( /** * Add a "from" address to the message. + * + * @return $this */ public function from(array|string $address, ?string $name = null): static { @@ -43,6 +45,8 @@ public function from(array|string $address, ?string $name = null): static /** * Set the "sender" of the message. + * + * @return $this */ public function sender(array|string $address, ?string $name = null): static { @@ -55,6 +59,8 @@ public function sender(array|string $address, ?string $name = null): static /** * Set the "return path" of the message. + * + * @return $this */ public function returnPath(Address|string $address): static { @@ -67,6 +73,8 @@ public function returnPath(Address|string $address): static /** * Add a recipient to the message. + * + * @return $this */ public function to(array|string $address, ?string $name = null, bool $override = false): static { @@ -83,6 +91,8 @@ public function to(array|string $address, ?string $name = null, bool $override = /** * Remove all "to" addresses from the message. + * + * @return $this */ public function forgetTo(): static { @@ -98,6 +108,8 @@ public function forgetTo(): static /** * Add a carbon copy to the message. + * + * @return $this */ public function cc(array|string $address, ?string $name = null, bool $override = false): static { @@ -114,6 +126,8 @@ public function cc(array|string $address, ?string $name = null, bool $override = /** * Remove all carbon copy addresses from the message. + * + * @return $this */ public function forgetCc(): static { @@ -129,6 +143,8 @@ public function forgetCc(): static /** * Add a blind carbon copy to the message. + * + * @return $this */ public function bcc(array|string $address, ?string $name = null, bool $override = false): static { @@ -145,6 +161,8 @@ public function bcc(array|string $address, ?string $name = null, bool $override /** * Remove all of the blind carbon copy addresses from the message. + * + * @return $this */ public function forgetBcc(): static { @@ -160,6 +178,8 @@ public function forgetBcc(): static /** * Add a "reply to" address to the message. + * + * @return $this */ public function replyTo(array|string $address, ?string $name = null): static { @@ -168,6 +188,8 @@ public function replyTo(array|string $address, ?string $name = null): static /** * Add a recipient to the message. + * + * @return $this */ protected function addAddresses(array|string $address, ?string $name, string $type): static { @@ -237,6 +259,7 @@ protected function ensureAddressesAreSafe(array $addresses): array * Add an address debug header for a list of recipients. * * @param Address[] $addresses + * @return $this */ protected function addAddressDebugHeader(string $header, array $addresses): static { @@ -250,6 +273,8 @@ protected function addAddressDebugHeader(string $header, array $addresses): stat /** * Set the subject of the message. + * + * @return $this */ public function subject(string $subject): static { @@ -260,6 +285,8 @@ public function subject(string $subject): static /** * Set the message priority level. + * + * @return $this */ public function priority(int $level): static { @@ -270,6 +297,8 @@ public function priority(int $level): static /** * Attach a file to the message. + * + * @return $this */ public function attach(Attachable|Attachment|string $file, array $options = []): static { @@ -290,6 +319,7 @@ public function attach(Attachable|Attachment|string $file, array $options = []): * Attach in-memory data as an attachment. * * @param resource|string $data + * @return $this */ public function attachData(mixed $data, string $name, array $options = []): static { diff --git a/src/mail/src/PendingMail.php b/src/mail/src/PendingMail.php index 3790a902b..960365c8e 100644 --- a/src/mail/src/PendingMail.php +++ b/src/mail/src/PendingMail.php @@ -45,6 +45,8 @@ public function __construct( /** * Set the locale of the message. + * + * @return $this */ public function locale(?string $locale): static { @@ -55,6 +57,8 @@ public function locale(?string $locale): static /** * Set the recipients of the message. + * + * @return $this */ public function to(mixed $users): static { @@ -69,6 +73,8 @@ public function to(mixed $users): static /** * Set the recipients of the message. + * + * @return $this */ public function cc(mixed $users): static { @@ -79,6 +85,8 @@ public function cc(mixed $users): static /** * Set the recipients of the message. + * + * @return $this */ public function bcc(mixed $users): static { diff --git a/src/notifications/src/AnonymousNotifiable.php b/src/notifications/src/AnonymousNotifiable.php index 5ee194c81..ccb9daab4 100644 --- a/src/notifications/src/AnonymousNotifiable.php +++ b/src/notifications/src/AnonymousNotifiable.php @@ -18,6 +18,8 @@ class AnonymousNotifiable /** * Add routing information to the target. * + * @return $this + * * @throws InvalidArgumentException */ public function route(string $channel, mixed $route): static diff --git a/src/notifications/src/ChannelManager.php b/src/notifications/src/ChannelManager.php index d281672ba..644795824 100644 --- a/src/notifications/src/ChannelManager.php +++ b/src/notifications/src/ChannelManager.php @@ -171,6 +171,8 @@ public function deliverVia(string $channel): void /** * Set the locale of notifications. + * + * @return $this */ public function locale(string $locale): static { diff --git a/src/notifications/src/Channels/MailChannel.php b/src/notifications/src/Channels/MailChannel.php index 6965a37b1..ffb6a3b36 100644 --- a/src/notifications/src/Channels/MailChannel.php +++ b/src/notifications/src/Channels/MailChannel.php @@ -244,6 +244,8 @@ protected function addAttachments(Message $mailMessage, MailMessage $message): v /** * Run the callbacks for the message. + * + * @return $this */ protected function runCallbacks(Message $mailMessage, MailMessage $message): static { diff --git a/src/notifications/src/Messages/BroadcastMessage.php b/src/notifications/src/Messages/BroadcastMessage.php index 2ce05541b..6f0bb6dab 100644 --- a/src/notifications/src/Messages/BroadcastMessage.php +++ b/src/notifications/src/Messages/BroadcastMessage.php @@ -22,6 +22,8 @@ public function __construct( /** * Set the message data. + * + * @return $this */ public function data(array $data): static { diff --git a/src/notifications/src/Messages/MailMessage.php b/src/notifications/src/Messages/MailMessage.php index a366462fb..036067056 100644 --- a/src/notifications/src/Messages/MailMessage.php +++ b/src/notifications/src/Messages/MailMessage.php @@ -91,6 +91,8 @@ class MailMessage extends SimpleMessage implements Renderable /** * Set the view for the mail message. + * + * @return $this */ public function view(array|string $view, array $data = []): static { @@ -104,6 +106,8 @@ public function view(array|string $view, array $data = []): static /** * Set the plain text view for the mail message. + * + * @return $this */ public function text(string $textView, array $data = []): static { @@ -115,6 +119,8 @@ public function text(string $textView, array $data = []): static /** * Set the Markdown template for the notification. + * + * @return $this */ public function markdown(string $view, array $data = []): static { @@ -128,6 +134,8 @@ public function markdown(string $view, array $data = []): static /** * Set the default markdown template. + * + * @return $this */ public function template(string $template): static { @@ -138,6 +146,8 @@ public function template(string $template): static /** * Set the theme to use with the Markdown template. + * + * @return $this */ public function theme(string $theme): static { @@ -148,6 +158,8 @@ public function theme(string $theme): static /** * Set the from address for the mail message. + * + * @return $this */ public function from(string $address, ?string $name = null): static { @@ -158,6 +170,8 @@ public function from(string $address, ?string $name = null): static /** * Set the "reply to" address of the message. + * + * @return $this */ public function replyTo(mixed $address, ?string $name = null): static { @@ -172,6 +186,8 @@ public function replyTo(mixed $address, ?string $name = null): static /** * Set the cc address for the mail message. + * + * @return $this */ public function cc(mixed $address, ?string $name = null): static { @@ -186,6 +202,8 @@ public function cc(mixed $address, ?string $name = null): static /** * Set the bcc address for the mail message. + * + * @return $this */ public function bcc(mixed $address, ?string $name = null): static { @@ -200,6 +218,8 @@ public function bcc(mixed $address, ?string $name = null): static /** * Attach a file to the message. + * + * @return $this */ public function attach(Attachable|Attachment|string $file, array $options = []): static { @@ -220,6 +240,7 @@ public function attach(Attachable|Attachment|string $file, array $options = []): * Attach multiple files to the message. * * @param array $files + * @return $this */ public function attachMany(array $files): static { @@ -236,6 +257,8 @@ public function attachMany(array $files): static /** * Attach in-memory data as an attachment. + * + * @return $this */ public function attachData(string $data, string $name, array $options = []): static { @@ -246,6 +269,8 @@ public function attachData(string $data, string $name, array $options = []): sta /** * Attach a file to the message from storage. + * + * @return $this */ public function attachFromStorage( string $path, @@ -257,6 +282,8 @@ public function attachFromStorage( /** * Attach a file to the message from storage. + * + * @return $this */ public function attachFromStorageDisk( ?string $disk, @@ -279,6 +306,8 @@ public function attachFromStorageDisk( /** * Add a tag header to the message when supported by the underlying transport. + * + * @return $this */ public function tag(string $value): static { @@ -289,6 +318,8 @@ public function tag(string $value): static /** * Add a metadata header to the message when supported by the underlying transport. + * + * @return $this */ public function metadata(string $key, int|string $value): static { @@ -301,6 +332,8 @@ public function metadata(string $key, int|string $value): static * Set the priority of this message. * * The value is an integer where 1 is the highest priority and 5 is the lowest. + * + * @return $this */ public function priority(int $level): static { @@ -362,6 +395,8 @@ public function render(): string /** * Register a callback to be called with the Symfony message instance. + * + * @return $this */ public function withSymfonyMessage(callable $callback): static { diff --git a/src/notifications/src/Messages/SimpleMessage.php b/src/notifications/src/Messages/SimpleMessage.php index f1c284c04..cea851eaa 100644 --- a/src/notifications/src/Messages/SimpleMessage.php +++ b/src/notifications/src/Messages/SimpleMessage.php @@ -56,6 +56,8 @@ class SimpleMessage /** * Indicate that the notification gives information about a successful operation. + * + * @return $this */ public function success(): static { @@ -66,6 +68,8 @@ public function success(): static /** * Indicate that the notification gives information about an error. + * + * @return $this */ public function error(): static { @@ -76,6 +80,8 @@ public function error(): static /** * Set the "level" of the notification (success, error, etc.). + * + * @return $this */ public function level(string $level): static { @@ -86,6 +92,8 @@ public function level(string $level): static /** * Set the subject of the notification. + * + * @return $this */ public function subject(string $subject): static { @@ -96,6 +104,8 @@ public function subject(string $subject): static /** * Set the greeting of the notification. + * + * @return $this */ public function greeting(string $greeting): static { @@ -106,6 +116,8 @@ public function greeting(string $greeting): static /** * Set the salutation of the notification. + * + * @return $this */ public function salutation(string $salutation): static { @@ -116,6 +128,8 @@ public function salutation(string $salutation): static /** * Add a line of text to the notification. + * + * @return $this */ public function line(mixed $line): static { @@ -124,6 +138,8 @@ public function line(mixed $line): static /** * Add a line of text to the notification if the given condition is true. + * + * @return $this */ public function lineIf(bool $boolean, mixed $line): static { @@ -136,6 +152,8 @@ public function lineIf(bool $boolean, mixed $line): static /** * Add lines of text to the notification. + * + * @return $this */ public function lines(iterable $lines): static { @@ -148,6 +166,8 @@ public function lines(iterable $lines): static /** * Add lines of text to the notification if the given condition is true. + * + * @return $this */ public function linesIf(bool $boolean, iterable $lines): static { @@ -160,6 +180,8 @@ public function linesIf(bool $boolean, iterable $lines): static /** * Add a line of text to the notification. + * + * @return $this */ public function with(mixed $line): static { @@ -192,6 +214,8 @@ protected function formatLine(array|Htmlable|string|null $line): Htmlable|string /** * Configure the "call to action" button. + * + * @return $this */ public function action(string $text, string $url): static { @@ -203,6 +227,8 @@ public function action(string $text, string $url): static /** * Set the name of the mailer that should send the notification. + * + * @return $this */ public function mailer(string $mailer): static { diff --git a/src/notifications/src/Notification.php b/src/notifications/src/Notification.php index 1a8c061d9..9f482bbdf 100644 --- a/src/notifications/src/Notification.php +++ b/src/notifications/src/Notification.php @@ -30,6 +30,8 @@ public function broadcastOn(): array /** * Set the locale to send this notification in. + * + * @return $this */ public function locale(string $locale): static { diff --git a/src/slack-notification-channel/src/Messages/SlackAttachment.php b/src/slack-notification-channel/src/Messages/SlackAttachment.php index a6b11e334..4c96efc10 100644 --- a/src/slack-notification-channel/src/Messages/SlackAttachment.php +++ b/src/slack-notification-channel/src/Messages/SlackAttachment.php @@ -105,6 +105,8 @@ class SlackAttachment /** * Set the title of the attachment. + * + * @return $this */ public function title(string $title, ?string $url = null): static { @@ -116,6 +118,8 @@ public function title(string $title, ?string $url = null): static /** * Set the pretext of the attachment. + * + * @return $this */ public function pretext(string $pretext): static { @@ -126,6 +130,8 @@ public function pretext(string $pretext): static /** * Set the content (text) of the attachment. + * + * @return $this */ public function content(string $content): static { @@ -136,6 +142,8 @@ public function content(string $content): static /** * A plain-text summary of the attachment. + * + * @return $this */ public function fallback(string $fallback): static { @@ -146,6 +154,8 @@ public function fallback(string $fallback): static /** * Set the color of the attachment. + * + * @return $this */ public function color(string $color): static { @@ -156,6 +166,8 @@ public function color(string $color): static /** * Add a field to the attachment. + * + * @return $this */ public function field(Closure|string $title, string $content = ''): static { @@ -177,6 +189,8 @@ public function field(Closure|string $title, string $content = ''): static /** * Set the fields of the attachment. + * + * @return $this */ public function fields(array $fields): static { @@ -187,6 +201,8 @@ public function fields(array $fields): static /** * Set the fields containing markdown. + * + * @return $this */ public function markdown(array $fields): static { @@ -197,6 +213,8 @@ public function markdown(array $fields): static /** * Set the image URL. + * + * @return $this */ public function image(string $url): static { @@ -207,6 +225,8 @@ public function image(string $url): static /** * Set the URL to the attachment thumbnail. + * + * @return $this */ public function thumb(string $url): static { @@ -217,6 +237,8 @@ public function thumb(string $url): static /** * Add an action (button) under the attachment. + * + * @return $this */ public function action(string $title, string $url, string $style = ''): static { @@ -232,6 +254,8 @@ public function action(string $title, string $url, string $style = ''): static /** * Set the author of the attachment. + * + * @return $this */ public function author(string $name, ?string $link = null, ?string $icon = null): static { @@ -244,6 +268,8 @@ public function author(string $name, ?string $link = null, ?string $icon = null) /** * Set the footer content. + * + * @return $this */ public function footer(string $footer): static { @@ -254,6 +280,8 @@ public function footer(string $footer): static /** * Set the footer icon. + * + * @return $this */ public function footerIcon(string $icon): static { @@ -264,6 +292,8 @@ public function footerIcon(string $icon): static /** * Set the timestamp a DateTimeInterface, DateInterval, or the number of seconds that should be added to the current time. + * + * @return $this */ public function timestamp(DateInterval|DateTimeInterface|int $timestamp): static { @@ -274,6 +304,8 @@ public function timestamp(DateInterval|DateTimeInterface|int $timestamp): static /** * Set the callback ID. + * + * @return $this */ public function callbackId(string $callbackId): static { diff --git a/src/slack-notification-channel/src/Messages/SlackAttachmentField.php b/src/slack-notification-channel/src/Messages/SlackAttachmentField.php index 81b60868c..ebc753ca7 100644 --- a/src/slack-notification-channel/src/Messages/SlackAttachmentField.php +++ b/src/slack-notification-channel/src/Messages/SlackAttachmentField.php @@ -23,6 +23,8 @@ class SlackAttachmentField /** * Set the title of the field. + * + * @return $this */ public function title(string $title): static { @@ -33,6 +35,8 @@ public function title(string $title): static /** * Set the content of the field. + * + * @return $this */ public function content(string $content): static { @@ -43,6 +47,8 @@ public function content(string $content): static /** * Indicates that the content should not be displayed side-by-side with other fields. + * + * @return $this */ public function long(): static { diff --git a/src/slack-notification-channel/src/Messages/SlackMessage.php b/src/slack-notification-channel/src/Messages/SlackMessage.php index 8c5e76962..b1a4b7471 100644 --- a/src/slack-notification-channel/src/Messages/SlackMessage.php +++ b/src/slack-notification-channel/src/Messages/SlackMessage.php @@ -72,6 +72,8 @@ class SlackMessage /** * Indicate that the notification gives information about an operation. + * + * @return $this */ public function info(): static { @@ -82,6 +84,8 @@ public function info(): static /** * Indicate that the notification gives information about a successful operation. + * + * @return $this */ public function success(): static { @@ -92,6 +96,8 @@ public function success(): static /** * Indicate that the notification gives information about a warning. + * + * @return $this */ public function warning(): static { @@ -102,6 +108,8 @@ public function warning(): static /** * Indicate that the notification gives information about an error. + * + * @return $this */ public function error(): static { @@ -112,6 +120,8 @@ public function error(): static /** * Set a custom username and optional emoji icon for the Slack message. + * + * @return $this */ public function from(string $username, ?string $icon = null): static { @@ -126,6 +136,8 @@ public function from(string $username, ?string $icon = null): static /** * Set a custom image icon the message should use. + * + * @return $this */ public function image(string $image): static { @@ -136,6 +148,8 @@ public function image(string $image): static /** * Set the Slack channel the message should be sent to. + * + * @return $this */ public function to(?string $channel): static { @@ -146,6 +160,8 @@ public function to(?string $channel): static /** * Set the content of the Slack message. + * + * @return $this */ public function content(string $content): static { @@ -156,6 +172,8 @@ public function content(string $content): static /** * Define an attachment for the message. + * + * @return $this */ public function attachment(Closure $callback): static { @@ -185,6 +203,8 @@ public function color(): ?string /** * Find and link channel names and usernames. + * + * @return $this */ public function linkNames(): static { @@ -195,6 +215,8 @@ public function linkNames(): static /** * Unfurl links to rich display. + * + * @return $this */ public function unfurlLinks(bool $unfurlLinks): static { @@ -205,6 +227,8 @@ public function unfurlLinks(bool $unfurlLinks): static /** * Unfurl media to rich display. + * + * @return $this */ public function unfurlMedia(bool $unfurlMedia): static { @@ -215,6 +239,8 @@ public function unfurlMedia(bool $unfurlMedia): static /** * Set additional request options for the Guzzle HTTP client. + * + * @return $this */ public function http(array $options): static { From cde6eef0fbf44d2275b9f9765fc72698a501bcb5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 142/303] Restore fluent process contracts Restore receiver identity for process configuration, fake sequences, signaling and result exception helpers. Keep interface and implementation contracts aligned without changing process execution. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/contracts/src/Process/InvokedProcess.php | 2 ++ src/contracts/src/Process/ProcessResult.php | 4 ++++ src/process/src/Factory.php | 17 +++++++++++++++++ src/process/src/FakeInvokedProcess.php | 4 ++++ src/process/src/FakeProcessDescription.php | 16 ++++++++++++++++ src/process/src/FakeProcessResult.php | 4 ++++ src/process/src/FakeProcessSequence.php | 6 ++++++ src/process/src/InvokedProcess.php | 2 ++ src/process/src/PendingProcess.php | 18 ++++++++++++++++++ src/process/src/ProcessResult.php | 4 ++++ 10 files changed, 77 insertions(+) diff --git a/src/contracts/src/Process/InvokedProcess.php b/src/contracts/src/Process/InvokedProcess.php index 0519465e0..034d484aa 100644 --- a/src/contracts/src/Process/InvokedProcess.php +++ b/src/contracts/src/Process/InvokedProcess.php @@ -18,6 +18,8 @@ public function command(): string; /** * Send a signal to the process. + * + * @return $this */ public function signal(int $signal): static; diff --git a/src/contracts/src/Process/ProcessResult.php b/src/contracts/src/Process/ProcessResult.php index 70abb750c..7763e0021 100644 --- a/src/contracts/src/Process/ProcessResult.php +++ b/src/contracts/src/Process/ProcessResult.php @@ -48,11 +48,15 @@ public function seeInErrorOutput(string $output): bool; /** * Throw an exception if the process failed. + * + * @return $this */ public function throw(?callable $callback = null): static; /** * Throw an exception if the process failed and the given condition is true. + * + * @return $this */ public function throwIf(bool $condition, ?callable $callback = null): static; } diff --git a/src/process/src/Factory.php b/src/process/src/Factory.php index a3610477b..dadd33d9c 100644 --- a/src/process/src/Factory.php +++ b/src/process/src/Factory.php @@ -77,6 +77,8 @@ public function sequence(array $processes = []): FakeProcessSequence * Indicate that the process factory should fake processes. * * Tests only. Fake handlers and recordings persist on the container-resolved factory for the worker lifetime and affect later calls. + * + * @return $this */ public function fake(array|Closure|null $callback = null): static { @@ -113,6 +115,8 @@ public function isRecording(): bool /** * Record the given process if processes should be recorded. + * + * @return $this */ public function recordIfRecording(PendingProcess $process, ProcessResultContract $result): static { @@ -127,6 +131,8 @@ public function recordIfRecording(PendingProcess $process, ProcessResultContract * Record the given process. * * Tests only. Recorded processes are retained on the worker factory and affect later assertions and memory retention. + * + * @return $this */ public function record(PendingProcess $process, ProcessResultContract $result): static { @@ -139,6 +145,8 @@ public function record(PendingProcess $process, ProcessResultContract $result): * Indicate that an exception should be thrown if any process is not faked. * * Tests only. This setting persists on the worker factory and can reject every later unmatched process. + * + * @return $this */ public function preventStrayProcesses(bool $prevent = true): static { @@ -159,6 +167,7 @@ public function preventingStrayProcesses(): bool * Assert that a process was recorded matching a given truth test. * * @param array|Closure|string $callback + * @return $this */ public function assertRan(Closure|array|string $callback): static { @@ -178,6 +187,7 @@ public function assertRan(Closure|array|string $callback): static * Assert that a process was recorded a given number of times matching a given truth test. * * @param array|Closure|string $callback + * @return $this */ public function assertRanTimes(Closure|array|string $callback, int $times = 1): static { @@ -200,6 +210,7 @@ public function assertRanTimes(Closure|array|string $callback, int $times = 1): * Assert that the given processes were run in the given order. * * @param list|Closure|string> $callbacks + * @return $this */ public function assertRanInOrder(array $callbacks): static { @@ -221,6 +232,8 @@ public function assertRanInOrder(array $callbacks): static /** * Assert how many processes have been recorded. + * + * @return $this */ protected function assertRanCount(int $count): static { @@ -233,6 +246,7 @@ protected function assertRanCount(int $count): static * Assert that a process was not recorded matching a given truth test. * * @param array|Closure|string $callback + * @return $this */ public function assertNotRan(Closure|array|string $callback): static { @@ -252,6 +266,7 @@ public function assertNotRan(Closure|array|string $callback): static * Assert that a process was not recorded matching a given truth test. * * @param array|Closure|string $callback + * @return $this */ public function assertDidntRun(Closure|array|string $callback): static { @@ -260,6 +275,8 @@ public function assertDidntRun(Closure|array|string $callback): static /** * Assert that no processes were recorded. + * + * @return $this */ public function assertNothingRan(): static { diff --git a/src/process/src/FakeInvokedProcess.php b/src/process/src/FakeInvokedProcess.php index ae21c3989..87888472a 100644 --- a/src/process/src/FakeInvokedProcess.php +++ b/src/process/src/FakeInvokedProcess.php @@ -74,6 +74,8 @@ public function command(): string /** * Send a signal to the process. + * + * @return $this */ public function signal(int $signal): static { @@ -324,6 +326,8 @@ public function predictProcessResult(): ProcessResultContract /** * Set the general output handler for the fake invoked process. + * + * @return $this */ public function withOutputHandler(?callable $outputHandler): static { diff --git a/src/process/src/FakeProcessDescription.php b/src/process/src/FakeProcessDescription.php index b09fcbbf5..4893ee62c 100644 --- a/src/process/src/FakeProcessDescription.php +++ b/src/process/src/FakeProcessDescription.php @@ -34,6 +34,8 @@ class FakeProcessDescription /** * Specify the process ID that should be assigned to the process. + * + * @return $this */ public function id(int $processId): static { @@ -44,6 +46,8 @@ public function id(int $processId): static /** * Describe a line of standard output. + * + * @return $this */ public function output(array|string $output): static { @@ -60,6 +64,8 @@ public function output(array|string $output): static /** * Describe a line of error output. + * + * @return $this */ public function errorOutput(array|string $output): static { @@ -76,6 +82,8 @@ public function errorOutput(array|string $output): static /** * Replace the entire output buffer with the given string. + * + * @return $this */ public function replaceOutput(string $output): static { @@ -96,6 +104,8 @@ public function replaceOutput(string $output): static /** * Replace the entire error output buffer with the given string. + * + * @return $this */ public function replaceErrorOutput(string $output): static { @@ -116,6 +126,8 @@ public function replaceErrorOutput(string $output): static /** * Specify the process exit code. + * + * @return $this */ public function exitCode(int $exitCode): static { @@ -126,6 +138,8 @@ public function exitCode(int $exitCode): static /** * Specify how many times the "isRunning" method should return "true". + * + * @return $this */ public function iterations(int $iterations): static { @@ -134,6 +148,8 @@ public function iterations(int $iterations): static /** * Specify how many times the "isRunning" method should return "true". + * + * @return $this */ public function runsFor(int $iterations): static { diff --git a/src/process/src/FakeProcessResult.php b/src/process/src/FakeProcessResult.php index 37ab6d090..4e1e53068 100644 --- a/src/process/src/FakeProcessResult.php +++ b/src/process/src/FakeProcessResult.php @@ -132,6 +132,8 @@ public function seeInErrorOutput(string $output): bool /** * Throw an exception if the process failed. * + * @return $this + * * @throws ProcessFailedException */ public function throw(?callable $callback = null): static @@ -152,6 +154,8 @@ public function throw(?callable $callback = null): static /** * Throw an exception if the process failed and the given condition is true. * + * @return $this + * * @throws ProcessFailedException */ public function throwIf(bool $condition, ?callable $callback = null): static diff --git a/src/process/src/FakeProcessSequence.php b/src/process/src/FakeProcessSequence.php index caac3d4da..5ce366788 100644 --- a/src/process/src/FakeProcessSequence.php +++ b/src/process/src/FakeProcessSequence.php @@ -36,6 +36,8 @@ public function __construct(protected array $processes = []) /** * Push a new process result or description onto the sequence. + * + * @return $this */ public function push(array|FakeProcessDescription|ProcessResultContract|string $process): static { @@ -46,6 +48,8 @@ public function push(array|FakeProcessDescription|ProcessResultContract|string $ /** * Make the sequence return a default result when it is empty. + * + * @return $this */ public function whenEmpty(array|FakeProcessDescription|ProcessResultContract|string $process): static { @@ -67,6 +71,8 @@ protected function toProcessResult(array|FakeProcessDescription|ProcessResultCon /** * Make the sequence return a default result when it is empty. + * + * @return $this */ public function dontFailWhenEmpty(): static { diff --git a/src/process/src/InvokedProcess.php b/src/process/src/InvokedProcess.php index 3c7550a49..b6e906c02 100644 --- a/src/process/src/InvokedProcess.php +++ b/src/process/src/InvokedProcess.php @@ -47,6 +47,8 @@ public function command(): string /** * Send a signal to the process. + * + * @return $this */ public function signal(int $signal): static { diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index 24a909b66..78543e35b 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -103,6 +103,8 @@ public function __construct(Factory $factory) /** * Specify the command that will invoke the process. + * + * @return $this */ public function command(array|string $command): static { @@ -113,6 +115,8 @@ public function command(array|string $command): static /** * Specify the working directory of the process. + * + * @return $this */ public function path(string $path): static { @@ -123,6 +127,8 @@ public function path(string $path): static /** * Specify the maximum number of seconds the process may run. + * + * @return $this */ public function timeout(CarbonInterval|int $timeout): static { @@ -133,6 +139,8 @@ public function timeout(CarbonInterval|int $timeout): static /** * Specify the maximum number of seconds a process may go without returning output. + * + * @return $this */ public function idleTimeout(CarbonInterval|int $timeout): static { @@ -143,6 +151,8 @@ public function idleTimeout(CarbonInterval|int $timeout): static /** * Indicate that the process may run forever without timing out. + * + * @return $this */ public function forever(): static { @@ -155,6 +165,7 @@ public function forever(): static * Set the additional environment variables for the process. * * @param array $environment + * @return $this */ public function env(array $environment): static { @@ -167,6 +178,7 @@ public function env(array $environment): static * Set the standard input that should be provided when invoking the process. * * @param null|bool|float|int|resource|string|Traversable $input + * @return $this */ public function input(mixed $input): static { @@ -177,6 +189,8 @@ public function input(mixed $input): static /** * Disable output for the process. + * + * @return $this */ public function quietly(): static { @@ -187,6 +201,8 @@ public function quietly(): static /** * Enable TTY mode for the process. + * + * @return $this */ public function tty(bool $tty = true): static { @@ -199,6 +215,7 @@ public function tty(bool $tty = true): static * Set the "proc_open" options that should be used when invoking the process. * * @param array $options + * @return $this */ public function options(array $options): static { @@ -337,6 +354,7 @@ public function supportsTty(): bool * Specify the fake process result handlers for the pending process. * * @param array $fakeHandlers + * @return $this */ public function withFakeHandlers(array $fakeHandlers): static { diff --git a/src/process/src/ProcessResult.php b/src/process/src/ProcessResult.php index 16bd5f75a..ce00f6b91 100644 --- a/src/process/src/ProcessResult.php +++ b/src/process/src/ProcessResult.php @@ -94,6 +94,8 @@ public function seeInErrorOutput(string $output): bool /** * Throw an exception if the process failed. * + * @return $this + * * @throws ProcessFailedException */ public function throw(?callable $callback = null): static @@ -114,6 +116,8 @@ public function throw(?callable $callback = null): static /** * Throw an exception if the process failed and the given condition is true. * + * @return $this + * * @throws ProcessFailedException */ public function throwIf(bool $condition, ?callable $callback = null): static From 40ee42230ec0cd3f7d595275d0cf70055478f24d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 143/303] Restore fluent testing assertion contracts Restore receiver identity across response assertions, fluent JSON scopes and pending console command expectations. Keep assertStructure untagged because one supported branch creates a separate assertion object. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/testing/src/AssertableJsonString.php | 20 +++ .../src/Concerns/AssertsStatusCodes.php | 50 ++++++ src/testing/src/Fluent/AssertableJson.php | 6 + src/testing/src/Fluent/Concerns/Debugging.php | 2 + src/testing/src/Fluent/Concerns/Has.php | 20 +++ .../src/Fluent/Concerns/Interaction.php | 2 + src/testing/src/Fluent/Concerns/Matching.php | 18 +++ src/testing/src/PendingCommand.php | 41 +++++ src/testing/src/TestResponse.php | 146 ++++++++++++++++++ 9 files changed, 305 insertions(+) diff --git a/src/testing/src/AssertableJsonString.php b/src/testing/src/AssertableJsonString.php index 8251c0efc..9fdee2101 100644 --- a/src/testing/src/AssertableJsonString.php +++ b/src/testing/src/AssertableJsonString.php @@ -77,6 +77,8 @@ public function object(?string $key = null): mixed /** * Assert that the response JSON has the expected count of items at the given key. + * + * @return $this */ public function assertCount(int $count, ?string $key = null): static { @@ -101,6 +103,8 @@ public function assertCount(int $count, ?string $key = null): static /** * Assert that the response has the exact given JSON. + * + * @return $this */ public function assertExact(array $data): static { @@ -118,6 +122,8 @@ public function assertExact(array $data): static /** * Assert that the response has the similar JSON as given. + * + * @return $this */ public function assertSimilar(array $data): static { @@ -133,6 +139,8 @@ public function assertSimilar(array $data): static /** * Assert that the response contains the given JSON fragment. + * + * @return $this */ public function assertFragment(array $data): static { @@ -158,6 +166,8 @@ public function assertFragment(array $data): static /** * Assert that the response does not contain the given JSON fragment. + * + * @return $this */ public function assertMissing(array $data, bool $exact = false): static { @@ -187,6 +197,8 @@ public function assertMissing(array $data, bool $exact = false): static /** * Assert that the response does not contain the exact JSON fragment. + * + * @return $this */ public function assertMissingExact(array $data): static { @@ -213,6 +225,8 @@ public function assertMissingExact(array $data): static /** * Assert that the response does not contain the given path. + * + * @return $this */ public function assertMissingPath(string $path): static { @@ -262,6 +276,8 @@ protected function hasJsonPath(mixed $data, array $segments): bool /** * Assert that the expected value and type exists at the given path in the response. + * + * @return $this */ public function assertPath(string $path, mixed $expect): static { @@ -276,6 +292,8 @@ public function assertPath(string $path, mixed $expect): static /** * Assert that the given path in the response contains all of the expected values without looking at the order. + * + * @return $this */ public function assertPathCanonicalizing(string $path, array $expect): static { @@ -328,6 +346,8 @@ public function assertStructure(?array $structure = null, ?array $responseData = /** * Assert that the response is a superset of the given JSON. + * + * @return $this */ public function assertSubset(array $data, bool $strict = false): static { diff --git a/src/testing/src/Concerns/AssertsStatusCodes.php b/src/testing/src/Concerns/AssertsStatusCodes.php index a5ab36101..9b73c44a3 100644 --- a/src/testing/src/Concerns/AssertsStatusCodes.php +++ b/src/testing/src/Concerns/AssertsStatusCodes.php @@ -10,6 +10,8 @@ trait AssertsStatusCodes { /** * Assert that the response has a 200 "OK" status code. + * + * @return $this */ public function assertOk(): static { @@ -18,6 +20,8 @@ public function assertOk(): static /** * Assert that the response has a 201 "Created" status code. + * + * @return $this */ public function assertCreated(): static { @@ -26,6 +30,8 @@ public function assertCreated(): static /** * Assert that the response has a 202 "Accepted" status code. + * + * @return $this */ public function assertAccepted(): static { @@ -34,6 +40,8 @@ public function assertAccepted(): static /** * Assert that the response has the given status code and no content. + * + * @return $this */ public function assertNoContent(int $status = 204): static { @@ -46,6 +54,8 @@ public function assertNoContent(int $status = 204): static /** * Assert that the response has a 301 "Moved Permanently" status code. + * + * @return $this */ public function assertMovedPermanently(): static { @@ -54,6 +64,8 @@ public function assertMovedPermanently(): static /** * Assert that the response has a 302 "Found" status code. + * + * @return $this */ public function assertFound(): static { @@ -62,6 +74,8 @@ public function assertFound(): static /** * Assert that the response has a 304 "Not Modified" status code. + * + * @return $this */ public function assertNotModified(): static { @@ -70,6 +84,8 @@ public function assertNotModified(): static /** * Assert that the response has a 307 "Temporary Redirect" status code. + * + * @return $this */ public function assertTemporaryRedirect(): static { @@ -78,6 +94,8 @@ public function assertTemporaryRedirect(): static /** * Assert that the response has a 308 "Permanent Redirect" status code. + * + * @return $this */ public function assertPermanentRedirect(): static { @@ -86,6 +104,8 @@ public function assertPermanentRedirect(): static /** * Assert that the response has a 400 "Bad Request" status code. + * + * @return $this */ public function assertBadRequest(): static { @@ -94,6 +114,8 @@ public function assertBadRequest(): static /** * Assert that the response has a 401 "Unauthorized" status code. + * + * @return $this */ public function assertUnauthorized(): static { @@ -102,6 +124,8 @@ public function assertUnauthorized(): static /** * Assert that the response has a 402 "Payment Required" status code. + * + * @return $this */ public function assertPaymentRequired(): static { @@ -110,6 +134,8 @@ public function assertPaymentRequired(): static /** * Assert that the response has a 403 "Forbidden" status code. + * + * @return $this */ public function assertForbidden(): static { @@ -118,6 +144,8 @@ public function assertForbidden(): static /** * Assert that the response has a 404 "Not Found" status code. + * + * @return $this */ public function assertNotFound(): static { @@ -126,6 +154,8 @@ public function assertNotFound(): static /** * Assert that the response has a 405 "Method Not Allowed" status code. + * + * @return $this */ public function assertMethodNotAllowed(): static { @@ -134,6 +164,8 @@ public function assertMethodNotAllowed(): static /** * Assert that the response has a 406 "Not Acceptable" status code. + * + * @return $this */ public function assertNotAcceptable(): static { @@ -142,6 +174,8 @@ public function assertNotAcceptable(): static /** * Assert that the response has a 408 "Request Timeout" status code. + * + * @return $this */ public function assertRequestTimeout(): static { @@ -150,6 +184,8 @@ public function assertRequestTimeout(): static /** * Assert that the response has a 409 "Conflict" status code. + * + * @return $this */ public function assertConflict(): static { @@ -158,6 +194,8 @@ public function assertConflict(): static /** * Assert that the response has a 410 "Gone" status code. + * + * @return $this */ public function assertGone(): static { @@ -166,6 +204,8 @@ public function assertGone(): static /** * Assert that the response has a 415 "Unsupported Media Type" status code. + * + * @return $this */ public function assertUnsupportedMediaType(): static { @@ -174,6 +214,8 @@ public function assertUnsupportedMediaType(): static /** * Assert that the response has a 422 "Unprocessable Content" status code. + * + * @return $this */ public function assertUnprocessable(): static { @@ -182,6 +224,8 @@ public function assertUnprocessable(): static /** * Assert that the response has a 424 "Failed Dependency" status code. + * + * @return $this */ public function assertFailedDependency(): static { @@ -190,6 +234,8 @@ public function assertFailedDependency(): static /** * Assert that the response has a 429 "Too Many Requests" status code. + * + * @return $this */ public function assertTooManyRequests(): static { @@ -198,6 +244,8 @@ public function assertTooManyRequests(): static /** * Assert that the response has a 500 "Internal Server Error" status code. + * + * @return $this */ public function assertInternalServerError(): static { @@ -206,6 +254,8 @@ public function assertInternalServerError(): static /** * Assert that the response has a 503 "Service Unavailable" status code. + * + * @return $this */ public function assertServiceUnavailable(): static { diff --git a/src/testing/src/Fluent/AssertableJson.php b/src/testing/src/Fluent/AssertableJson.php index b53620670..e385be6ae 100644 --- a/src/testing/src/Fluent/AssertableJson.php +++ b/src/testing/src/Fluent/AssertableJson.php @@ -64,6 +64,8 @@ protected function prop(?string $key = null): mixed /** * Instantiate a new "scope" at the path of the given key. + * + * @return $this */ protected function scope(string|int $key, Closure $callback): static { @@ -81,6 +83,8 @@ protected function scope(string|int $key, Closure $callback): static /** * Instantiate a new "scope" on the first child element. + * + * @return $this */ public function first(Closure $callback): static { @@ -104,6 +108,8 @@ public function first(Closure $callback): static /** * Instantiate a new "scope" on each child element. + * + * @return $this */ public function each(Closure $callback): static { diff --git a/src/testing/src/Fluent/Concerns/Debugging.php b/src/testing/src/Fluent/Concerns/Debugging.php index 6079b143d..c967bb227 100644 --- a/src/testing/src/Fluent/Concerns/Debugging.php +++ b/src/testing/src/Fluent/Concerns/Debugging.php @@ -12,6 +12,8 @@ trait Debugging /** * Dump the given props. + * + * @return $this */ public function dump(?string $prop = null): static { diff --git a/src/testing/src/Fluent/Concerns/Has.php b/src/testing/src/Fluent/Concerns/Has.php index 678902c19..b6042002f 100644 --- a/src/testing/src/Fluent/Concerns/Has.php +++ b/src/testing/src/Fluent/Concerns/Has.php @@ -12,6 +12,8 @@ trait Has { /** * Assert that the prop is of the expected size. + * + * @return $this */ public function count(string|int $key, ?int $length = null): static { @@ -40,6 +42,8 @@ public function count(string|int $key, ?int $length = null): static /** * Assert that the prop size is between a given minimum and maximum. + * + * @return $this */ public function countBetween(int|string $min, int|string $max): static { @@ -68,6 +72,8 @@ public function countBetween(int|string $min, int|string $max): static /** * Ensure that the given prop exists. + * + * @return $this */ public function has(string|int $key, int|Closure|null $length = null, ?Closure $callback = null): static { @@ -110,6 +116,8 @@ public function has(string|int $key, int|Closure|null $length = null, ?Closure $ /** * Assert that all of the given props exist. + * + * @return $this */ public function hasAll(array|string $key): static { @@ -128,6 +136,8 @@ public function hasAll(array|string $key): static /** * Assert that at least one of the given props exists. + * + * @return $this */ public function hasAny(array|string $key): static { @@ -147,6 +157,8 @@ public function hasAny(array|string $key): static /** * Assert that none of the given props exist. + * + * @return $this */ public function missingAll(array|string $key): static { @@ -161,6 +173,8 @@ public function missingAll(array|string $key): static /** * Assert that the given prop does not exist. + * + * @return $this */ public function missing(string $key): static { @@ -189,16 +203,22 @@ abstract protected function prop(?string $key = null): mixed; /** * Instantiate a new "scope" at the path of the given key. + * + * @return $this */ abstract protected function scope(string|int $key, Closure $callback): static; /** * Disable the interaction check. + * + * @return $this */ abstract public function etc(): static; /** * Instantiate a new "scope" on the first element. + * + * @return $this */ abstract public function first(Closure $callback): static; } diff --git a/src/testing/src/Fluent/Concerns/Interaction.php b/src/testing/src/Fluent/Concerns/Interaction.php index 14bd7bf17..3bde7b6c6 100644 --- a/src/testing/src/Fluent/Concerns/Interaction.php +++ b/src/testing/src/Fluent/Concerns/Interaction.php @@ -42,6 +42,8 @@ public function interacted(): void /** * Disable the interaction check. + * + * @return $this */ public function etc(): static { diff --git a/src/testing/src/Fluent/Concerns/Matching.php b/src/testing/src/Fluent/Concerns/Matching.php index 338a20cf3..e0572c372 100644 --- a/src/testing/src/Fluent/Concerns/Matching.php +++ b/src/testing/src/Fluent/Concerns/Matching.php @@ -15,6 +15,8 @@ trait Matching { /** * Assert that the property matches the expected value. + * + * @return $this */ public function where(string $key, mixed $expected): static { @@ -49,6 +51,8 @@ public function where(string $key, mixed $expected): static /** * Assert that the property does not match the expected value. + * + * @return $this */ public function whereNot(string $key, mixed $expected): static { @@ -88,6 +92,8 @@ public function whereNot(string $key, mixed $expected): static /** * Assert that the property is null. + * + * @return $this */ public function whereNull(string $key): static { @@ -108,6 +114,8 @@ public function whereNull(string $key): static /** * Assert that the property is not null. + * + * @return $this */ public function whereNotNull(string $key): static { @@ -128,6 +136,8 @@ public function whereNotNull(string $key): static /** * Assert that all properties match their expected values. + * + * @return $this */ public function whereAll(array $bindings): static { @@ -140,6 +150,8 @@ public function whereAll(array $bindings): static /** * Assert that the property is of the expected type. + * + * @return $this */ public function whereType(string $key, array|string $expected): static { @@ -162,6 +174,8 @@ public function whereType(string $key, array|string $expected): static /** * Assert that all properties are of their expected types. + * + * @return $this */ public function whereAllType(array $bindings): static { @@ -174,6 +188,8 @@ public function whereAllType(array $bindings): static /** * Assert that the property contains the expected values. + * + * @return $this */ public function whereContains(string $key, mixed $expected): static { @@ -236,6 +252,8 @@ abstract protected function dotPath(string $key = ''): string; /** * Ensure that the given prop exists. + * + * @return $this */ abstract public function has(string|int $key, int|Closure|null $value = null, ?Closure $scope = null): static; diff --git a/src/testing/src/PendingCommand.php b/src/testing/src/PendingCommand.php index 0b6c271d0..e13dd46d3 100644 --- a/src/testing/src/PendingCommand.php +++ b/src/testing/src/PendingCommand.php @@ -67,6 +67,8 @@ public function __construct( /** * Specify an expected question that will be asked when the command runs. + * + * @return $this */ public function expectsQuestion(string $question, mixed $answer): static { @@ -77,6 +79,8 @@ public function expectsQuestion(string $question, mixed $answer): static /** * Specify an expected confirmation question that will be asked when the command runs. + * + * @return $this */ public function expectsConfirmation(string $question, string $answer = 'no'): static { @@ -85,6 +89,8 @@ public function expectsConfirmation(string $question, string $answer = 'no'): st /** * Specify an expected choice question with expected answers that will be asked/shown when the command runs. + * + * @return $this */ public function expectsChoice(string $question, array|string $answer, array $answers, bool $strict = false): static { @@ -98,6 +104,8 @@ public function expectsChoice(string $question, array|string $answer, array $ans /** * Specify an expected search question with an expected search string, followed by an expected choice question with expected answers. + * + * @return $this */ public function expectsSearch(string $question, array|string $answer, string $search, array $answers): static { @@ -108,6 +116,8 @@ public function expectsSearch(string $question, array|string $answer, string $se /** * Specify output that should be printed when the command runs. + * + * @return $this */ public function expectsOutput(?string $output = null): static { @@ -124,6 +134,8 @@ public function expectsOutput(?string $output = null): static /** * Specify output that should never be printed when the command runs. + * + * @return $this */ public function doesntExpectOutput(?string $output = null): static { @@ -140,6 +152,8 @@ public function doesntExpectOutput(?string $output = null): static /** * Specify that the given string should be contained in the command output. + * + * @return $this */ public function expectsOutputToContain(string $string): static { @@ -150,6 +164,8 @@ public function expectsOutputToContain(string $string): static /** * Specify that the given string shouldn't be contained in the command output. + * + * @return $this */ public function doesntExpectOutputToContain(string $string): static { @@ -160,6 +176,8 @@ public function doesntExpectOutputToContain(string $string): static /** * Specify a table that should be printed when the command runs. + * + * @return $this */ public function expectsTable(array $headers, array|Arrayable $rows, string $tableStyle = 'default', array $columnStyles = []): static { @@ -187,6 +205,8 @@ public function expectsTable(array $headers, array|Arrayable $rows, string $tabl /** * Specify that the given Prompts info message should be contained in the command output. + * + * @return $this */ public function expectsPromptsInfo(string $message): static { @@ -199,6 +219,8 @@ public function expectsPromptsInfo(string $message): static /** * Specify that the given Prompts warning message should be contained in the command output. + * + * @return $this */ public function expectsPromptsWarning(string $message): static { @@ -211,6 +233,8 @@ public function expectsPromptsWarning(string $message): static /** * Specify that the given Prompts error message should be contained in the command output. + * + * @return $this */ public function expectsPromptsError(string $message): static { @@ -223,6 +247,8 @@ public function expectsPromptsError(string $message): static /** * Specify that the given Prompts alert message should be contained in the command output. + * + * @return $this */ public function expectsPromptsAlert(string $message): static { @@ -235,6 +261,8 @@ public function expectsPromptsAlert(string $message): static /** * Specify that the given Prompts intro message should be contained in the command output. + * + * @return $this */ public function expectsPromptsIntro(string $message): static { @@ -247,6 +275,8 @@ public function expectsPromptsIntro(string $message): static /** * Specify that the given Prompts outro message should be contained in the command output. + * + * @return $this */ public function expectsPromptsOutro(string $message): static { @@ -264,6 +294,7 @@ public function expectsPromptsOutro(string $message): static * @param null|array>|Collection> $rows * * @phpstan-param ($rows is null ? list>|Collection> : list>|Collection>) $headers + * @return $this */ public function expectsPromptsTable(array|Collection $headers, array|Collection|null $rows): static { @@ -289,6 +320,8 @@ protected function expectOutputToContainPrompt(BasePrompt $prompt): void /** * Assert that the command has the given exit code. + * + * @return $this */ public function assertExitCode(int $exitCode): static { @@ -299,6 +332,8 @@ public function assertExitCode(int $exitCode): static /** * Assert that the command does not have the given exit code. + * + * @return $this */ public function assertNotExitCode(int $exitCode): static { @@ -309,6 +344,8 @@ public function assertNotExitCode(int $exitCode): static /** * Assert that the command has the success exit code. + * + * @return $this */ public function assertSuccessful(): static { @@ -317,6 +354,8 @@ public function assertSuccessful(): static /** * Assert that the command has the success exit code. + * + * @return $this */ public function assertOk(): static { @@ -325,6 +364,8 @@ public function assertOk(): static /** * Assert that the command does not have the success exit code. + * + * @return $this */ public function assertFailed(): static { diff --git a/src/testing/src/TestResponse.php b/src/testing/src/TestResponse.php index cc037b60c..eb5dfb763 100644 --- a/src/testing/src/TestResponse.php +++ b/src/testing/src/TestResponse.php @@ -105,6 +105,8 @@ public static function fromBaseResponse($response, ?Request $request = null): st /** * Assert that the response has a successful status code. + * + * @return $this */ public function assertSuccessful(): static { @@ -118,6 +120,8 @@ public function assertSuccessful(): static /** * Assert that the Precognition request was successful. + * + * @return $this */ public function assertSuccessfulPrecognition(): static { @@ -139,6 +143,8 @@ public function assertSuccessfulPrecognition(): static /** * Assert that the response is a client error. + * + * @return $this */ public function assertClientError(): static { @@ -152,6 +158,8 @@ public function assertClientError(): static /** * Assert that the response is a server error. + * + * @return $this */ public function assertServerError(): static { @@ -165,6 +173,8 @@ public function assertServerError(): static /** * Assert that the response has the given status code. + * + * @return $this */ public function assertStatus(int $status): static { @@ -185,6 +195,8 @@ protected function statusMessageWithDetails(int|string $expected, int|string $ac /** * Assert whether the response is redirecting to a given URI. + * + * @return $this */ public function assertRedirect(?string $uri = null): static { @@ -202,6 +214,8 @@ public function assertRedirect(?string $uri = null): static /** * Assert whether the response is redirecting to a URI that contains the given URI. + * + * @return $this */ public function assertRedirectContains(string $uri): static { @@ -220,6 +234,8 @@ public function assertRedirectContains(string $uri): static /** * Assert whether the response is redirecting back to the previous location. + * + * @return $this */ public function assertRedirectBack(): static { @@ -235,6 +251,8 @@ public function assertRedirectBack(): static /** * Assert whether the response is redirecting back to the previous location with the given errors in the session. + * + * @return $this */ public function assertRedirectBackWithErrors(array|string $keys = [], mixed $format = null, string $errorBag = 'default'): static { @@ -247,6 +265,8 @@ public function assertRedirectBackWithErrors(array|string $keys = [], mixed $for /** * Assert whether the response is redirecting back to the previous location with no errors in the session. + * + * @return $this */ public function assertRedirectBackWithoutErrors(): static { @@ -259,6 +279,8 @@ public function assertRedirectBackWithoutErrors(): static /** * Assert whether the response is redirecting to a given route. + * + * @return $this */ public function assertRedirectToRoute(BackedEnum|string $name, mixed $parameters = []): static { @@ -276,6 +298,8 @@ public function assertRedirectToRoute(BackedEnum|string $name, mixed $parameters /** * Assert whether the response is redirecting to a given signed route. + * + * @return $this */ public function assertRedirectToSignedRoute(BackedEnum|string|null $name = null, mixed $parameters = [], bool $absolute = true): static { @@ -312,6 +336,8 @@ public function assertRedirectToSignedRoute(BackedEnum|string|null $name = null, /** * Assert whether the response is redirecting to a given controller action. + * + * @return $this */ public function assertRedirectToAction(array|string $name, array $parameters = []): static { @@ -329,6 +355,8 @@ public function assertRedirectToAction(array|string $name, array $parameters = [ /** * Asserts that the response contains the given header and equals the optional value. + * + * @return $this */ public function assertHeader(string $headerName, mixed $value = null): static { @@ -352,6 +380,8 @@ public function assertHeader(string $headerName, mixed $value = null): static /** * Asserts that the response contains the given header and that its value contains the given string. + * + * @return $this */ public function assertHeaderContains(string $headerName, string $value): static { @@ -372,6 +402,8 @@ public function assertHeaderContains(string $headerName, string $value): static /** * Asserts that the response does not contain the given header. + * + * @return $this */ public function assertHeaderMissing(string $headerName): static { @@ -385,6 +417,8 @@ public function assertHeaderMissing(string $headerName): static /** * Assert that the current location header matches the given URI. + * + * @return $this */ public function assertLocation(string $uri): static { @@ -398,6 +432,8 @@ public function assertLocation(string $uri): static /** * Assert that the response offers a file download. + * + * @return $this */ public function assertDownload(?string $filename = null): static { @@ -443,6 +479,8 @@ public function assertDownload(?string $filename = null): static /** * Asserts that the response contains the given cookie and equals the optional value. + * + * @return $this */ public function assertPlainCookie(string $cookieName, mixed $value = null): static { @@ -453,6 +491,8 @@ public function assertPlainCookie(string $cookieName, mixed $value = null): stat /** * Asserts that the response contains the given cookie and equals the optional value. + * + * @return $this */ public function assertCookie(string $cookieName, mixed $value = null, bool $encrypted = true, bool $unserialize = false): static { @@ -478,6 +518,8 @@ public function assertCookie(string $cookieName, mixed $value = null, bool $encr /** * Asserts that the response contains the given cookie and is expired. + * + * @return $this */ public function assertCookieExpired(string $cookieName): static { @@ -498,6 +540,8 @@ public function assertCookieExpired(string $cookieName): static /** * Asserts that the response contains the given cookie and is not expired. + * + * @return $this */ public function assertCookieNotExpired(string $cookieName): static { @@ -518,6 +562,8 @@ public function assertCookieNotExpired(string $cookieName): static /** * Asserts that the response does not contain the given cookie. + * + * @return $this */ public function assertCookieMissing(string $cookieName): static { @@ -564,6 +610,8 @@ public function getCookie(string $cookieName, bool $decrypt = true, bool $unseri /** * Assert that the given string matches the response content. + * + * @return $this */ public function assertContent(string $value): static { @@ -574,6 +622,8 @@ public function assertContent(string $value): static /** * Assert that the response was streamed. + * + * @return $this */ public function assertStreamed(): static { @@ -587,6 +637,8 @@ public function assertStreamed(): static /** * Assert that the response was not streamed. + * + * @return $this */ public function assertNotStreamed(): static { @@ -600,6 +652,8 @@ public function assertNotStreamed(): static /** * Assert that the given string matches the streamed response content. + * + * @return $this */ public function assertStreamedContent(string $value): static { @@ -611,6 +665,8 @@ public function assertStreamedContent(string $value): static /** * Assert that the given array matches the streamed JSON response content. * + * @return $this + * * @throws JsonException */ public function assertStreamedJsonContent(array $value): static @@ -622,6 +678,7 @@ public function assertStreamedJsonContent(array $value): static * Assert that the given string or array of strings are contained within the response. * * @param list|string $value + * @return $this */ public function assertSee(array|string $value, bool $escape = true): static { @@ -640,6 +697,7 @@ public function assertSee(array|string $value, bool $escape = true): static * Assert that the given HTML string or array of HTML strings are contained within the response. * * @param list|string $value + * @return $this */ public function assertSeeHtml(array|string $value): static { @@ -650,6 +708,7 @@ public function assertSeeHtml(array|string $value): static * Assert that the given strings are contained in order within the response. * * @param list $values + * @return $this */ public function assertSeeInOrder(array $values, bool $escape = true): static { @@ -664,6 +723,7 @@ public function assertSeeInOrder(array $values, bool $escape = true): static * Assert that the given HTML strings are contained in order within the response. * * @param list $values + * @return $this */ public function assertSeeHtmlInOrder(array $values): static { @@ -674,6 +734,7 @@ public function assertSeeHtmlInOrder(array $values): static * Assert that the given string or array of strings are contained within the response text. * * @param list|string $value + * @return $this */ public function assertSeeText(array|string $value, bool $escape = true): static { @@ -690,6 +751,7 @@ public function assertSeeText(array|string $value, bool $escape = true): static * Assert that the given strings are contained in order within the response text. * * @param list $values + * @return $this */ public function assertSeeTextInOrder(array $values, bool $escape = true): static { @@ -704,6 +766,7 @@ public function assertSeeTextInOrder(array $values, bool $escape = true): static * Assert that the given string or array of strings are not contained within the response. * * @param list|string $value + * @return $this */ public function assertDontSee(array|string $value, bool $escape = true): static { @@ -722,6 +785,7 @@ public function assertDontSee(array|string $value, bool $escape = true): static * Assert that the given HTML string or array of HTML strings are not contained within the response. * * @param list|string $value + * @return $this */ public function assertDontSeeHtml(array|string $value): static { @@ -732,6 +796,7 @@ public function assertDontSeeHtml(array|string $value): static * Assert that the given string or array of strings are not contained within the response text. * * @param list|string $value + * @return $this */ public function assertDontSeeText(array|string $value, bool $escape = true): static { @@ -746,6 +811,8 @@ public function assertDontSeeText(array|string $value, bool $escape = true): sta /** * Assert that the response is a superset of the given JSON. + * + * @return $this */ public function assertJson(array|callable $value, bool $strict = false): static { @@ -768,6 +835,8 @@ public function assertJson(array|callable $value, bool $strict = false): static /** * Assert that the expected value and type exists at the given path in the response. + * + * @return $this */ public function assertJsonPath(string $path, mixed $expect): static { @@ -780,6 +849,7 @@ public function assertJsonPath(string $path, mixed $expect): static * Assert that the expected values and types exist at the given paths in the response. * * @param array $paths + * @return $this */ public function assertJsonPaths(array $paths): static { @@ -794,6 +864,7 @@ public function assertJsonPaths(array $paths): static * Assert that the given paths in the response contain all of the expected values without looking at the order. * * @param array> $paths + * @return $this */ public function assertJsonPathsCanonicalizing(array $paths): static { @@ -806,6 +877,8 @@ public function assertJsonPathsCanonicalizing(array $paths): static /** * Assert that the given path in the response contains all of the expected values without looking at the order. + * + * @return $this */ public function assertJsonPathCanonicalizing(string $path, array $expect): static { @@ -816,6 +889,8 @@ public function assertJsonPathCanonicalizing(string $path, array $expect): stati /** * Assert that the response has the exact given JSON. + * + * @return $this */ public function assertExactJson(array $data): static { @@ -826,6 +901,8 @@ public function assertExactJson(array $data): static /** * Assert that the response has the similar JSON as given. + * + * @return $this */ public function assertSimilarJson(array $data): static { @@ -836,6 +913,8 @@ public function assertSimilarJson(array $data): static /** * Assert that the response contains the given JSON fragments. + * + * @return $this */ public function assertJsonFragments(array $data): static { @@ -848,6 +927,8 @@ public function assertJsonFragments(array $data): static /** * Assert that the response contains the given JSON fragment. + * + * @return $this */ public function assertJsonFragment(array $data): static { @@ -858,6 +939,8 @@ public function assertJsonFragment(array $data): static /** * Assert that the response does not contain the given JSON fragment. + * + * @return $this */ public function assertJsonMissing(array $data, bool $exact = false): static { @@ -868,6 +951,8 @@ public function assertJsonMissing(array $data, bool $exact = false): static /** * Assert that the response does not contain the exact JSON fragment. + * + * @return $this */ public function assertJsonMissingExact(array $data): static { @@ -878,6 +963,8 @@ public function assertJsonMissingExact(array $data): static /** * Assert that the response does not contain the given path. + * + * @return $this */ public function assertJsonMissingPath(string $path): static { @@ -890,6 +977,7 @@ public function assertJsonMissingPath(string $path): static * Assert that the response does not contain the given paths. * * @param list $paths + * @return $this */ public function assertJsonMissingPaths(array $paths): static { @@ -902,6 +990,8 @@ public function assertJsonMissingPaths(array $paths): static /** * Assert that the response has a given JSON structure. + * + * @return $this */ public function assertJsonStructure(?array $structure = null, ?array $responseData = null): static { @@ -912,6 +1002,8 @@ public function assertJsonStructure(?array $structure = null, ?array $responseDa /** * Assert that the response has the exact JSON structure. + * + * @return $this */ public function assertExactJsonStructure(?array $structure = null, ?array $responseData = null): static { @@ -922,6 +1014,8 @@ public function assertExactJsonStructure(?array $structure = null, ?array $respo /** * Assert that the response JSON has the expected count of items at the given key. + * + * @return $this */ public function assertJsonCount(int $count, ?string $key = null): static { @@ -932,6 +1026,8 @@ public function assertJsonCount(int $count, ?string $key = null): static /** * Assert that the response has the given JSON validation errors. + * + * @return $this */ public function assertJsonValidationErrors(array|string|null $errors, string $responseKey = 'errors'): static { @@ -979,6 +1075,8 @@ public function assertJsonValidationErrors(array|string|null $errors, string $re /** * Assert that the response has the given JSON validation errors but does not have any other JSON validation errors. + * + * @return $this */ public function assertOnlyJsonValidationErrors(array|string|null $errors, string $responseKey = 'errors'): static { @@ -1002,6 +1100,8 @@ public function assertOnlyJsonValidationErrors(array|string|null $errors, string /** * Assert the response has any JSON validation errors for the given key. + * + * @return $this */ public function assertJsonValidationErrorFor(string $key, string $responseKey = 'errors'): static { @@ -1023,6 +1123,8 @@ public function assertJsonValidationErrorFor(string $key, string $responseKey = /** * Assert that the response has no JSON validation errors for the given keys. + * + * @return $this */ public function assertJsonMissingValidationErrors(array|string|null $keys = null, string $responseKey = 'errors'): static { @@ -1061,6 +1163,8 @@ public function assertJsonMissingValidationErrors(array|string|null $keys = null /** * Assert that the given key is a JSON array. + * + * @return $this */ public function assertJsonIsArray(?string $key = null): static { @@ -1074,6 +1178,8 @@ public function assertJsonIsArray(?string $key = null): static /** * Assert that the given key is a JSON object. + * + * @return $this */ public function assertJsonIsObject(?string $key = null): static { @@ -1130,6 +1236,8 @@ public function collect(?string $key = null): Collection /** * Assert that the response view equals the given value. + * + * @return $this */ public function assertViewIs(string $value): static { @@ -1142,6 +1250,8 @@ public function assertViewIs(string $value): static /** * Assert that the response view has a given piece of bound data. + * + * @return $this */ public function assertViewHas(array|string $key, mixed $value = null): static { @@ -1183,6 +1293,8 @@ public function assertViewHas(array|string $key, mixed $value = null): static /** * Assert that the response view has a given list of bound data. + * + * @return $this */ public function assertViewHasAll(array $bindings): static { @@ -1215,6 +1327,8 @@ public function viewData(?string $key = null): mixed /** * Assert that the response view is missing a piece of bound data. + * + * @return $this */ public function assertViewMissing(string $key): static { @@ -1227,6 +1341,8 @@ public function assertViewMissing(string $key): static /** * Ensure that the response has a view as its original content. + * + * @return $this */ protected function ensureResponseHasView(): static { @@ -1247,6 +1363,8 @@ protected function responseHasView(): bool /** * Assert that the given keys do not have validation errors. + * + * @return $this */ public function assertValid(array|string|null $keys = null, string $errorBag = 'default', string $responseKey = 'errors'): static { @@ -1285,6 +1403,8 @@ public function assertValid(array|string|null $keys = null, string $errorBag = ' /** * Assert that the response has the given validation errors. + * + * @return $this */ public function assertInvalid(array|string|null $errors = null, string $errorBag = 'default', string $responseKey = 'errors'): static { @@ -1334,6 +1454,8 @@ public function assertInvalid(array|string|null $errors = null, string $errorBag /** * Assert that the response has the given validation errors but does not have any other validation errors. + * + * @return $this */ public function assertOnlyInvalid(array|string|null $errors = null, string $errorBag = 'default', string $responseKey = 'errors'): static { @@ -1363,6 +1485,8 @@ public function assertOnlyInvalid(array|string|null $errors = null, string $erro /** * Assert that the session has a given value. + * + * @return $this */ public function assertSessionHas(array|string $key, mixed $value = null): static { @@ -1386,6 +1510,8 @@ public function assertSessionHas(array|string $key, mixed $value = null): static /** * Assert that the session has a given list of values. + * + * @return $this */ public function assertSessionHasAll(array $bindings): static { @@ -1413,6 +1539,8 @@ public function assertSessionHasAll(array $bindings): static /** * Assert that the session has a given value in the flashed input array. + * + * @return $this */ public function assertSessionHasInput(array|string $key, mixed $value = null): static { @@ -1468,6 +1596,8 @@ public function assertSessionMissingInput(array|string $key): static /** * Assert that the session has the given errors. + * + * @return $this */ public function assertSessionHasErrors(array|string $keys = [], mixed $format = null, string $errorBag = 'default'): static { @@ -1490,6 +1620,8 @@ public function assertSessionHasErrors(array|string $keys = [], mixed $format = /** * Assert that the session is missing the given errors. + * + * @return $this */ public function assertSessionDoesntHaveErrors(array|string $keys = [], ?string $format = null, string $errorBag = 'default'): static { @@ -1521,6 +1653,8 @@ public function assertSessionDoesntHaveErrors(array|string $keys = [], ?string $ /** * Assert that the session has no errors. * + * @return $this + * * @throws JsonException */ public function assertSessionHasNoErrors(): static @@ -1552,6 +1686,8 @@ public function assertSessionHasNoErrors(): static /** * Assert that the session has the given errors. + * + * @return $this */ public function assertSessionHasErrorsIn(string $errorBag, array|string $keys = [], mixed $format = null): static { @@ -1560,6 +1696,8 @@ public function assertSessionHasErrorsIn(string $errorBag, array|string $keys = /** * Assert that the session does not have a given key. + * + * @return $this */ public function assertSessionMissing(array|string $key, mixed $value = null): static { @@ -1637,6 +1775,8 @@ public function ddSession(array|string $keys = []): never /** * Dump the content from the response. + * + * @return $this */ public function dump(?string $key = null): static { @@ -1660,6 +1800,8 @@ public function dump(?string $key = null): static /** * Dump the headers from the response. + * + * @return $this */ public function dumpHeaders(): static { @@ -1670,6 +1812,8 @@ public function dumpHeaders(): static /** * Dump the session from the response. + * + * @return $this */ public function dumpSession(array|string $keys = []): static { @@ -1743,6 +1887,8 @@ protected function isStreamedResponse(): bool /** * Set the previous exceptions on the response. + * + * @return $this */ public function withExceptions(Collection $exceptions): static { From 0e534e2248b217e400b2c8b378cf39a1281905b1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 144/303] Restore fluent validation rule contracts Restore receiver identity for validation configuration, rule builders and data-aware or validator-aware contracts. Annotate Numeric and StringRule addRule helpers so delegated builder calls retain the same identity. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- .../src/Validation/DataAwareRule.php | 2 ++ src/contracts/src/Validation/Validator.php | 4 +++ .../src/Validation/ValidatorAwareRule.php | 2 ++ src/validation/src/ClosureValidationRule.php | 2 ++ src/validation/src/Factory.php | 2 ++ .../src/InvokableValidationRule.php | 4 +++ src/validation/src/Rules/AnyOf.php | 2 ++ src/validation/src/Rules/Can.php | 2 ++ src/validation/src/Rules/DatabaseRule.php | 18 ++++++++++ src/validation/src/Rules/Dimensions.php | 20 +++++++++++ src/validation/src/Rules/Email.php | 16 +++++++++ src/validation/src/Rules/Enum.php | 4 +++ src/validation/src/Rules/File.php | 18 ++++++++++ src/validation/src/Rules/ImageFile.php | 2 ++ src/validation/src/Rules/Numeric.php | 36 +++++++++++++++++++ src/validation/src/Rules/Password.php | 18 ++++++++++ src/validation/src/Rules/StringRule.php | 30 ++++++++++++++++ src/validation/src/Rules/Unique.php | 4 +++ src/validation/src/ValidationException.php | 6 ++++ src/validation/src/Validator.php | 27 ++++++++++++++ 20 files changed, 219 insertions(+) diff --git a/src/contracts/src/Validation/DataAwareRule.php b/src/contracts/src/Validation/DataAwareRule.php index e8eb4deb4..37ed765bd 100644 --- a/src/contracts/src/Validation/DataAwareRule.php +++ b/src/contracts/src/Validation/DataAwareRule.php @@ -8,6 +8,8 @@ interface DataAwareRule { /** * Set the data under validation. + * + * @return $this */ public function setData(array $data): static; } diff --git a/src/contracts/src/Validation/Validator.php b/src/contracts/src/Validation/Validator.php index 942c20f6c..93b7e5b07 100644 --- a/src/contracts/src/Validation/Validator.php +++ b/src/contracts/src/Validation/Validator.php @@ -38,11 +38,15 @@ public function failed(): array; /** * Add conditions to a given field based on a Closure. + * + * @return $this */ public function sometimes(array|string $attribute, array|object|string $rules, callable $callback): static; /** * Add an after validation callback. + * + * @return $this */ public function after(callable|string $callback): static; diff --git a/src/contracts/src/Validation/ValidatorAwareRule.php b/src/contracts/src/Validation/ValidatorAwareRule.php index 3e86da4bc..88a9e4b77 100644 --- a/src/contracts/src/Validation/ValidatorAwareRule.php +++ b/src/contracts/src/Validation/ValidatorAwareRule.php @@ -10,6 +10,8 @@ interface ValidatorAwareRule { /** * Set the current validator. + * + * @return $this */ public function setValidator(Validator $validator): static; } diff --git a/src/validation/src/ClosureValidationRule.php b/src/validation/src/ClosureValidationRule.php index 12f89bd92..8dc22dbe3 100644 --- a/src/validation/src/ClosureValidationRule.php +++ b/src/validation/src/ClosureValidationRule.php @@ -65,6 +65,8 @@ public function message(): array /** * Set the current validator. + * + * @return $this */ public function setValidator(Validator $validator): static { diff --git a/src/validation/src/Factory.php b/src/validation/src/Factory.php index c64f2859e..849de82a4 100644 --- a/src/validation/src/Factory.php +++ b/src/validation/src/Factory.php @@ -289,6 +289,8 @@ public function getContainer(): ?Container * Tests only. Swaps the container on the singleton validation Factory; * per-request use races across coroutines and breaks every concurrent * validator build. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/validation/src/InvokableValidationRule.php b/src/validation/src/InvokableValidationRule.php index 3dcd2b664..a1adf4377 100644 --- a/src/validation/src/InvokableValidationRule.php +++ b/src/validation/src/InvokableValidationRule.php @@ -99,6 +99,8 @@ public function message(): array /** * Set the data under validation. + * + * @return $this */ public function setData(array $data): static { @@ -109,6 +111,8 @@ public function setData(array $data): static /** * Set the current validator. + * + * @return $this */ public function setValidator(Validator $validator): static { diff --git a/src/validation/src/Rules/AnyOf.php b/src/validation/src/Rules/AnyOf.php index f4c47c8ab..de48bdef7 100644 --- a/src/validation/src/Rules/AnyOf.php +++ b/src/validation/src/Rules/AnyOf.php @@ -68,6 +68,8 @@ public function message(): array|string /** * Set the current validator. + * + * @return $this */ public function setValidator(ValidatorContract $validator): static { diff --git a/src/validation/src/Rules/Can.php b/src/validation/src/Rules/Can.php index 10c15d0d2..f3024600b 100644 --- a/src/validation/src/Rules/Can.php +++ b/src/validation/src/Rules/Can.php @@ -51,6 +51,8 @@ public function message(): array|string /** * Set the current validator. + * + * @return $this */ public function setValidator(Validator $validator): static { diff --git a/src/validation/src/Rules/DatabaseRule.php b/src/validation/src/Rules/DatabaseRule.php index 4a6d56336..2cad43ffe 100644 --- a/src/validation/src/Rules/DatabaseRule.php +++ b/src/validation/src/Rules/DatabaseRule.php @@ -70,6 +70,8 @@ public function resolveTableName(string $table): string /** * Set a "where" constraint on the query. + * + * @return $this */ public function where(Closure|string $column, mixed $value = null): static { @@ -94,6 +96,8 @@ public function where(Closure|string $column, mixed $value = null): static /** * Set a "where not" constraint on the query. + * + * @return $this */ public function whereNot(string $column, mixed $value): static { @@ -108,6 +112,8 @@ public function whereNot(string $column, mixed $value): static /** * Set a "where null" constraint on the query. + * + * @return $this */ public function whereNull(string $column): static { @@ -116,6 +122,8 @@ public function whereNull(string $column): static /** * Set a "where not null" constraint on the query. + * + * @return $this */ public function whereNotNull(string $column): static { @@ -124,6 +132,8 @@ public function whereNotNull(string $column): static /** * Set a "where in" constraint on the query. + * + * @return $this */ public function whereIn(string $column, array|Arrayable|BackedEnum $values): static { @@ -134,6 +144,8 @@ public function whereIn(string $column, array|Arrayable|BackedEnum $values): sta /** * Set a "where not in" constraint on the query. + * + * @return $this */ public function whereNotIn(string $column, array|Arrayable|BackedEnum $values): static { @@ -144,6 +156,8 @@ public function whereNotIn(string $column, array|Arrayable|BackedEnum $values): /** * Ignore soft deleted models during the existence checks. + * + * @return $this */ public function withoutTrashed(string $deletedAtColumn = 'deleted_at'): static { @@ -154,6 +168,8 @@ public function withoutTrashed(string $deletedAtColumn = 'deleted_at'): static /** * Only include soft deleted models during the existence check. + * + * @return $this */ public function onlyTrashed(string $deletedAtColumn = 'deleted_at'): static { @@ -164,6 +180,8 @@ public function onlyTrashed(string $deletedAtColumn = 'deleted_at'): static /** * Register a custom query callback. + * + * @return $this */ public function using(Closure $callback): static { diff --git a/src/validation/src/Rules/Dimensions.php b/src/validation/src/Rules/Dimensions.php index c9ce9a3a1..9ebe7033a 100644 --- a/src/validation/src/Rules/Dimensions.php +++ b/src/validation/src/Rules/Dimensions.php @@ -23,6 +23,8 @@ public function __construct( /** * Set the "width" constraint. + * + * @return $this */ public function width(int|string $value): static { @@ -33,6 +35,8 @@ public function width(int|string $value): static /** * Set the "height" constraint. + * + * @return $this */ public function height(int|string $value): static { @@ -43,6 +47,8 @@ public function height(int|string $value): static /** * Set the "min width" constraint. + * + * @return $this */ public function minWidth(int|string $value): static { @@ -53,6 +59,8 @@ public function minWidth(int|string $value): static /** * Set the "min height" constraint. + * + * @return $this */ public function minHeight(int|string $value): static { @@ -63,6 +71,8 @@ public function minHeight(int|string $value): static /** * Set the "max width" constraint. + * + * @return $this */ public function maxWidth(int|string $value): static { @@ -73,6 +83,8 @@ public function maxWidth(int|string $value): static /** * Set the "max height" constraint. + * + * @return $this */ public function maxHeight(int|string $value): static { @@ -83,6 +95,8 @@ public function maxHeight(int|string $value): static /** * Set the "ratio" constraint. + * + * @return $this */ public function ratio(float|string $value): static { @@ -93,6 +107,8 @@ public function ratio(float|string $value): static /** * Set the minimum aspect ratio. + * + * @return $this */ public function minRatio(float|string $value): static { @@ -103,6 +119,8 @@ public function minRatio(float|string $value): static /** * Set the maximum aspect ratio. + * + * @return $this */ public function maxRatio(float|string $value): static { @@ -113,6 +131,8 @@ public function maxRatio(float|string $value): static /** * Set the aspect ratio range. + * + * @return $this */ public function ratioBetween(float|string $min, float|string $max): static { diff --git a/src/validation/src/Rules/Email.php b/src/validation/src/Rules/Email.php index 587eeccc5..6831a4ccc 100644 --- a/src/validation/src/Rules/Email.php +++ b/src/validation/src/Rules/Email.php @@ -111,6 +111,8 @@ public static function default(): static /** * Ensure that the email is an RFC compliant email address. + * + * @return $this */ public function rfcCompliant(bool $strict = false): static { @@ -125,6 +127,8 @@ public function rfcCompliant(bool $strict = false): static /** * Ensure that the email is a strictly enforced RFC compliant email address. + * + * @return $this */ public function strict(): static { @@ -135,6 +139,8 @@ public function strict(): static * Check the email domain's MX records, with A or AAAA records as a fallback. * * Requires the PHP intl extension. + * + * @return $this */ public function validateMxRecord(): static { @@ -145,6 +151,8 @@ public function validateMxRecord(): static /** * Ensure that the email address is not attempting to spoof another email address using invalid unicode characters. + * + * @return $this */ public function preventSpoofing(): static { @@ -155,6 +163,8 @@ public function preventSpoofing(): static /** * Ensure the email address is valid using PHP's native email validation functions. + * + * @return $this */ public function withNativeValidation(bool $allowUnicode = false): static { @@ -169,6 +179,8 @@ public function withNativeValidation(bool $allowUnicode = false): static /** * Specify additional validation rules that should be merged with the default rules during validation. + * + * @return $this */ public function rules(array|string $rules): static { @@ -250,6 +262,8 @@ public function message(): array /** * Set the current validator. + * + * @return $this */ public function setValidator(ValidatorContract $validator): static { @@ -260,6 +274,8 @@ public function setValidator(ValidatorContract $validator): static /** * Set the current data under validation. + * + * @return $this */ public function setData(array $data): static { diff --git a/src/validation/src/Rules/Enum.php b/src/validation/src/Rules/Enum.php index 7745d7d40..379a50ef8 100644 --- a/src/validation/src/Rules/Enum.php +++ b/src/validation/src/Rules/Enum.php @@ -68,6 +68,7 @@ public function passes(string $attribute, mixed $value): bool * Specify the cases that should be considered valid. * * @param Arrayable>|UnitEnum|UnitEnum[] $values + * @return $this */ public function only(mixed $values): static { @@ -80,6 +81,7 @@ public function only(mixed $values): static * Specify the cases that should be considered invalid. * * @param Arrayable>|UnitEnum|UnitEnum[] $values + * @return $this */ public function except(mixed $values): static { @@ -114,6 +116,8 @@ public function message(): array|string /** * Set the current validator. + * + * @return $this */ public function setValidator(Validator $validator): static { diff --git a/src/validation/src/Rules/File.php b/src/validation/src/Rules/File.php index f15493d12..e0f10781f 100644 --- a/src/validation/src/Rules/File.php +++ b/src/validation/src/Rules/File.php @@ -144,6 +144,8 @@ public static function types(array|string $mimetypes): static /** * Limit the uploaded file to the given file extensions. + * + * @return $this */ public function extensions(array|string $extensions): static { @@ -154,6 +156,8 @@ public function extensions(array|string $extensions): static /** * Indicate that the uploaded file should be exactly a certain size in kilobytes. + * + * @return $this */ public function size(int|string $size): static { @@ -165,6 +169,8 @@ public function size(int|string $size): static /** * Indicate that the uploaded file should be between a minimum and maximum size in kilobytes. + * + * @return $this */ public function between(int|string $minSize, int|string $maxSize): static { @@ -176,6 +182,8 @@ public function between(int|string $minSize, int|string $maxSize): static /** * Indicate that the uploaded file should be no less than the given number of kilobytes. + * + * @return $this */ public function min(int|string $size): static { @@ -186,6 +194,8 @@ public function min(int|string $size): static /** * Indicate that the uploaded file should be no more than the given number of kilobytes. + * + * @return $this */ public function max(int|string $size): static { @@ -196,6 +206,8 @@ public function max(int|string $size): static /** * Indicate that the uploaded file should be in the given encoding. + * + * @return $this */ public function encoding(string $encoding): static { @@ -232,6 +244,8 @@ protected function toKilobytes(int|string $size): int|float /** * Specify additional validation rules that should be merged with the default rules during validation. + * + * @return $this */ public function rules(array|string|Stringable $rules): static { @@ -338,6 +352,8 @@ public function message(): array /** * Set the current validator. + * + * @return $this */ public function setValidator(ValidatorContract $validator): static { @@ -348,6 +364,8 @@ public function setValidator(ValidatorContract $validator): static /** * Set the current data under validation. + * + * @return $this */ public function setData(array $data): static { diff --git a/src/validation/src/Rules/ImageFile.php b/src/validation/src/Rules/ImageFile.php index 7b3e4beda..e89b5c861 100644 --- a/src/validation/src/Rules/ImageFile.php +++ b/src/validation/src/Rules/ImageFile.php @@ -20,6 +20,8 @@ public function __construct(bool $allowSvg = false) /** * The dimension constraints for the uploaded file. + * + * @return $this */ public function dimensions(Dimensions $dimensions): static { diff --git a/src/validation/src/Rules/Numeric.php b/src/validation/src/Rules/Numeric.php index 697c3bbf1..d377d89a1 100644 --- a/src/validation/src/Rules/Numeric.php +++ b/src/validation/src/Rules/Numeric.php @@ -27,6 +27,8 @@ public function __construct(bool $strict = false) /** * The field under validation must have a size between the given min and max (inclusive). + * + * @return $this */ public function between(float|int $min, float|int $max): static { @@ -35,6 +37,8 @@ public function between(float|int $min, float|int $max): static /** * The field under validation must contain the specified number of decimal places. + * + * @return $this */ public function decimal(int $min, ?int $max = null): static { @@ -49,6 +53,8 @@ public function decimal(int $min, ?int $max = null): static /** * The field under validation must have a different value than field. + * + * @return $this */ public function different(string $field): static { @@ -57,6 +63,8 @@ public function different(string $field): static /** * The integer under validation must have an exact number of digits. + * + * @return $this */ public function digits(int $length): static { @@ -65,6 +73,8 @@ public function digits(int $length): static /** * The integer under validation must between the given min and max number of digits. + * + * @return $this */ public function digitsBetween(int $min, int $max): static { @@ -73,6 +83,8 @@ public function digitsBetween(int $min, int $max): static /** * The field under validation must be greater than the given field or value. + * + * @return $this */ public function greaterThan(string $field): static { @@ -81,6 +93,8 @@ public function greaterThan(string $field): static /** * The field under validation must be greater than or equal to the given field or value. + * + * @return $this */ public function greaterThanOrEqualTo(string $field): static { @@ -89,6 +103,8 @@ public function greaterThanOrEqualTo(string $field): static /** * The field under validation must be an integer. + * + * @return $this */ public function integer(bool $strict = false): static { @@ -97,6 +113,8 @@ public function integer(bool $strict = false): static /** * The field under validation must be less than the given field. + * + * @return $this */ public function lessThan(string $field): static { @@ -105,6 +123,8 @@ public function lessThan(string $field): static /** * The field under validation must be less than or equal to the given field. + * + * @return $this */ public function lessThanOrEqualTo(string $field): static { @@ -113,6 +133,8 @@ public function lessThanOrEqualTo(string $field): static /** * The field under validation must be less than or equal to a maximum value. + * + * @return $this */ public function max(float|int $value): static { @@ -121,6 +143,8 @@ public function max(float|int $value): static /** * The integer under validation must have a maximum number of digits. + * + * @return $this */ public function maxDigits(int $value): static { @@ -129,6 +153,8 @@ public function maxDigits(int $value): static /** * The field under validation must have a minimum value. + * + * @return $this */ public function min(float|int $value): static { @@ -137,6 +163,8 @@ public function min(float|int $value): static /** * The integer under validation must have a minimum number of digits. + * + * @return $this */ public function minDigits(int $value): static { @@ -145,6 +173,8 @@ public function minDigits(int $value): static /** * The field under validation must be a multiple of the given value. + * + * @return $this */ public function multipleOf(float|int $value): static { @@ -153,6 +183,8 @@ public function multipleOf(float|int $value): static /** * The given field must match the field under validation. + * + * @return $this */ public function same(string $field): static { @@ -161,6 +193,8 @@ public function same(string $field): static /** * The field under validation must match the given value. + * + * @return $this */ public function exactly(int $value): static { @@ -187,6 +221,8 @@ public function toArray(): array /** * Add custom rules to the validation rules array. + * + * @return $this */ protected function addRule(array|string $rules): static { diff --git a/src/validation/src/Rules/Password.php b/src/validation/src/Rules/Password.php index f798b3b10..59ce4ad22 100644 --- a/src/validation/src/Rules/Password.php +++ b/src/validation/src/Rules/Password.php @@ -186,6 +186,8 @@ public static function sometimes(): static /** * Set the performing validator. + * + * @return $this */ public function setValidator(ValidatorContract $validator): static { @@ -196,6 +198,8 @@ public function setValidator(ValidatorContract $validator): static /** * Set the data under validation. + * + * @return $this */ public function setData(array $data): static { @@ -214,6 +218,8 @@ public static function min(int $size): static /** * Set the maximum size of the password. + * + * @return $this */ public function max(int $size): static { @@ -224,6 +230,8 @@ public function max(int $size): static /** * Ensures the password has not been compromised in data leaks. + * + * @return $this */ public function uncompromised(int $threshold = 0): static { @@ -236,6 +244,8 @@ public function uncompromised(int $threshold = 0): static /** * Makes the password require at least one uppercase and one lowercase letter. + * + * @return $this */ public function mixedCase(): static { @@ -246,6 +256,8 @@ public function mixedCase(): static /** * Makes the password require at least one letter. + * + * @return $this */ public function letters(): static { @@ -256,6 +268,8 @@ public function letters(): static /** * Makes the password require at least one number. + * + * @return $this */ public function numbers(): static { @@ -266,6 +280,8 @@ public function numbers(): static /** * Makes the password require at least one symbol. + * + * @return $this */ public function symbols(): static { @@ -276,6 +292,8 @@ public function symbols(): static /** * Specify additional validation rules that should be merged with the default rules during validation. + * + * @return $this */ public function rules(array|Closure|Rule|string $rules): static { diff --git a/src/validation/src/Rules/StringRule.php b/src/validation/src/Rules/StringRule.php index d636d1d77..1df9576e4 100644 --- a/src/validation/src/Rules/StringRule.php +++ b/src/validation/src/Rules/StringRule.php @@ -19,6 +19,8 @@ class StringRule implements Stringable /** * The field under validation must be entirely alphabetic characters. + * + * @return $this */ public function alpha(bool $ascii = false): static { @@ -27,6 +29,8 @@ public function alpha(bool $ascii = false): static /** * The field under validation must be entirely alpha-numeric characters, dashes, and underscores. + * + * @return $this */ public function alphaDash(bool $ascii = false): static { @@ -35,6 +39,8 @@ public function alphaDash(bool $ascii = false): static /** * The field under validation must be entirely alpha-numeric characters. + * + * @return $this */ public function alphaNumeric(bool $ascii = false): static { @@ -43,6 +49,8 @@ public function alphaNumeric(bool $ascii = false): static /** * The field under validation must be entirely ASCII characters. + * + * @return $this */ public function ascii(): static { @@ -51,6 +59,8 @@ public function ascii(): static /** * The field under validation must have a length between the given min and max (inclusive). + * + * @return $this */ public function between(int $min, int $max): static { @@ -59,6 +69,8 @@ public function between(int $min, int $max): static /** * The field under validation must not end with any of the given values. + * + * @return $this */ public function doesntEndWith(string ...$values): static { @@ -67,6 +79,8 @@ public function doesntEndWith(string ...$values): static /** * The field under validation must not start with any of the given values. + * + * @return $this */ public function doesntStartWith(string ...$values): static { @@ -75,6 +89,8 @@ public function doesntStartWith(string ...$values): static /** * The field under validation must end with one of the given values. + * + * @return $this */ public function endsWith(string ...$values): static { @@ -83,6 +99,8 @@ public function endsWith(string ...$values): static /** * The field under validation must have an exact length. + * + * @return $this */ public function exactly(int $value): static { @@ -91,6 +109,8 @@ public function exactly(int $value): static /** * The field under validation must be entirely lowercase. + * + * @return $this */ public function lowercase(): static { @@ -99,6 +119,8 @@ public function lowercase(): static /** * The field under validation must not exceed the given length. + * + * @return $this */ public function max(int $value): static { @@ -107,6 +129,8 @@ public function max(int $value): static /** * The field under validation must have a minimum length. + * + * @return $this */ public function min(int $value): static { @@ -115,6 +139,8 @@ public function min(int $value): static /** * The field under validation must start with one of the given values. + * + * @return $this */ public function startsWith(string ...$values): static { @@ -123,6 +149,8 @@ public function startsWith(string ...$values): static /** * The field under validation must be entirely uppercase. + * + * @return $this */ public function uppercase(): static { @@ -162,6 +190,8 @@ protected function formatValues(array $values): string /** * Add custom rules to the validation rules array. + * + * @return $this */ protected function addRule(array|string $rules): static { diff --git a/src/validation/src/Rules/Unique.php b/src/validation/src/Rules/Unique.php index 3134a7846..b68cdcbea 100644 --- a/src/validation/src/Rules/Unique.php +++ b/src/validation/src/Rules/Unique.php @@ -25,6 +25,8 @@ class Unique implements Stringable /** * Ignore the given ID during the unique check. + * + * @return $this */ public function ignore(mixed $id, ?string $idColumn = null): static { @@ -40,6 +42,8 @@ public function ignore(mixed $id, ?string $idColumn = null): static /** * Ignore the given model during the unique check. + * + * @return $this */ public function ignoreModel(Model $model, ?string $idColumn = null): static { diff --git a/src/validation/src/ValidationException.php b/src/validation/src/ValidationException.php index 5c6b85e41..605286031 100644 --- a/src/validation/src/ValidationException.php +++ b/src/validation/src/ValidationException.php @@ -83,6 +83,8 @@ public function errors(): array /** * Set the HTTP status code to be used for the response. + * + * @return $this */ public function status(int $status): static { @@ -93,6 +95,8 @@ public function status(int $status): static /** * Set the error bag on the exception. + * + * @return $this */ public function errorBag(string $errorBag): static { @@ -103,6 +107,8 @@ public function errorBag(string $errorBag): static /** * Set the URL to redirect to on a validation error. + * + * @return $this */ public function redirectTo(string $url): static { diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index fb7de05b5..690850d1f 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -423,6 +423,8 @@ protected function replaceDotPlaceholderInParameters(array $parameters): array /** * Add an after validation callback. + * + * @return $this */ public function after(array|callable|string $callback): static { @@ -1640,6 +1642,8 @@ public function getData(): array /** * Set the data under validation. + * + * @return $this */ public function setData(array $data): static { @@ -1688,6 +1692,8 @@ public function getRulesWithoutPlaceholders(): array /** * Set the validation rules. + * + * @return $this */ public function setRules(array $rules): static { @@ -1715,6 +1721,7 @@ public function setRules(array $rules): static * rebuilds the complete original rule graph. * * @param list $attributes + * @return $this */ public function retainRules(array $attributes): static { @@ -1731,6 +1738,8 @@ public function retainRules(array $attributes): static /** * Append new validation rules to the validator. + * + * @return $this */ public function appendRules(array $rules): static { @@ -1768,6 +1777,8 @@ public function addRules(array $rules): void /** * Add conditions to a given field based on a Closure. + * + * @return $this */ public function sometimes(array|string $attribute, array|object|string $rules, callable $callback): static { @@ -1812,6 +1823,8 @@ private function dataForSometimesIteration(string $attribute, bool $removeLastSe /** * Instruct the validator to stop validating after the first rule failure. + * + * @return $this */ public function stopOnFirstFailure(bool $stopOnFirstFailure = true): static { @@ -1910,6 +1923,8 @@ public function addReplacer(string $rule, Closure|string $replacer): void /** * Set the custom messages for the validator. + * + * @return $this */ public function setCustomMessages(array $messages): static { @@ -1920,6 +1935,8 @@ public function setCustomMessages(array $messages): static /** * Set the custom attributes on the validator. + * + * @return $this */ public function setAttributeNames(array $attributes): static { @@ -1930,6 +1947,8 @@ public function setAttributeNames(array $attributes): static /** * Add custom attributes to the validator. + * + * @return $this */ public function addCustomAttributes(array $attributes): static { @@ -1940,6 +1959,8 @@ public function addCustomAttributes(array $attributes): static /** * Set the callback that used to format an implicit attribute. + * + * @return $this */ public function setImplicitAttributesFormatter(?callable $formatter = null): static { @@ -1950,6 +1971,8 @@ public function setImplicitAttributesFormatter(?callable $formatter = null): sta /** * Set the custom values on the validator. + * + * @return $this */ public function setValueNames(array $values): static { @@ -1960,6 +1983,8 @@ public function setValueNames(array $values): static /** * Add the custom values for the validator. + * + * @return $this */ public function addCustomValues(array $customValues): static { @@ -2019,6 +2044,7 @@ public function getException(): string|ValidationException * Set the exception to throw upon failed validation. * * @param class-string|Throwable $exception + * @return $this * * @throws InvalidArgumentException */ @@ -2039,6 +2065,7 @@ public function setException(string|Throwable $exception): static * Ensure exponents are within range using the given callback. * * @param callable(int, string, mixed): mixed $callback + * @return $this */ public function ensureExponentWithinAllowedRangeUsing(callable $callback): static { From cd0f6f13dab73f6146aeee7325139ed820304252 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 145/303] Restore fluent view and translation contracts Restore receiver identity for view data, namespaces, component configuration and translation helpers. Match view interfaces to the corresponding fluent implementations. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/contracts/src/View/Factory.php | 4 ++++ src/contracts/src/View/View.php | 2 ++ src/translation/src/ArrayLoader.php | 2 ++ src/translation/src/PotentiallyTranslatedString.php | 4 ++++ src/view/src/Compilers/BladeCompiler.php | 2 ++ src/view/src/Component.php | 4 ++++ src/view/src/ComponentSlot.php | 2 ++ src/view/src/Factory.php | 6 ++++++ src/view/src/FileViewFinder.php | 2 ++ src/view/src/View.php | 6 ++++++ 10 files changed, 34 insertions(+) diff --git a/src/contracts/src/View/Factory.php b/src/contracts/src/View/Factory.php index 0a428cb37..d1404d815 100644 --- a/src/contracts/src/View/Factory.php +++ b/src/contracts/src/View/Factory.php @@ -46,11 +46,15 @@ public function creator(array|string $views, Closure|string $callback): array; /** * Add a new namespace to the loader. + * + * @return $this */ public function addNamespace(string $namespace, string|array $hints): static; /** * Replace the namespace hints for the given namespace. + * + * @return $this */ public function replaceNamespace(string $namespace, string|array $hints): static; diff --git a/src/contracts/src/View/View.php b/src/contracts/src/View/View.php index f6e53bc96..61e4b3174 100644 --- a/src/contracts/src/View/View.php +++ b/src/contracts/src/View/View.php @@ -15,6 +15,8 @@ public function name(): string; /** * Add a piece of data to the view. + * + * @return $this */ public function with(string|array $key, mixed $value = null): static; diff --git a/src/translation/src/ArrayLoader.php b/src/translation/src/ArrayLoader.php index 1478597a0..fea336210 100644 --- a/src/translation/src/ArrayLoader.php +++ b/src/translation/src/ArrayLoader.php @@ -46,6 +46,8 @@ public function addPath(string $path): void /** * Add messages to the loader. + * + * @return $this */ public function addMessages(string $locale, string $group, array $messages, ?string $namespace = null): static { diff --git a/src/translation/src/PotentiallyTranslatedString.php b/src/translation/src/PotentiallyTranslatedString.php index f10ee87dc..cb831e1dd 100644 --- a/src/translation/src/PotentiallyTranslatedString.php +++ b/src/translation/src/PotentiallyTranslatedString.php @@ -29,6 +29,8 @@ public function __construct( /** * Translate the string. + * + * @return $this */ public function translate(array $replace = [], ?string $locale = null): static { @@ -39,6 +41,8 @@ public function translate(array $replace = [], ?string $locale = null): static /** * Translate the string based on a count. + * + * @return $this */ public function translateChoice(array|Countable|float|int $number, array $replace = [], ?string $locale = null): static { diff --git a/src/view/src/Compilers/BladeCompiler.php b/src/view/src/Compilers/BladeCompiler.php index dbcaa7439..2fa557ddd 100644 --- a/src/view/src/Compilers/BladeCompiler.php +++ b/src/view/src/Compilers/BladeCompiler.php @@ -1027,6 +1027,8 @@ public function getCustomDirectives(): array * * Boot-only. The callback persists on the singleton BladeCompiler for the * worker lifetime and runs on every subsequent template compilation. + * + * @return $this */ public function prepareStringsForCompilationUsing(callable $callback): static { diff --git a/src/view/src/Component.php b/src/view/src/Component.php index 05f5c98c1..ec8f836db 100644 --- a/src/view/src/Component.php +++ b/src/view/src/Component.php @@ -312,6 +312,8 @@ protected function ignoredMethods(): array /** * Set the component alias name. + * + * @return $this */ public function withName(string $name): static { @@ -322,6 +324,8 @@ public function withName(string $name): static /** * Set the extra attributes that the component should make available. + * + * @return $this */ public function withAttributes(array $attributes): static { diff --git a/src/view/src/ComponentSlot.php b/src/view/src/ComponentSlot.php index c81f38630..9a6212362 100644 --- a/src/view/src/ComponentSlot.php +++ b/src/view/src/ComponentSlot.php @@ -27,6 +27,8 @@ public function __construct( /** * Set the extra attributes that the slot should make available. + * + * @return $this */ public function withAttributes(array $attributes): static { diff --git a/src/view/src/Factory.php b/src/view/src/Factory.php index 02b948075..4c27f4898 100755 --- a/src/view/src/Factory.php +++ b/src/view/src/Factory.php @@ -454,6 +454,8 @@ public function prependLocation(string $location): void * * Boot-only. The namespace persists on the singleton view finder for the * worker lifetime and affects every subsequent request. + * + * @return $this */ public function addNamespace(string $namespace, string|array $hints): static { @@ -467,6 +469,8 @@ public function addNamespace(string $namespace, string|array $hints): static * * Boot-only. The namespace persists on the singleton view finder for the * worker lifetime and affects every subsequent request. + * + * @return $this */ public function prependNamespace(string $namespace, string|array $hints): static { @@ -480,6 +484,8 @@ public function prependNamespace(string $namespace, string|array $hints): static * * Boot-only. The namespace persists on the singleton view finder for the * worker lifetime and affects every subsequent request. + * + * @return $this */ public function replaceNamespace(string $namespace, string|array $hints): static { diff --git a/src/view/src/FileViewFinder.php b/src/view/src/FileViewFinder.php index 467f9f33f..bf77feb5d 100755 --- a/src/view/src/FileViewFinder.php +++ b/src/view/src/FileViewFinder.php @@ -242,6 +242,8 @@ public function getFilesystem(): Filesystem * * Boot-only. Request-time use changes shared view paths while concurrent * coroutines may still be resolving views. + * + * @return $this */ public function setPaths(array $paths): static { diff --git a/src/view/src/View.php b/src/view/src/View.php index 83d176f03..580873c09 100755 --- a/src/view/src/View.php +++ b/src/view/src/View.php @@ -261,6 +261,8 @@ public function renderSections(): array /** * Add a piece of data to the view. + * + * @return $this */ public function with(string|array $key, mixed $value = null): static { @@ -275,6 +277,8 @@ public function with(string|array $key, mixed $value = null): static /** * Add a view instance to the view data. + * + * @return $this */ public function nest(string $key, string $view, array $data = []): static { @@ -283,6 +287,8 @@ public function nest(string $key, string $view, array $data = []): static /** * Add validation errors to the view. + * + * @return $this */ public function withErrors(MessageProvider|array|string $provider, string $bag = 'default'): static { From d7b2a34ee1171d8af06d6ee11cd602ccb5f0be04 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 146/303] Restore fluent framework integration contracts Restore receiver identity in Horizon configuration and payloads, Scout engines, Pennant configuration and Telescope entries. Preserve all existing runtime and worker behavior. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- src/horizon/src/Events/JobsMigrated.php | 4 ++++ src/horizon/src/Events/RedisEvent.php | 4 ++++ src/horizon/src/Events/SupervisorOutOfMemory.php | 2 ++ src/horizon/src/JobPayload.php | 8 ++++++++ src/horizon/src/MasterSupervisor.php | 2 ++ src/horizon/src/ProcessPool.php | 2 ++ src/horizon/src/Supervisor.php | 2 ++ src/horizon/src/WorkerProcess.php | 4 ++++ src/pennant/src/Drivers/Decorator.php | 2 ++ src/pennant/src/FeatureManager.php | 9 +++++++++ .../src/PendingScopedFeatureInteraction.php | 2 ++ src/scout/src/EngineManager.php | 6 ++++++ src/telescope/src/EntryResult.php | 2 ++ src/telescope/src/EntryUpdate.php | 6 ++++++ src/telescope/src/IncomingEntry.php | 10 ++++++++++ src/telescope/src/Storage/EntryModel.php | 14 ++++++++++++++ src/telescope/src/Storage/EntryQueryOptions.php | 14 ++++++++++++++ 17 files changed, 93 insertions(+) diff --git a/src/horizon/src/Events/JobsMigrated.php b/src/horizon/src/Events/JobsMigrated.php index fb022e6e5..38f45d752 100644 --- a/src/horizon/src/Events/JobsMigrated.php +++ b/src/horizon/src/Events/JobsMigrated.php @@ -41,6 +41,8 @@ public function __construct(array $payloads) /** * Set the connection name. + * + * @return $this */ public function connection(string $connectionName): static { @@ -51,6 +53,8 @@ public function connection(string $connectionName): static /** * Set the queue name. + * + * @return $this */ public function queue(string $queue): static { diff --git a/src/horizon/src/Events/RedisEvent.php b/src/horizon/src/Events/RedisEvent.php index 21879e28c..6dbce41ca 100644 --- a/src/horizon/src/Events/RedisEvent.php +++ b/src/horizon/src/Events/RedisEvent.php @@ -33,6 +33,8 @@ public function __construct(string $payload) /** * Set the connection name. + * + * @return $this */ public function connection(string $connectionName): static { @@ -43,6 +45,8 @@ public function connection(string $connectionName): static /** * Set the queue name. + * + * @return $this */ public function queue(string $queue): static { diff --git a/src/horizon/src/Events/SupervisorOutOfMemory.php b/src/horizon/src/Events/SupervisorOutOfMemory.php index 0bf31d283..de185894c 100644 --- a/src/horizon/src/Events/SupervisorOutOfMemory.php +++ b/src/horizon/src/Events/SupervisorOutOfMemory.php @@ -33,6 +33,8 @@ public function getMemoryUsage(): float|int /** * Set the memory usage that was recorded when the event was dispatched. + * + * @return $this */ public function setMemoryUsage(float|int $memoryUsage): static { diff --git a/src/horizon/src/JobPayload.php b/src/horizon/src/JobPayload.php index 995845858..3e5f0eea7 100644 --- a/src/horizon/src/JobPayload.php +++ b/src/horizon/src/JobPayload.php @@ -92,6 +92,8 @@ public function isSilenced(): bool /** * Prepare the payload for storage on the queue by adding tags, etc. + * + * @return $this */ public function prepare(mixed $job): static { @@ -103,6 +105,8 @@ public function prepare(mixed $job): static /** * Prepare the payload metadata while the original job is available. + * + * @return $this */ public function prepareForQueueing(mixed $job): static { @@ -131,6 +135,8 @@ protected function queueingMetadata(mixed $job): array /** * Mark the payload as published to Redis. + * + * @return $this */ public function markAsPushed(): static { @@ -198,6 +204,8 @@ protected function underlyingJob(mixed $job): mixed /** * Set the given key / value pairs on the payload. + * + * @return $this */ public function set(array $values): static { diff --git a/src/horizon/src/MasterSupervisor.php b/src/horizon/src/MasterSupervisor.php index 5f67803ea..06d4815d5 100644 --- a/src/horizon/src/MasterSupervisor.php +++ b/src/horizon/src/MasterSupervisor.php @@ -317,6 +317,8 @@ public static function commandQueueFor(?string $name = null): string /** * Set the output handler. + * + * @return $this */ public function handleOutputUsing(Closure $callback): static { diff --git a/src/horizon/src/ProcessPool.php b/src/horizon/src/ProcessPool.php index 365adc4f4..3a29b272d 100644 --- a/src/horizon/src/ProcessPool.php +++ b/src/horizon/src/ProcessPool.php @@ -128,6 +128,8 @@ protected function removeProcesses(int $count): void /** * Add a new worker process to the pool. + * + * @return $this */ protected function start(): static { diff --git a/src/horizon/src/Supervisor.php b/src/horizon/src/Supervisor.php index 039af4ec9..4c7ac2758 100644 --- a/src/horizon/src/Supervisor.php +++ b/src/horizon/src/Supervisor.php @@ -398,6 +398,8 @@ public function isPaused(): bool /** * Set the output handler. + * + * @return $this */ public function handleOutputUsing(Closure $callback): static { diff --git a/src/horizon/src/WorkerProcess.php b/src/horizon/src/WorkerProcess.php index 97306884c..556f065f2 100644 --- a/src/horizon/src/WorkerProcess.php +++ b/src/horizon/src/WorkerProcess.php @@ -52,6 +52,8 @@ public function __construct( /** * Start the process. + * + * @return $this */ public function start(Closure $callback): static { @@ -212,6 +214,8 @@ public function coolingDown(): bool /** * Set the output handler. + * + * @return $this */ public function handleOutputUsing(Closure $callback): static { diff --git a/src/pennant/src/Drivers/Decorator.php b/src/pennant/src/Drivers/Decorator.php index 606b929dd..b1ee267b0 100644 --- a/src/pennant/src/Drivers/Decorator.php +++ b/src/pennant/src/Drivers/Decorator.php @@ -1049,6 +1049,8 @@ public function getDriver(): Driver * Set the container instance used by the decorator. * * Tests only. The container persists on the store for the worker lifetime. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/pennant/src/FeatureManager.php b/src/pennant/src/FeatureManager.php index 0d2881137..d7cde1fba 100644 --- a/src/pennant/src/FeatureManager.php +++ b/src/pennant/src/FeatureManager.php @@ -302,6 +302,8 @@ public function serializeScope(mixed $scope): string * * Boot-only. The setting persists on the manager for the worker lifetime and * changes how every subsequent request serializes model scopes. + * + * @return $this */ public function useMorphMap(bool $value = true): static { @@ -469,6 +471,7 @@ public function setDefaultDriver(string $name): void * the current coroutine's values for a forgotten store are flushed. * * @param null|array|string $name + * @return $this */ public function forgetDriver(array|string|null $name = null): static { @@ -490,6 +493,8 @@ public function forgetDriver(array|string|null $name = null): static * * Boot or tests only. Stores are shared by every request in the worker, and * the current coroutine's values for the forgotten stores are flushed. + * + * @return $this */ public function forgetDrivers(): static { @@ -506,6 +511,8 @@ public function forgetDrivers(): static * Register a custom driver creator Closure. * * Boot-only. The creator persists on the manager for the worker lifetime. + * + * @return $this */ public function extend(string $driver, Closure $callback): static { @@ -519,6 +526,8 @@ public function extend(string $driver, Closure $callback): static * * Tests only. The container persists on the manager and its stores for the * worker lifetime. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/pennant/src/PendingScopedFeatureInteraction.php b/src/pennant/src/PendingScopedFeatureInteraction.php index 819192e42..1745feb09 100644 --- a/src/pennant/src/PendingScopedFeatureInteraction.php +++ b/src/pennant/src/PendingScopedFeatureInteraction.php @@ -35,6 +35,8 @@ public function __construct(Decorator $driver) /** * Add scope to the feature interaction. + * + * @return $this */ public function for(mixed $scope): static { diff --git a/src/scout/src/EngineManager.php b/src/scout/src/EngineManager.php index 27cfeddfe..c9027a344 100644 --- a/src/scout/src/EngineManager.php +++ b/src/scout/src/EngineManager.php @@ -241,6 +241,8 @@ public function createNullDriver(): NullEngine * * Boot-only. The callback persists in the singleton's customCreators array * for the worker lifetime and applies to every subsequent engine resolution. + * + * @return $this */ public function extend(string $driver, Closure $callback): static { @@ -255,6 +257,8 @@ public function extend(string $driver, Closure $callback): static * Boot or tests only. Clears the manager's engine cache; concurrent * coroutines may already hold references that next resolution will not * share. + * + * @return $this */ public function forgetEngines(): static { @@ -269,6 +273,8 @@ public function forgetEngines(): static * Boot or tests only. Mutates the manager's engine cache; concurrent * coroutines may already hold a reference to the engine and next resolution * will rebuild. + * + * @return $this */ public function forgetEngine(string $name): static { diff --git a/src/telescope/src/EntryResult.php b/src/telescope/src/EntryResult.php index af7ce40d4..dfc98e4d6 100644 --- a/src/telescope/src/EntryResult.php +++ b/src/telescope/src/EntryResult.php @@ -41,6 +41,8 @@ public function __construct( /** * Set the URL to the entry user's avatar. + * + * @return $this */ public function generateAvatar(): static { diff --git a/src/telescope/src/EntryUpdate.php b/src/telescope/src/EntryUpdate.php index ce9a2bcbe..ffd7b17ab 100644 --- a/src/telescope/src/EntryUpdate.php +++ b/src/telescope/src/EntryUpdate.php @@ -35,6 +35,8 @@ public static function make(mixed ...$arguments): static /** * Set the properties that should be updated. + * + * @return $this */ public function change(array $changes): static { @@ -45,6 +47,8 @@ public function change(array $changes): static /** * Add tags to the entry. + * + * @return $this */ public function addTags(array $tags): static { @@ -57,6 +61,8 @@ public function addTags(array $tags): static /** * Remove tags from the entry. + * + * @return $this */ public function removeTags(array $tags): static { diff --git a/src/telescope/src/IncomingEntry.php b/src/telescope/src/IncomingEntry.php index 8e22dc08f..d0e8e6a2b 100644 --- a/src/telescope/src/IncomingEntry.php +++ b/src/telescope/src/IncomingEntry.php @@ -74,6 +74,8 @@ public static function make(mixed ...$arguments): static /** * Assign the entry a given batch ID. + * + * @return $this */ public function batchId(string $batchId): static { @@ -84,6 +86,8 @@ public function batchId(string $batchId): static /** * Assign the entry a given type. + * + * @return $this */ public function type(string $type): static { @@ -94,6 +98,8 @@ public function type(string $type): static /** * Assign the entry a family hash. + * + * @return $this */ public function withFamilyHash(?string $familyHash): static { @@ -104,6 +110,8 @@ public function withFamilyHash(?string $familyHash): static /** * Set the currently authenticated user. + * + * @return $this */ public function user(Authenticatable $user): static { @@ -124,6 +132,8 @@ public function user(Authenticatable $user): static /** * Merge tags into the entry's existing tags. + * + * @return $this */ public function tags(array $tags): static { diff --git a/src/telescope/src/Storage/EntryModel.php b/src/telescope/src/Storage/EntryModel.php index f57ba228e..1163324cb 100644 --- a/src/telescope/src/Storage/EntryModel.php +++ b/src/telescope/src/Storage/EntryModel.php @@ -78,6 +78,8 @@ public function scopeWithTelescopeOptions(Builder $query, ?string $type, EntryQu /** * Scope the query for the given type. + * + * @return $this */ protected function whereType(Builder $query, ?string $type): static { @@ -90,6 +92,8 @@ protected function whereType(Builder $query, ?string $type): static /** * Scope the query for the given batch ID. + * + * @return $this */ protected function whereBatchId(Builder $query, EntryQueryOptions $options): static { @@ -102,6 +106,8 @@ protected function whereBatchId(Builder $query, EntryQueryOptions $options): sta /** * Scope the query for the given entry UUIDs. + * + * @return $this */ protected function whereUuids(Builder $query, EntryQueryOptions $options): static { @@ -114,6 +120,8 @@ protected function whereUuids(Builder $query, EntryQueryOptions $options): stati /** * Scope the query for the given tag. + * + * @return $this */ protected function whereTag(Builder $query, EntryQueryOptions $options): static { @@ -134,6 +142,8 @@ protected function whereTag(Builder $query, EntryQueryOptions $options): static /** * Scope the query for the given family hash. + * + * @return $this */ protected function whereFamilyHash(Builder $query, EntryQueryOptions $options): static { @@ -146,6 +156,8 @@ protected function whereFamilyHash(Builder $query, EntryQueryOptions $options): /** * Scope the query for the given pagination options. + * + * @return $this */ protected function whereBeforeSequence(Builder $query, EntryQueryOptions $options): static { @@ -158,6 +170,8 @@ protected function whereBeforeSequence(Builder $query, EntryQueryOptions $option /** * Scope the query for the given display options. + * + * @return $this */ protected function filter(Builder $query, EntryQueryOptions $options): static { diff --git a/src/telescope/src/Storage/EntryQueryOptions.php b/src/telescope/src/Storage/EntryQueryOptions.php index 97f50e287..1f822e349 100644 --- a/src/telescope/src/Storage/EntryQueryOptions.php +++ b/src/telescope/src/Storage/EntryQueryOptions.php @@ -68,6 +68,8 @@ public static function forBatchId(?string $batchId): static /** * Set the batch ID for the query. + * + * @return $this */ public function batchId(?string $batchId): static { @@ -78,6 +80,8 @@ public function batchId(?string $batchId): static /** * Set the list of UUIDs of entries to retrieve. + * + * @return $this */ public function uuids(?array $uuids): static { @@ -88,6 +92,8 @@ public function uuids(?array $uuids): static /** * Set the ID that all retrieved entries should be less than. + * + * @return $this */ public function beforeSequence(int|string|null $id): static { @@ -98,6 +104,8 @@ public function beforeSequence(int|string|null $id): static /** * Set the tag that must belong to retrieved entries. + * + * @return $this */ public function tag(?string $tag): static { @@ -108,6 +116,8 @@ public function tag(?string $tag): static /** * Match the tag literally instead of as a search expression. + * + * @return $this */ public function exactTag(bool $exactTag = true): static { @@ -118,6 +128,8 @@ public function exactTag(bool $exactTag = true): static /** * Set the family hash that must belong to retrieved entries. + * + * @return $this */ public function familyHash(?string $familyHash): static { @@ -128,6 +140,8 @@ public function familyHash(?string $familyHash): static /** * Set the number of entries that should be retrieved. + * + * @return $this */ public function limit(int $limit): static { From 8847bcb4798e53d035ce0e2d82a7da381d1e9dd8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:18:57 +0000 Subject: [PATCH 147/303] Record completed fluent annotation restoration Record completion of the reviewed fluent identity restoration and retain the meaningful clone, immutable and dynamic-return exclusions. Resume the remaining PHPStan port flows, with runtime-input tracking and warm-cache verification still required. Docblocks only; runtime bodies and native signatures are unchanged. The combined checkpoint passes formatting, all three PHPStan configurations, 2,468 analyzer tests and facade documentation lint. --- docs/plans/2026-11-04-hypervel-phpstan.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index 924d870b0..c136559ae 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -25,8 +25,9 @@ Remaining work: - Also completed and reviewed: the model-properties/schema flow, including schema-parser impact, casts/accessors, fetched types, schema and forwarding dependencies, the schema-dump diagnostic, the routing and SQLite JSON fixes, Attribute variance, inherited method-template declarations, tests and documentation. - Also completed: the approved `final` cleanup in the PHPStan and schema-parser packages and branch-added fixtures. `readonly` is preserved; the `Comment`/`Comments` type fixtures are unchanged. - Also completed: the Eloquent builder and relation forwarding flow (`BuilderHelper`, the `builder-of`, `collection-of` and `relation-of` types, `ClosureTypes/`, the forwarding return types, the 13.x stub merges, dynamic wheres and stub-validation compliance), with its type and integration fixtures. -- Also completed: the upstream Integration merge (except the entries the checklist assigns to the Eloquent collection and factory flows), with its source corrections, `RelationExistenceRule`, and Laravel's `@return $this` tags restored on the Query and Eloquent builders, `JoinClause` and `QueriesRelationships`. -- Next: restore the remaining missing Laravel `@return $this` tags (see Type inference and stubs) across Components: the rest of Database (Model, Eloquent collection, connection, attribute and relationship concerns, schema, seeder, factories, exceptions) and every other package. Then port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension`, the `toQuery()`/`load()`/`loadMissing()` typing with `eager-loading-callbacks.php`, and the model-dependent collection fixtures listed in the checklist. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. +- Also completed: the upstream Integration merge (except the entries the checklist assigns to the Eloquent collection and factory flows), with its source corrections and `RelationExistenceRule`. +- Also completed: Laravel's `@return $this` tags restored across Components (see Type inference and stubs). +- Next: port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension`, the `toQuery()`/`load()`/`loadMissing()` typing with `eager-loading-callbacks.php`, and the model-dependent collection fixtures listed in the checklist. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -270,7 +271,7 @@ Do not copy `ContractsMethodsExtension`, container ArrayAccess/dynamic-property General type improvements go into source, with dedicated type fixtures and no runtime overhead. Confirmed examples include `Attribute` and the conditional `FormRequest::validated()` result. `Attribute`'s get callback input is a method template (`TValue`) on `__construct()`, `make()` and `get()`, so typed getters such as Laravel's `fn (string $value) => ucfirst($value)` are accepted; `make()`'s `TMakeGet`/`TMakeSet` default to `never`, so `make(get: ...)` resolves. The runtime bodies stay Laravel's. The owner approved declaration variance: `@template-covariant TGet`, `@template-contravariant TSet` and PHPDoc `@readonly` `$get`/`$set` (no native `readonly`), so a declaration may read wider and accept narrower than its callbacks infer (`Attribute` around a `non-falsy-string` getter). Reassigning a callback is reported (`property.readOnlyByPhpDocAssignOutOfClass`) and may be ignored with a reason; model attribute reads and writes are unaffected. PHPStan leaves the constructor's own template unresolved in the inferred class type (`string&TValue`); fixtures do not assert that leak. At max level, source analysis of `set()` reports `return.type` because a missing getter leaves `TGet` unresolved; the repo analyses source at level 5 and callers get the declared type. Class template defaults (`= never`) would remove that report but also stop `missingType.generics` for bare `Attribute` returns, so they are not used. `Enumerable::keyBy()` already declares a new-key template (its bound includes `null`, which both collections key as `''`), and `Builder::make()`/`create()` already return `TModel`; add no stubs for these. PHPStan 2.3.5 does not inherit a parent's method templates into an override whose PHPDoc omits them, so they stay unresolved when the arguments do not infer them. The affected overrides (`Collection::keyBy()`, `LazyCollection::keyBy()`/`mapToDictionary()`/`crossJoin()`/`merge()`/`mergeRecursive()`, `Sentry\Hub::getIntegration()`/`withScope()`) repeat the parent's declaration with an `@TODO` to remove it once PHPStan inherits them. `duplicates()`/`duplicatesStrict()` cannot leak (a callback always infers their template) and the Eloquent collection's overrides declare their own types, so they repeat nothing. Analyzer-only `model-property` refinements on Model/Builder (including `array, mixed>` inputs) and `view-string` refinements on mail/view/route methods live in package stubs, not framework source. The Redis facade's tags and the `RedisManager`/`RedisProxy` mixins already type Hypervel's phpredis-only pooled surface: port the applicable phpredis assertions and add stubs only for demonstrated gaps. Omit Predis stubs, Laravel's client-selection branches and the `redisDriver` parameter; there is no supported alternate transport to select. -Fluent methods keep Laravel's `@return $this` beside the native `: static` wherever the Hypervel body returns the receiving object: native `static` promises the class, not the same instance, so a custom builder declaring `@return $this` and returning a parent call otherwise fails. Check each tag against the method's return paths (clone and new-instance returns keep `static`); replacing stub docblocks repeat it. The auth and notification traits (`MustVerifyEmail`, `CanResetPassword`, `RoutesNotifications`) declare no `email`/`email_verified_at` properties, which Laravel lacks: a trait tag overrides the model's schema and casts (`email_verified_at` lost its cast's write types). Truthful trait contracts stay, such as Nested Set's computed `depth` and relationship properties. `MustVerifyEmail` and `CanResetPassword` keep `@method notify()`, as Foundation `User` uses them without `Notifiable`. The `save()` family's options are `array{touch?: null|bool, ...}` in source and the `update()` stub (only `touch` is read; subclasses may read other keys); `transform()` takes `null|TValue`, as its callback runs only for a filled value; a single `upsert()` row's values are `scalar|object|resource|null` (PDO binds resources as large objects), so a list of rows keeps its column checks. The polymorphic relationship queries with a callback are stubbed with upstream's typing: `TMorphModel` from the `class-string`s in `$types` (a `TType of string` alternative keeps other strings from absorbing them) and the callback's `builder-of`, so an already-typed callback for another model is reported; `'*'` and morph aliases leave `TMorphModel` unresolved, which accepts any callback, and `RelationshipQueryCallbackExtension` still types inline closures from the relation for them. Stub validation needs the empty `BelongsTo` and `MorphTo` declarations this adds. +Fluent methods keep Laravel's `@return $this` beside the native `: static` wherever the Hypervel body returns the receiving object: native `static` promises the class, not the same instance, so a custom builder declaring `@return $this` and returning a parent call otherwise fails. Check each tag against the method's return paths (clone and new-instance returns keep `static`); replacing stub docblocks repeat it. The tags cover the methods Laravel tags, their relocated Hypervel counterparts and Hypervel additions to classes using the convention. Laravel's tags on methods returning another instance (`Stringable::newLine()`, `withoutRelation(s)()`, `assertStructure()`, the `Mailable`/`Fluent` `__call()` macro paths) are not copied. Methods Laravel leaves native-only (AI, MCP, Head, JsonSchema and newer framework APIs) and Hypervel-only packages stay native-only; add a tag where a concrete typing failure needs the identity contract. The auth and notification traits (`MustVerifyEmail`, `CanResetPassword`, `RoutesNotifications`) declare no `email`/`email_verified_at` properties, which Laravel lacks: a trait tag overrides the model's schema and casts (`email_verified_at` lost its cast's write types). Truthful trait contracts stay, such as Nested Set's computed `depth` and relationship properties. `MustVerifyEmail` and `CanResetPassword` keep `@method notify()`, as Foundation `User` uses them without `Notifiable`. The `save()` family's options are `array{touch?: null|bool, ...}` in source and the `update()` stub (only `touch` is read; subclasses may read other keys); `transform()` takes `null|TValue`, as its callback runs only for a filled value; a single `upsert()` row's values are `scalar|object|resource|null` (PDO binds resources as large objects), so a list of rows keeps its column checks. The polymorphic relationship queries with a callback are stubbed with upstream's typing: `TMorphModel` from the `class-string`s in `$types` (a `TType of string` alternative keeps other strings from absorbing them) and the callback's `builder-of`, so an already-typed callback for another model is reported; `'*'` and morph aliases leave `TMorphModel` unresolved, which accepts any callback, and `RelationshipQueryCallbackExtension` still types inline closures from the relation for them. Stub validation needs the empty `BelongsTo` and `MorphTo` declarations this adds. Preserve upstream's Request and Optional `universalObjectCratesClasses` entries: `Request::__get()` resolves arbitrary input/route keys, and `Optional::__get()` forwards an arbitrary property name or returns null. These are supported dynamic APIs, not undefined-property mistakes. Do not add blanket crates for models or other classes to hide missing inference; use their dedicated reflection extensions and preserve explicit property types. Include the documented `$request->name` and Optional property access in typing coverage. Removing either dynamic API would require owner approval. From a8187bb037f3921816d8d6f69bee3eff68199286 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:00:08 +0000 Subject: [PATCH 148/303] Refine Eloquent collection and paginator type declarations Preserve model-valued map results as Eloquent collections and keep non-model results as support collections. Refine eager loading, aggregates and morph maps through analyzer-only parameter annotations while retaining the ordinary source contracts. Apply the same morph-map typing to both paginator families. Update public collection type assertions to match runtime collection identity. No runtime bodies or native signatures change. Combined checkpoint validation: formatting passes, all three PHPStan configurations pass, and the PHPStan suite passes with 2,895 tests and 4,458 assertions. Independently reviewed before committing. --- src/database/src/Eloquent/Collection.php | 40 ++++++++++++++++++- .../src/AbstractCursorPaginator.php | 6 +++ src/pagination/src/AbstractPaginator.php | 6 +++ types/Database/Eloquent/Collection.php | 6 +-- 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index 1885e372e..0fb26f237 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -108,7 +108,10 @@ public function findOrFail(mixed $key): Model|static /** * Load a set of relationships onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param eager-loads|string $relations * @return $this */ public function load(array|string $relations): static @@ -129,7 +132,10 @@ public function load(array|string $relations): static /** * Load a set of aggregations over relationship's column onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations * @return $this * * @throws MissingAttributeException @@ -192,7 +198,10 @@ public function loadAggregate(array|string $relations, Expression|string $column /** * Load a set of relationship counts onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations * @return $this */ public function loadCount(array|string $relations): static @@ -203,7 +212,10 @@ public function loadCount(array|string $relations): static /** * Load a set of relationship's max column values onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations * @return $this */ public function loadMax(array|string $relations, Expression|string $column): static @@ -214,7 +226,10 @@ public function loadMax(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's min column values onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations * @return $this */ public function loadMin(array|string $relations, Expression|string $column): static @@ -225,7 +240,10 @@ public function loadMin(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's column summations onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations * @return $this */ public function loadSum(array|string $relations, Expression|string $column): static @@ -236,7 +254,10 @@ public function loadSum(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's average column values onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations * @return $this */ public function loadAvg(array|string $relations, Expression|string $column): static @@ -247,7 +268,10 @@ public function loadAvg(array|string $relations, Expression|string $column): sta /** * Load a set of related existences onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations * @return $this */ public function loadExists(array|string $relations): static @@ -258,7 +282,10 @@ public function loadExists(array|string $relations): static /** * Load a set of relationships onto the collection if they are not already eager loaded. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param eager-loads|string $relations * @return $this */ public function loadMissing(array|string $relations): static @@ -361,7 +388,10 @@ function (BaseCollection $models) use ($path): void { /** * Load a set of relationships onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-eager-loads $relations * @return $this */ public function loadMorph(string $relation, array $relations): static @@ -379,7 +409,10 @@ public function loadMorph(string $relation, array $relations): static /** * Load a set of relationship counts onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-aggregates $relations * @return $this */ public function loadMorphCount(string $relation, array $relations): static @@ -474,7 +507,7 @@ public function merge($items): static * @template TMapValue * * @param callable(TModel, TKey): TMapValue $callback - * @return BaseCollection|static + * @return (array{TMapValue} is array{Model} ? static : BaseCollection) */ #[Override] public function map(callable $callback): BaseCollection @@ -494,7 +527,7 @@ public function map(callable $callback): BaseCollection * @template TMapWithKeysValue * * @param callable(TModel, TKey): array $callback - * @return BaseCollection|static + * @return (array{TMapWithKeysValue} is array{Model} ? static : BaseCollection) */ #[Override] public function mapWithKeys(callable $callback): BaseCollection @@ -508,7 +541,10 @@ public function mapWithKeys(callable $callback): BaseCollection /** * Reload a fresh model instance from the database for all the entities. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $with + * @phpstan-param eager-loads|string $with * * @throws MissingAttributeException */ diff --git a/src/pagination/src/AbstractCursorPaginator.php b/src/pagination/src/AbstractCursorPaginator.php index 5220139d1..c7cc2fe98 100644 --- a/src/pagination/src/AbstractCursorPaginator.php +++ b/src/pagination/src/AbstractCursorPaginator.php @@ -363,7 +363,10 @@ protected function buildFragment(): string /** * Load a set of relationships onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-eager-loads $relations * @return $this */ public function loadMorph(string $relation, array $relations): static @@ -377,7 +380,10 @@ public function loadMorph(string $relation, array $relations): static /** * Load a set of relationship counts onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-aggregates $relations * @return $this */ public function loadMorphCount(string $relation, array $relations): static diff --git a/src/pagination/src/AbstractPaginator.php b/src/pagination/src/AbstractPaginator.php index ce6d2dd74..e142ceb69 100644 --- a/src/pagination/src/AbstractPaginator.php +++ b/src/pagination/src/AbstractPaginator.php @@ -287,7 +287,10 @@ protected function buildFragment(): string /** * Load a set of relationships onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-eager-loads $relations * @return $this */ public function loadMorph(string $relation, array $relations): static @@ -301,7 +304,10 @@ public function loadMorph(string $relation, array $relations): static /** * Load a set of relationship counts onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-aggregates $relations * @return $this */ public function loadMorphCount(string $relation, array $relations): static diff --git a/types/Database/Eloquent/Collection.php b/types/Database/Eloquent/Collection.php index 1b26627a6..ef3c07d00 100644 --- a/types/Database/Eloquent/Collection.php +++ b/types/Database/Eloquent/Collection.php @@ -132,7 +132,7 @@ function assertEloquentCollectionAggregateExpressionTypes(Collection $collection assertType('Hypervel\Database\Eloquent\Collection', $collection->merge([new User])); assertType( - 'Hypervel\Support\Collection', + 'Hypervel\Database\Eloquent\Collection', $collection->map(function ($user, $int) { assertType('User', $user); assertType('int', $int); @@ -142,7 +142,7 @@ function assertEloquentCollectionAggregateExpressionTypes(Collection $collection ); assertType( - 'Hypervel\Support\Collection', + 'Hypervel\Database\Eloquent\Collection', $collection->mapWithKeys(function ($user, $int) { assertType('User', $user); assertType('int', $int); @@ -151,7 +151,7 @@ function assertEloquentCollectionAggregateExpressionTypes(Collection $collection }) ); assertType( - 'Hypervel\Support\Collection', + 'Hypervel\Database\Eloquent\Collection', $collection->mapWithKeys(function ($user, $int) { return ['string' => new User]; }) From a0d1705ca17fe977af05ed9457fc859dc132e62b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:00:08 +0000 Subject: [PATCH 149/303] Type eager-loading constraints by their individual map keys Resolve eager-load, aggregate and polymorphic map callbacks from each constant key, preserving nested paths, optional entries and unknown-entry fallbacks. Match class-first loading and alias-first MorphTo lookup through the existing booted morph map. Use one late-resolvable map implementation and a small callable parameter reflection. Require callbacks for multiple possible relations to accept the strict union. Refine Model, Builder and MorphTo stubs without replacing collection or paginator class contracts. Combined checkpoint validation: formatting passes, all three PHPStan configurations pass, and the PHPStan suite passes with 2,895 tests and 4,458 assertions. Independently reviewed before committing. --- .../CallableParameterReflection.php | 74 +++ .../Types/EagerLoadMap/EagerLoadMapKind.php | 40 ++ .../Types/EagerLoadMap/EagerLoadMapType.php | 431 ++++++++++++++++++ .../EagerLoadMapTypeNodeResolverExtension.php | 75 +++ .../src/Types/RelationOf/RelationOfType.php | 5 +- .../common/Database/Eloquent/Relations.stub | 29 +- src/phpstan/stubs/common/EloquentBuilder.stub | 7 +- src/phpstan/stubs/common/Model.stub | 152 +++++- 8 files changed, 798 insertions(+), 15 deletions(-) create mode 100644 src/phpstan/src/Reflection/CallableParameterReflection.php create mode 100644 src/phpstan/src/Types/EagerLoadMap/EagerLoadMapKind.php create mode 100644 src/phpstan/src/Types/EagerLoadMap/EagerLoadMapType.php create mode 100644 src/phpstan/src/Types/EagerLoadMap/EagerLoadMapTypeNodeResolverExtension.php diff --git a/src/phpstan/src/Reflection/CallableParameterReflection.php b/src/phpstan/src/Reflection/CallableParameterReflection.php new file mode 100644 index 000000000..aa7a8ef7a --- /dev/null +++ b/src/phpstan/src/Reflection/CallableParameterReflection.php @@ -0,0 +1,74 @@ +name; + } + + /** + * Determine whether the parameter is optional. + */ + public function isOptional(): bool + { + return false; + } + + /** + * Return the parameter type. + */ + public function getType(): Type + { + return $this->type; + } + + /** + * Return the parameter reference mode. + */ + public function passedByReference(): PassedByReference + { + return PassedByReference::createNo(); + } + + /** + * Determine whether the parameter is variadic. + */ + public function isVariadic(): bool + { + return false; + } + + /** + * Return the default value type. + */ + public function getDefaultValue(): ?Type + { + return null; + } +} diff --git a/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapKind.php b/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapKind.php new file mode 100644 index 000000000..1c1af0b7f --- /dev/null +++ b/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapKind.php @@ -0,0 +1,40 @@ + with + * `@template TRelations of array`, so each constant key's callback expects that key's own relation or builder. + * Upstream's stubs union the keys instead, which accepts a callback written for another key's relation. + * Integer keys, keys that name no model and maps without constant keys keep Laravel's value type. Optional + * keys stay optional, and a supplied callback's type never becomes part of the expected type. + */ +// @phpstan-ignore phpstanApi.interface (PHPStan's own late-resolvable types are compound, so comparisons resolve them first.) +class EagerLoadMapType implements CompoundType, LateResolvableType +{ + use LateResolvableTypeTrait; + + /** + * Create an eager-load map type. + */ + public function __construct( + private EagerLoadMapKind $kind, + private ?Type $model, + private Type $map, + private BuilderHelper $builderHelper, + ) { + } + + /** + * Build the expected map from the map's constant keys. + */ + protected function getResult(): Type + { + if ($this->model === null) { + return $this->morphEntries(); + } + + return $this->kind === EagerLoadMapKind::EagerLoads + ? $this->eagerLoads($this->model, $this->map, '', $this->eagerLoadValue()) + : $this->relationAggregates($this->model, $this->map); + } + + /** + * Build the expected eager loads of a model's relations. + * + * Nested arrays follow Builder::prepareNestedWithRelationships(): their keys continue the path from the + * parent key's name without its column list. Laravel declares nested values as any array, so nested + * entries the keys do not type accept any value. + */ + private function eagerLoads(Type $model, Type $map, string $prefix, Type $fallbackValue): Type + { + return $this->mapEntries($map, $fallbackValue, function (int|string $key, Type $value) use ($model, $prefix): ?Type { + if (! is_string($key)) { + return null; + } + + $relationType = (new RelationOfType($model, new ConstantStringType($prefix . $key)))->resolve(); + + return TypeCombinator::union( + $this->callback($relationType), + new StringType, + $this->eagerLoads($model, $this->arrayPart($value), $prefix . explode(':', $key, 2)[0] . '.', new MixedType), + ); + }); + } + + /** + * Build the expected aggregate constraints of a model's relations. + * + * The relation name is read as QueriesRelationships::withAggregate() reads it, without an alias. Aggregates + * do not follow nested arrays: an array value is only the empty, unconstrained form. A relation that does not + * resolve keeps Laravel's value type and its Builder<*> callback, as an unknown eager load gets Laravel's + * Relation<*, *, *>. + */ + private function relationAggregates(Type $model, Type $map): Type + { + return $this->mapEntries($map, $this->relationAggregateValue(), function (int|string $key) use ($model): ?Type { + if (! is_string($key)) { + return null; + } + + $segments = explode(' ', $key); + $relation = count($segments) === 3 && Str::lower($segments[1]) === 'as' ? $segments[0] : $key; + + [$relationType, $unknown] = (new RelationOfType($model, new ConstantStringType($relation)))->resolveRelations(); + + if ($relationType === null || $unknown) { + return null; + } + + $relatedModel = $relationType->getTemplateType(Relation::class, 'TRelatedModel'); + + return TypeCombinator::union( + ConstantArrayTypeBuilder::createEmpty()->getArray(), + $this->callback((new BuilderOfType($relatedModel, $this->builderHelper))->resolve()), + new StringType, + ); + }); + } + + /** + * Build the expected morph-keyed map, typing each key's value for the models it applies to. + */ + private function morphEntries(): Type + { + $fallbackValue = match ($this->kind) { + EagerLoadMapKind::MorphEagerLoads => TypeCombinator::union(new ArrayType((new MixedType)->toArrayKey(), $this->eagerLoadValue()), new StringType), + EagerLoadMapKind::MorphAggregates => TypeCombinator::union(new ArrayType((new MixedType)->toArrayKey(), $this->relationAggregateValue()), new StringType), + // MorphTo declares its maps as any array. The mixed is explicit, as any value is accepted, so stub + // validation does not read the value type as missing. + default => new MixedType(true), + }; + + return $this->mapEntries($this->map, $fallbackValue, function (int|string $key, Type $value, ConstantArrayType $map): ?Type { + $model = $this->morphModel($key, $map); + + if ($model === null) { + return null; + } + + return match ($this->kind) { + EagerLoadMapKind::MorphTypeConstraints => $this->callback((new BuilderOfType($model, $this->builderHelper))->resolve()), + EagerLoadMapKind::MorphAggregates, EagerLoadMapKind::MorphTypeAggregates => TypeCombinator::union( + $this->relationAggregates($model, $this->arrayPart($value)), + new StringType, + ), + default => TypeCombinator::union( + $this->eagerLoads($model, $this->arrayPart($value), '', $this->eagerLoadValue()), + new StringType, + ), + }; + }); + } + + /** + * Get the models a morph map key applies to, or null when it names none. + * + * MorphTo::resolveMorphTypes() maps a key through the morph map before using it as a class name. loadMorph() + * and its siblings read a model's entry by its class, then by its morph alias, so an alias key applies to its + * model only when the map has no key for that class. + */ + private function morphModel(int|string $key, ConstantArrayType $map): ?Type + { + $morphedModel = Relation::getMorphedModel($key); + + if ($this->kind->resolvesAliasFirst()) { + return $this->modelType($morphedModel ?? (string) $key); + } + + $models = []; + + if (($modelType = $this->modelType((string) $key)) !== null) { + $models[] = $modelType; + } + + if ($morphedModel !== null + && (string) Relation::getMorphAlias($morphedModel) === (string) $key + && ! $map->hasOffsetValueType(new ConstantStringType($morphedModel))->yes() + && ($modelType = $this->modelType($morphedModel)) !== null) { + $models[] = $modelType; + } + + return $models === [] ? null : TypeCombinator::union(...$models); + } + + /** + * Get the type of a model class named exactly, as get_class() names it. + */ + private function modelType(string $className): ?Type + { + $modelType = new ObjectType($className); + + return $modelType->getClassReflection()?->getName() === $className + && (new ObjectType(Model::class))->isSuperTypeOf($modelType)->yes() + ? $modelType + : null; + } + + /** + * Build an expected map from a map's constant keys, keeping the fallback value for other entries. + * + * @param callable(int|string, Type, ConstantArrayType): ?Type $expectedValue + */ + private function mapEntries(Type $map, Type $fallbackValue, callable $expectedValue): Type + { + $results = []; + + // Union members are read whole: flattening a shape would expand its optional keys. + foreach ($map instanceof UnionType ? $map->getTypes() : [$map] as $mapType) { + $constantArrays = $mapType->getConstantArrays(); + + if ($constantArrays === []) { + $results[] = new ArrayType((new MixedType)->toArrayKey(), $fallbackValue); + + continue; + } + + foreach ($constantArrays as $constantArray) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($constantArray->getKeyTypes() as $index => $keyType) { + $builder->setOffsetValueType( + $keyType, + $expectedValue($keyType->getValue(), $constantArray->getValueTypes()[$index], $constantArray) ?? $fallbackValue, + $constantArray->isOptionalKey($index), + ); + } + + if ($constantArray->isUnsealed()->yes()) { + $builder->makeUnsealed($constantArray->getUnsealedTypes()[0], $fallbackValue); + } + + $results[] = $builder->getArray(); + } + } + + return TypeCombinator::union(...$results); + } + + /** + * Get the array part of an entry's value, or any array when it has none. + */ + private function arrayPart(Type $value): Type + { + $arrayType = new ArrayType(new MixedType, new MixedType); + + return $value->isArray()->no() ? $arrayType : TypeCombinator::intersect($value, $arrayType); + } + + /** + * Get a callable type that receives the given type. + */ + private function callback(Type $parameterType): Type + { + return new CallableType( + [new CallableParameterReflection('query', $parameterType)], + new MixedType, + false, + ); + } + + /** + * Get Laravel's eager-load value type: array|(callable(Relation<*, *, *>): mixed)|string. + */ + private function eagerLoadValue(): Type + { + return TypeCombinator::union( + new ArrayType(new MixedType, new MixedType), + $this->callback(new GenericObjectType( + Relation::class, + [new MixedType, new MixedType, new MixedType], + null, + null, + array_fill(0, 3, TemplateTypeVariance::createBivariant()), + )), + new StringType, + ); + } + + /** + * Get Laravel's aggregate value type: array{}|(callable(Builder<*>): mixed)|string. + */ + private function relationAggregateValue(): Type + { + return TypeCombinator::union( + ConstantArrayTypeBuilder::createEmpty()->getArray(), + $this->callback(new GenericObjectType(EloquentBuilder::class, [new MixedType], null, null, [TemplateTypeVariance::createBivariant()])), + new StringType, + ); + } + + /** + * Infer the map template from the whole argument, so each key keeps its own value. + */ + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + return $this->map->inferTemplateTypes($receivedType); + } + + /** + * Determine whether the type can be resolved, which waits for its template types. + */ + public function isResolvable(): bool + { + return ! TypeUtils::containsTemplateType($this->map) + && ($this->model === null || ! TypeUtils::containsTemplateType($this->model)); + } + + /** + * Get the classes the type refers to. + * + * @return list + */ + public function getReferencedClasses(): array + { + return array_merge($this->model?->getReferencedClasses() ?? [], $this->map->getReferencedClasses()); + } + + /** + * Get the template types the type refers to. + * + * @return list + */ + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $variance = $positionVariance->compose(TemplateTypeVariance::createInvariant()); + + return array_merge( + $this->model?->getReferencedTemplateTypes($variance) ?? [], + $this->map->getReferencedTemplateTypes($variance), + ); + } + + /** + * Determine whether the type equals another. + */ + public function equals(Type $type): bool + { + return $type instanceof self + && $this->kind === $type->kind + && $this->map->equals($type->map) + && ($this->model === null ? $type->model === null : $type->model !== null && $this->model->equals($type->model)); + } + + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + return $this->kind->value . '<' + . ($this->model === null ? '' : $this->model->describe($level) . ', ') + . $this->map->describe($level) . '>'; + } + + /** + * Map the inner types. + * + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $model = $this->model === null ? null : $cb($this->model); + $map = $cb($this->map); + + if ($model === $this->model && $map === $this->map) { + return $this; + } + + return new self($this->kind, $model, $map, $this->builderHelper); + } + + /** + * Map the inner types together with another eager-load map type's. + */ + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (! $right instanceof self || $right->kind !== $this->kind) { + return $this; + } + + $model = $this->model !== null && $right->model !== null ? $cb($this->model, $right->model) : $this->model; + $map = $cb($this->map, $right->map); + + if ($model === $this->model && $map === $this->map) { + return $this; + } + + return new self($this->kind, $model, $map, $this->builderHelper); + } + + /** + * Convert the type to a PHPDoc node. + */ + public function toPhpDocNode(): TypeNode + { + $types = $this->model === null ? [] : [$this->model->toPhpDocNode()]; + $types[] = $this->map->toPhpDocNode(); + + return new GenericTypeNode(new IdentifierTypeNode($this->kind->value), $types); + } + + /** + * Generalize the inner types. + */ + public function generalize(GeneralizePrecision $precision): Type + { + return $this->traverse(static fn (Type $type) => $type->generalize($precision)); + } +} diff --git a/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapTypeNodeResolverExtension.php b/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapTypeNodeResolverExtension.php new file mode 100644 index 000000000..9693b4ba7 --- /dev/null +++ b/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapTypeNodeResolverExtension.php @@ -0,0 +1,75 @@ +, relation-aggregates or morph map type node. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof GenericTypeNode) { + return null; + } + + $kind = EagerLoadMapKind::tryFrom((string) $typeNode->type); + + if ($kind === null || count($typeNode->genericTypes) !== ($kind->hasModel() ? 2 : 1)) { + return null; + } + + $model = null; + + if ($kind->hasModel()) { + $model = $this->typeNodeResolver->resolve($typeNode->genericTypes[0], $nameScope); + + if ($model instanceof NeverType || (new ObjectType(Model::class))->isSuperTypeOf($model)->no()) { + return null; + } + } + + $map = $this->typeNodeResolver->resolve($typeNode->genericTypes[count($typeNode->genericTypes) - 1], $nameScope); + + if ($map instanceof NeverType || $map->isArray()->no()) { + return null; + } + + return new EagerLoadMapType($kind, $model, $map, $this->builderHelper); + } + + /** + * Set the resolver used for the inner type nodes. + */ + public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void + { + $this->typeNodeResolver = $typeNodeResolver; + } +} diff --git a/src/phpstan/src/Types/RelationOf/RelationOfType.php b/src/phpstan/src/Types/RelationOf/RelationOfType.php index 536cb2242..46e83357d 100644 --- a/src/phpstan/src/Types/RelationOf/RelationOfType.php +++ b/src/phpstan/src/Types/RelationOf/RelationOfType.php @@ -64,12 +64,11 @@ protected function getResult(): Type ); } + // Unlike upstream's benevolent union, a callback for several possible relations must accept each of them. $relation = TypeCombinator::union(...$relations); // Eager loading creates the relation on a fresh model instance. - $relation = TypeTraverser::map($relation, static fn (Type $type, callable $traverse): Type => $type instanceof StaticType ? $type->getStaticObjectType() : $traverse($type)); - - return TypeUtils::toBenevolentUnion($relation); + return TypeTraverser::map($relation, static fn (Type $type, callable $traverse): Type => $type instanceof StaticType ? $type->getStaticObjectType() : $traverse($type)); } /** diff --git a/src/phpstan/stubs/common/Database/Eloquent/Relations.stub b/src/phpstan/stubs/common/Database/Eloquent/Relations.stub index 1ff4caddf..bdad0ba08 100644 --- a/src/phpstan/stubs/common/Database/Eloquent/Relations.stub +++ b/src/phpstan/stubs/common/Database/Eloquent/Relations.stub @@ -23,4 +23,31 @@ class BelongsTo extends Relation * @extends BelongsTo */ class MorphTo extends BelongsTo -{} +{ + /** + * Specify which relations to load for a given morph type. + * + * @template TWith of array + * @param morph-type-eager-loads $with + * @return $this + */ + public function morphWith(array $with): static; + + /** + * Specify which relationship counts to load for a given morph type. + * + * @template TWithCount of array + * @param morph-type-aggregates $withCount + * @return $this + */ + public function morphWithCount(array $withCount): static; + + /** + * Specify constraints on the query for a given morph type. + * + * @template TCallbacks of array + * @param morph-type-constraints $callbacks + * @return $this + */ + public function constrain(array $callbacks): static; +} diff --git a/src/phpstan/stubs/common/EloquentBuilder.stub b/src/phpstan/stubs/common/EloquentBuilder.stub index 85d587218..b9658a12b 100644 --- a/src/phpstan/stubs/common/EloquentBuilder.stub +++ b/src/phpstan/stubs/common/EloquentBuilder.stub @@ -324,8 +324,9 @@ class Builder * Specify relationships that should be eager loaded. * * @template TRelation of string + * @template TRelations of array * - * @param TRelation|array|(callable(relation-of): mixed)|string> $relations + * @param TRelation|eager-loads $relations * @param null|(Closure(relation-of): mixed)|string $callback * @return $this */ @@ -334,9 +335,9 @@ class Builder /** * Set the relationships that should be eager loaded while removing any previously added eager loading specifications. * - * @template TRelation of string + * @template TRelations of array * - * @param string|array|(callable(relation-of): mixed)|string> $relations + * @param eager-loads|string $relations * @return $this */ public function withOnly(array|string $relations): static; diff --git a/src/phpstan/stubs/common/Model.stub b/src/phpstan/stubs/common/Model.stub index e8439ec11..5f66c9751 100644 --- a/src/phpstan/stubs/common/Model.stub +++ b/src/phpstan/stubs/common/Model.stub @@ -2,9 +2,11 @@ namespace Hypervel\Database\Eloquent; +use Hypervel\Contracts\Database\Query\Expression; + /* - * Analyzer-only types: builder-of, model-property and relation-of need this package. The source - * declares the rest, including the property, save() option and increment types upstream stubs. + * Analyzer-only types: builder-of, model-property and the eager-load map types need this package. The + * source declares the rest, including the property, save() option and increment types upstream stubs. */ abstract class Model { @@ -62,8 +64,8 @@ abstract class Model /** * Begin querying a model with eager loading. * - * @template TRelation of string - * @param string|array): mixed)|string|array> $relations + * @template TRelations of array + * @param eager-loads|string $relations * @return builder-of */ public static function with(array|string $relations): Builder; @@ -71,18 +73,152 @@ abstract class Model /** * Eager load relations on the model. * - * @template TRelation of string - * @param string|array): mixed)|string|array> $relations + * @template TRelations of array + * @param eager-loads|string $relations * @return $this */ public function load(array|string $relations): static; + /** + * Eager load relationships on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-eager-loads $relations + * @return $this + */ + public function loadMorph(string $relation, array $relations): static; + /** * Eager load relations on the model if they are not already eager loaded. * - * @template TRelation of string - * @param string|array): mixed)|string|array> $relations + * @template TRelations of array + * @param eager-loads|string $relations * @return $this */ public function loadMissing(array|string $relations): static; + + /** + * Eager load relation's column aggregations on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadAggregate(array|string $relations, Expression|string $column, ?string $function = null): static; + + /** + * Eager load relation counts on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadCount(array|string $relations): static; + + /** + * Eager load relation max column values on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadMax(array|string $relations, Expression|string $column): static; + + /** + * Eager load relation min column values on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadMin(array|string $relations, Expression|string $column): static; + + /** + * Eager load relation's column summations on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadSum(array|string $relations, Expression|string $column): static; + + /** + * Eager load relation average column values on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadAvg(array|string $relations, Expression|string $column): static; + + /** + * Eager load related model existence values on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadExists(array|string $relations): static; + + /** + * Eager load relationship column aggregation on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphAggregate(string $relation, array $relations, Expression|string $column, ?string $function = null): static; + + /** + * Eager load relationship counts on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphCount(string $relation, array $relations): static; + + /** + * Eager load relationship max column values on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphMax(string $relation, array $relations, Expression|string $column): static; + + /** + * Eager load relationship min column values on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphMin(string $relation, array $relations, Expression|string $column): static; + + /** + * Eager load relationship column summations on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphSum(string $relation, array $relations, Expression|string $column): static; + + /** + * Eager load relationship average column values on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphAvg(string $relation, array $relations, Expression|string $column): static; + + /** + * Reload a fresh model instance from the database. + * + * @template TRelations of array + * @param eager-loads|string $with + */ + public function fresh(array|string $with = []): ?static; } From 910d487f44e71920c90c6a2ddc284c9a0604604a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:00:08 +0000 Subject: [PATCH 150/303] Preserve custom model query builders through collection queries Honor a newModelQuery override with a native custom-builder return in the existing builder-selection path, preserving HasBuilder precedence and model binding. Keep PHPDoc-only narrowing outside this bounded selection step to avoid resolving inherited builder-of annotations recursively. Resolve collection toQuery results through the same builder type, including unknown-model collections. Cover custom query entry points, forwarded builder methods, model unions and custom collection results. Combined checkpoint validation: formatting passes, all three PHPStan configurations pass, and the PHPStan suite passes with 2,895 tests and 4,458 assertions. Independently reviewed before committing. --- src/phpstan/src/Methods/BuilderHelper.php | 14 ++ ...ctionToQueryDynamicReturnTypeExtension.php | 68 ++++++++ .../Type/data/custom-eloquent-builder.php | 41 +++++ .../Type/data/custom-eloquent-collection.php | 164 ++++++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 src/phpstan/src/ReturnTypes/EloquentCollectionToQueryDynamicReturnTypeExtension.php create mode 100644 tests/PHPStan/Type/data/custom-eloquent-collection.php diff --git a/src/phpstan/src/Methods/BuilderHelper.php b/src/phpstan/src/Methods/BuilderHelper.php index cb55ff25b..2591598d1 100644 --- a/src/phpstan/src/Methods/BuilderHelper.php +++ b/src/phpstan/src/Methods/BuilderHelper.php @@ -22,6 +22,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\VerbosityLevel; +use ReflectionNamedType; use function count; use function in_array; @@ -171,6 +172,19 @@ private function determineBuilder(ClassReflection $modelReflection): string|Type return $modelReflection->getNativeMethod('newQuery')->getVariants()[0]->getReturnType(); } + // Every query entry point runs newModelQuery(), so an override naming a builder decides. Only its native + // return is read: an undocumented override inherits builder-of, which calls back here, so a + // PHPDoc-only narrowing is not seen. + $newModelQuery = $modelReflection->getNativeReflection()->getMethod('newModelQuery'); + $returnType = $newModelQuery->getReturnType(); + + if ($newModelQuery->getDeclaringClass()->getName() !== Model::class + && $returnType instanceof ReflectionNamedType + && $this->reflectionProvider->hasClass($returnType->getName()) + && $this->reflectionProvider->getClass($returnType->getName())->isSubclassOfClass($this->reflectionProvider->getClass(EloquentBuilder::class))) { + return $returnType->getName(); + } + $method = $modelReflection->getNativeMethod('newEloquentBuilder'); // The override decides on its own, so its declared return is kept even when it is broader than the attribute. diff --git a/src/phpstan/src/ReturnTypes/EloquentCollectionToQueryDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/EloquentCollectionToQueryDynamicReturnTypeExtension.php new file mode 100644 index 000000000..dbff8a463 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/EloquentCollectionToQueryDynamicReturnTypeExtension.php @@ -0,0 +1,68 @@ +. The collection cannot be stubbed, and an analyzer-only + * return type in source would read as mixed without this package, so the source keeps Builder. + */ +#[AutowiredService] +class EloquentCollectionToQueryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension +{ + /** + * Create the toQuery() return type extension. + */ + public function __construct( + private BuilderHelper $builderHelper, + ) { + } + + /** + * Get the class the extension applies to. + */ + public function getClass(): string + { + return Collection::class; + } + + /** + * Determine whether the method is the Eloquent collection's own toQuery(). + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + // A collection that overrides the method has its own contract. + return $methodReflection->getName() === 'toQuery' + && $methodReflection->getDeclaringClass()->getName() === Collection::class; + } + + /** + * Get the builder of the collection's model. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): Type { + $modelType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants()) + ->getReturnType() + ->getTemplateType(Builder::class, 'TModel'); + + return (new BuilderOfType($modelType, $this->builderHelper))->resolve(); + } +} diff --git a/tests/PHPStan/Type/data/custom-eloquent-builder.php b/tests/PHPStan/Type/data/custom-eloquent-builder.php index be4b6a412..671c4afc3 100644 --- a/tests/PHPStan/Type/data/custom-eloquent-builder.php +++ b/tests/PHPStan/Type/data/custom-eloquent-builder.php @@ -96,6 +96,18 @@ function testNonGenericBuilderDocBlock($builder): void assertType('CustomEloquentBuilder\ChildNonGenericBuilder', $builder); } +/** + * Hypervel: every query entry point runs newModelQuery(), so an override's native builder return applies. + */ +function testNewModelQueryOverride(ModelWithOwnModelQuery $model): void +{ + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', ModelWithOwnModelQuery::query()); + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', $model->newQuery()); + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', $model->newQueryWithoutScopes()); + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', $model->newQueryWithoutRelationships()); + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', ModelWithOwnModelQuery::active()); +} + /** * @property string $email * @property string $category @@ -316,3 +328,32 @@ public function newEloquentBuilder($query): EloquentBuilderWithCustomQueryBuilde return new EloquentBuilderWithCustomQueryBuilder($query); } } + +class ModelWithOwnModelQuery extends Model +{ + /** + * Get a new query builder that doesn't have any global scopes or eager loading. + */ + public function newModelQuery(): OwnModelQueryBuilder + { + return (new OwnModelQueryBuilder($this->newBaseQueryBuilder()))->setModel($this); + } +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class OwnModelQueryBuilder extends Builder +{ + /** + * Limit the query to active models. + * + * @return $this + */ + public function active(): static + { + return $this; + } +} diff --git a/tests/PHPStan/Type/data/custom-eloquent-collection.php b/tests/PHPStan/Type/data/custom-eloquent-collection.php new file mode 100644 index 000000000..e8af57d6d --- /dev/null +++ b/tests/PHPStan/Type/data/custom-eloquent-collection.php @@ -0,0 +1,164 @@ +', Account::query()->fromQuery('select * from accounts')); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->fromQuery('select * from accounts')); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->fromQuery('select * from accounts')); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->fromQuery('select * from accounts')); + + assertType('App\AccountCollection', Account::fromQuery('select * from accounts')); + assertType('Hypervel\Database\Eloquent\Collection', User::fromQuery('select * from accounts')); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::fromQuery('select * from accounts')); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::fromQuery('select * from accounts')); + + assertType('App\AccountCollection', Account::query()->hydrate([['active' => 1], ['active' => 0]])); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->hydrate([['active' => 1], ['active' => 0]])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->hydrate([['active' => 1], ['active' => 0]])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->hydrate([['active' => 1], ['active' => 0]])); + + assertType('App\AccountCollection', Account::hydrate([['active' => 1], ['active' => 0]])); + assertType('Hypervel\Database\Eloquent\Collection', User::hydrate([['active' => 1], ['active' => 0]])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::hydrate([['active' => 1], ['active' => 0]])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::hydrate([['active' => 1], ['active' => 0]])); + + assertType('App\AccountCollection', Account::query()->find([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->find([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->find([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->find([1, 2])); + + assertType('App\AccountCollection', Account::find([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::find([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::find([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::find([1, 2])); + + assertType('App\AccountCollection', Account::query()->findMany([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->findMany([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->findMany([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->findMany([1, 2])); + + assertType('App\AccountCollection', Account::findMany([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::findMany([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::findMany([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::findMany([1, 2])); + + assertType('App\AccountCollection', Account::query()->findOrFail([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->findOrFail([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->findOrFail([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->findOrFail([1, 2])); + + assertType('App\AccountCollection', Account::findOrFail([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::findOrFail([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::findOrFail([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::findOrFail([1, 2])); + + // Hypervel: findOr() calls the callback when any of the keys is missing. + assertType('42|App\AccountCollection', Account::query()->findOr([1, 2], fn () => 42)); + assertType('42|Hypervel\Database\Eloquent\Collection', User::query()->findOr([1, 2], fn () => 42)); + assertType('42|App\NonGenericCollection', ModelWithNonGenericCollection::query()->findOr([1, 2], fn () => 42)); + assertType('42|App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->findOr([1, 2], fn () => 42)); + + assertType('42|App\AccountCollection', Account::findOr([1, 2], fn () => 42)); + assertType('42|Hypervel\Database\Eloquent\Collection', User::findOr([1, 2], fn () => 42)); + + assertType('App\AccountCollection', Account::query()->get()); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->get()); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->get()); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->get()); + + assertType('App\AccountCollection', Account::get()); + assertType('Hypervel\Database\Eloquent\Collection', User::get()); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::get()); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::get()); + + assertType('App\AccountCollection', Account::all()); + assertType('Hypervel\Database\Eloquent\Collection', User::all()); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::all()); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::all()); + + assertType('App\AccountCollection', (new User)->accounts()->get()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->get()); + assertType('App\NonGenericCollection', (new User)->modelsWithNonGenericCollection()->get()); + assertType('App\OnlyValueGenericCollection', (new User)->modelsWithOnlyValueGenericCollection()->get()); + + assertType('App\AccountCollection', (new User)->accounts()->getEager()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->getEager()); + assertType('App\NonGenericCollection', (new User)->modelsWithNonGenericCollection()->getEager()); + assertType('App\OnlyValueGenericCollection', (new User)->modelsWithOnlyValueGenericCollection()->getEager()); + + assertType('App\AccountCollection', (new User)->accounts()->createMany([])); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->createMany([])); + assertType('App\NonGenericCollection', (new User)->modelsWithNonGenericCollection()->createMany([])); + assertType('App\OnlyValueGenericCollection', (new User)->modelsWithOnlyValueGenericCollection()->createMany([])); + + assertType('App\AccountCollection', (new User)->accounts()->active()->get()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->active()->get()); + + assertType('App\AccountCollection', (new User)->accounts); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children); + assertType('App\NonGenericCollection', (new User)->modelsWithNonGenericCollection); + assertType('App\OnlyValueGenericCollection', (new User)->modelsWithOnlyValueGenericCollection); + + // Hypervel: BelongsToMany results carry their pivot, as single results do. + assertType('App\RoleCollection', (new User)->roles()->find([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->find([1, 2])); + + assertType('(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})|null', (new User)->roles()->find(1)); + assertType('App\RoleCollection', (new User)->roles()->findOrFail([1, 2])); + + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->findOrFail([1, 2])); + assertType('App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', (new User)->roles()->findOrFail(1)); + + assertType('App\RoleCollection', (new User)->roles()->findMany([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->findMany([1, 2])); + + assertType('App\TransactionCollection', (new User)->transactions()->find([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->find([1, 2])); + assertType('App\Transaction|null', (new User)->transactions()->find(1)); + assertType('App\TransactionCollection', (new User)->transactions()->findOrFail([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->findOrFail([1, 2])); + assertType('App\Transaction', (new User)->transactions()->findOrFail(1)); + assertType('App\TransactionCollection', (new User)->transactions()->findMany([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->findMany([1, 2])); + assertType('App\AccountCollection', (new Group)->accounts()->find([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->find([1, 2])); + assertType('App\Account|null', (new Group)->accounts()->find(1)); + assertType('App\AccountCollection', (new Group)->accounts()->findOrFail([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->findOrFail([1, 2])); + assertType('App\Account', (new Group)->accounts()->findOrFail(1)); + assertType('App\AccountCollection', (new Group)->accounts()->findMany([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->findMany([1, 2])); + assertType('App\AccountCollection', (new User)->accounts->where('active', true)); + assertType('App\AccountCollection', (new User)->accounts->filterByActive()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children->where('active', true)); +} + +/** + * @param \Hypervel\Database\Eloquent\Collection $posts + * @param \Hypervel\Database\Eloquent\Collection $teams + * @param \Hypervel\Database\Eloquent\Collection $users + * @param \Hypervel\Database\Eloquent\Collection $union + * @param \Hypervel\Database\Eloquent\Collection $ownModelQuery + * @param \Hypervel\Database\Eloquent\Collection $unknown + */ +function testToQuery($posts, $teams, $users, $union, $ownModelQuery, $unknown): void +{ + assertType('App\PostBuilder', $posts->toQuery()); + assertType('App\ChildTeamBuilder', $teams->toQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $users->toQuery()); + assertType('App\ChildTeamBuilder|App\PostBuilder', $union->toQuery()); + // Hypervel: toQuery() runs the model's newModelQuery(), including an override, and an unknown model has an + // unknown builder. + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', $ownModelQuery->toQuery()); + assertType('Hypervel\Database\Eloquent\Builder<*>', $unknown->toQuery()); +} From 3a13ff32addf64d421182c784c36e75ee8f4339a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:00:08 +0000 Subject: [PATCH 151/303] Port Eloquent mapping and collection inference coverage Infer support collection results when map callbacks may return non-model values, while keeping declared collection overrides intact. Resolve legacy Eloquent collection-and-array PHPDoc unions through the package type resolver. Port collection mapping, filtering, rejection, static-result and bleeding-edge fixtures. Correct copied calls to nonexistent helpers, retain explicit markers for the known pinned-static PHPStan limitation and remove empty fixture comments. Combined checkpoint validation: formatting passes, all three PHPStan configurations pass, and the PHPStan suite passes with 2,895 tests and 4,458 assertions. Independently reviewed before committing. --- ...ollectionMapDynamicReturnTypeExtension.php | 85 ++++++ ...entCollectionTypeNodeResolverExtension.php | 94 +++++++ tests/PHPStan/Type/BleedingEdgeTypeTest.php | 2 + ...llectionDynamicReturnTypeExtensionTest.php | 3 + ...lectionDynamicReturnTypeExtensionsTest.php | 4 + .../bleeding-edge/closure-usage-inference.php | 20 ++ .../bleeding-edge/eloquent-collection-map.php | 16 ++ tests/PHPStan/Type/data/collection-filter.php | 69 +++++ .../collection-generic-static-methods-l12.php | 25 ++ .../collection-generic-static-methods.php | 258 ++++++++++++++++++ tests/PHPStan/Type/data/collection-reject.php | 64 +++++ tests/PHPStan/Type/data/collection-where.php | 65 +++++ .../Type/data/eloquent-collection-map.php | 244 +++++++++++++++++ .../application/app/RoleCollection.php | 1 - .../application/app/TransactionCollection.php | 1 - .../application/app/UserCollection.php | 12 + 16 files changed, 961 insertions(+), 2 deletions(-) create mode 100644 src/phpstan/src/ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php create mode 100644 src/phpstan/src/Types/GenericEloquentCollectionTypeNodeResolverExtension.php create mode 100644 tests/PHPStan/Type/data/bleeding-edge/closure-usage-inference.php create mode 100644 tests/PHPStan/Type/data/bleeding-edge/eloquent-collection-map.php create mode 100644 tests/PHPStan/Type/data/collection-filter.php create mode 100644 tests/PHPStan/Type/data/collection-generic-static-methods-l12.php create mode 100644 tests/PHPStan/Type/data/collection-generic-static-methods.php create mode 100644 tests/PHPStan/Type/data/collection-reject.php create mode 100644 tests/PHPStan/Type/data/collection-where.php create mode 100644 tests/PHPStan/Type/data/eloquent-collection-map.php create mode 100644 tests/PHPStan/application/app/UserCollection.php diff --git a/src/phpstan/src/ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php new file mode 100644 index 000000000..5855a8474 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php @@ -0,0 +1,85 @@ +getName(), ['map', 'mapWithKeys'], true) + && $methodReflection->getDeclaringClass()->getName() === EloquentCollection::class; + } + + /** + * Get the base collection type when the callback might return a value that is not a model. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + if ($methodCall->getArgs() === []) { + return null; + } + + // The receiver can be a union with other types that have these methods. + $calledOnType = TypeCombinator::intersect( + $scope->getType($methodCall->var), + new ObjectType(EloquentCollection::class), + ); + + // Same as the runtime: map the items as a base collection, then decide which class is kept. + $baseCollectionType = new GenericObjectType(Collection::class, [ + $calledOnType->getTemplateType(Collection::class, 'TKey'), + $calledOnType->getTemplateType(Collection::class, 'TValue'), + ]); + + $baseMethodReflection = $baseCollectionType->getMethod($methodReflection->getName(), $scope); + + $mappedType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $baseMethodReflection->getVariants(), + $baseMethodReflection->getNamedArgumentsVariants(), + )->getReturnType(); + + $mappedValueType = $mappedType->getTemplateType(Collection::class, 'TValue'); + + if (! (new ObjectType(Model::class))->isSuperTypeOf($mappedValueType)->maybe()) { + return null; + } + + return $mappedType; + } +} diff --git a/src/phpstan/src/Types/GenericEloquentCollectionTypeNodeResolverExtension.php b/src/phpstan/src/Types/GenericEloquentCollectionTypeNodeResolverExtension.php new file mode 100644 index 000000000..e553265dd --- /dev/null +++ b/src/phpstan/src/Types/GenericEloquentCollectionTypeNodeResolverExtension.php @@ -0,0 +1,94 @@ + $accounts + * + * Now IDEs can benefit from auto-completion, and we can benefit from the correct type passed to the generic collection + */ +#[AutowiredService] +class GenericEloquentCollectionTypeNodeResolverExtension implements TypeNodeResolverExtension +{ + /** + * Create the generic Eloquent collection type node resolver. + */ + public function __construct(private TypeNodeResolver $typeNodeResolver) + { + } + + /** + * Resolve a two-member union of the Eloquent collection and an array of models. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof UnionTypeNode || count($typeNode->types) !== 2) { + return null; + } + + $arrayTypeNode = null; + $identifierTypeNode = null; + + foreach ($typeNode->types as $innerTypeNode) { + if ($innerTypeNode instanceof ArrayTypeNode) { + $arrayTypeNode = $innerTypeNode; + continue; + } + + if (! $innerTypeNode instanceof IdentifierTypeNode) { + continue; + } + + $identifierTypeNode = $innerTypeNode; + } + + if ($arrayTypeNode === null || $identifierTypeNode === null) { + return null; + } + + $identifierTypeName = $nameScope->resolveStringName($identifierTypeNode->name); + + if ($identifierTypeName !== Collection::class) { + return null; + } + + $innerArrayTypeNode = $arrayTypeNode->type; + + if (! $innerArrayTypeNode instanceof IdentifierTypeNode) { + return null; + } + + $resolvedInnerArrayType = $this->typeNodeResolver->resolve($innerArrayTypeNode, $nameScope); + + return new GenericObjectType($identifierTypeName, [ + new IntegerType, + $resolvedInnerArrayType, + ]); + } +} diff --git a/tests/PHPStan/Type/BleedingEdgeTypeTest.php b/tests/PHPStan/Type/BleedingEdgeTypeTest.php index 9eb5c4f3c..d66b3bf9d 100644 --- a/tests/PHPStan/Type/BleedingEdgeTypeTest.php +++ b/tests/PHPStan/Type/BleedingEdgeTypeTest.php @@ -26,6 +26,8 @@ public static function dataFileAsserts(): iterable { yield from self::gatherAssertTypes(__DIR__ . '/data/bleeding-edge/collection-keys.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/bleeding-edge/collection-usage-inference.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bleeding-edge/closure-usage-inference.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bleeding-edge/eloquent-collection-map.php'); } /** @return string[] */ diff --git a/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionTest.php b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionTest.php index 5ad0e3d60..17e3252e6 100644 --- a/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionTest.php +++ b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionTest.php @@ -21,6 +21,9 @@ public function testFileAsserts( /** @return iterable */ public static function dataFileAsserts(): iterable { + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-filter.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-reject.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-where.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/collection-narrowing-boundaries.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/collection-where-not-null.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/collection-nullsafe-chain.php'); diff --git a/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionsTest.php b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionsTest.php index 19a4aea33..1d1fc61c6 100644 --- a/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionsTest.php +++ b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionsTest.php @@ -25,6 +25,10 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/data/collection-make-static.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/collection-stubs.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/collection-key-types.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-generic-static-methods.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-collection-map.php'); + // REMOVED: collection-generic-static-methods-l11.php; Laravel 11's Eloquent partition() returned static, and Hypervel follows the Laravel 12 base collection. + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-generic-static-methods-l12.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/collection-stubs-l12-20.php'); } diff --git a/tests/PHPStan/Type/data/bleeding-edge/closure-usage-inference.php b/tests/PHPStan/Type/data/bleeding-edge/closure-usage-inference.php new file mode 100644 index 000000000..4f69a1831 --- /dev/null +++ b/tests/PHPStan/Type/data/bleeding-edge/closure-usage-inference.php @@ -0,0 +1,20 @@ +', $query); + }; + User::query()->whereHas('accounts', $constrain); + + $each = function ($user): void { + assertType('App\User', $user); + }; + User::all()->each($each); +} diff --git a/tests/PHPStan/Type/data/bleeding-edge/eloquent-collection-map.php b/tests/PHPStan/Type/data/bleeding-edge/eloquent-collection-map.php new file mode 100644 index 000000000..eb92a5812 --- /dev/null +++ b/tests/PHPStan/Type/data/bleeding-edge/eloquent-collection-map.php @@ -0,0 +1,16 @@ + $users */ +function mappedTypesAreInferredFromUsages(EloquentCollection $users): void +{ + $labels = $users->map(fn ($user) => 'unknown'); + $labels->push('other'); + assertType("Hypervel\Support\Collection", $labels); +} diff --git a/tests/PHPStan/Type/data/collection-filter.php b/tests/PHPStan/Type/data/collection-filter.php new file mode 100644 index 000000000..aa1ea3758 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-filter.php @@ -0,0 +1,69 @@ + 1; +} + +/** + * @param EloquentCollection $users + * @param SupportCollection $mixedCollection + */ +function test(User $user, SupportCollection $users, SupportCollection $mixedCollection): void +{ + assertType("Hypervel\Support\Collection<(int|string), mixed~(0|0.0|''|'0'|array{}|false|null)>", collect()->filter()); + + assertType('Hypervel\Support\Collection', collect(['foo', null, '', 'bar', null])->filter()); + + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->filter(function (int $value) { + return $value > 2; + })); + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->filter(fn (int $value) => $value > 2)); + + assertType("Hypervel\Database\Eloquent\Collection", $users->filter(function (User $user): bool { + return ! $user->blocked; + })); + assertType("Hypervel\Database\Eloquent\Collection", $users->filter(fn (User $user) => ! $user->blocked)); + + assertType( + 'Hypervel\Support\Collection', + collect($users->all()) + ->map(function (User $attachment): ?Account { + return convertToAccount($attachment); + }) + ->filter() + ); + + $accounts = $user->accounts()->active()->get(); + assertType('App\AccountCollection', $accounts); + + // Hypervel: calls this file's helper; upstream calls a CollectionStubs function that does not exist. + assertType('App\AccountCollection', $accounts->filter(function ($account) { + return dummyFilter($account); + })); + + $accounts->filter(function ($account) { + return dummyFilter($account); + }) + ->map(function ($account) { + assertType('App\Account', $account); + }); + + assertType("Hypervel\Support\Collection<(int|string), mixed~(0|0.0|''|'0'|array{}|false|null)>", $mixedCollection->pluck('foo')->filter()); +} diff --git a/tests/PHPStan/Type/data/collection-generic-static-methods-l12.php b/tests/PHPStan/Type/data/collection-generic-static-methods-l12.php new file mode 100644 index 000000000..32aefadff --- /dev/null +++ b/tests/PHPStan/Type/data/collection-generic-static-methods-l12.php @@ -0,0 +1,25 @@ + $collection + * @param TransactionCollection $customEloquentCollection + */ +function test( + EloquentCollection $collection, + TransactionCollection $customEloquentCollection, + UserCollection $secondCustomEloquentCollection, +): void { + assertType('Hypervel\Support\Collection, Hypervel\Database\Eloquent\Collection>', $collection->partition('foo')); + assertType('Hypervel\Support\Collection, App\TransactionCollection>', $customEloquentCollection->partition('foo')); + assertType('Hypervel\Support\Collection, App\UserCollection>', $secondCustomEloquentCollection->partition('foo')); +} diff --git a/tests/PHPStan/Type/data/collection-generic-static-methods.php b/tests/PHPStan/Type/data/collection-generic-static-methods.php new file mode 100644 index 000000000..dfab2d465 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-generic-static-methods.php @@ -0,0 +1,258 @@ + $collection + * @param SupportCollection $items + * @param TransactionCollection $customEloquentCollection + * @param UserCollection $secondCustomEloquentCollection + * @param LazyCollection $lazyCollection + * @param User $user + * @param Enumerable $enumerableIntUsers + * @param Enumerable $enumerableStringUsers + */ +function test( + EloquentCollection $collection, SupportCollection $items, + TransactionCollection $customEloquentCollection, UserCollection $secondCustomEloquentCollection, + LazyCollection $lazyCollection, User $user, + Enumerable $enumerableIntUsers, Enumerable $enumerableStringUsers +): void { + assertType('Hypervel\Database\Eloquent\Collection', EloquentCollection::range(1, 10)); + assertType('Hypervel\Support\LazyCollection', LazyCollection::range(1, 10)); + + assertType('Hypervel\Support\Collection', $collection->collapse()); + assertType('Hypervel\Support\Collection', $items->collapse()); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->crossJoin([1])); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->find($items)); + assertType('Hypervel\Database\Eloquent\Collection', $collection->find([1])); + assertType('App\User|null', $collection->find($user)); + assertType('App\User|null', $collection->find(1)); + assertType('App\User|false', $collection->find(1, false)); + + assertType('App\TransactionCollection', $customEloquentCollection->find($items)); + assertType('App\TransactionCollection', $customEloquentCollection->find([1])); + assertType('App\Transaction|null', $customEloquentCollection->find($user)); + assertType('App\Transaction|null', $customEloquentCollection->find(1)); + assertType('App\Transaction|false', $customEloquentCollection->find(1, false)); + + assertType('App\UserCollection', $secondCustomEloquentCollection->find($items)); + assertType('App\UserCollection', $secondCustomEloquentCollection->find([1])); + assertType('App\User|null', $secondCustomEloquentCollection->find($user)); + assertType('App\User|null', $secondCustomEloquentCollection->find(1)); + assertType('App\User|false', $secondCustomEloquentCollection->find(1, false)); + + assertType('Hypervel\Support\Collection', $collection->flatten()); + assertType('Hypervel\Support\Collection', $items->flatten()); + + // Hypervel: flip() keys are array keys: values that cannot be keys are skipped, and numeric strings become integers. + assertType('Hypervel\Support\Collection<(int|string), int>', $collection->flip()); + assertType('Hypervel\Support\Collection<(int|string), string>', $items->flip()); + + assertType('Hypervel\Database\Eloquent\Collection<(int|string), Hypervel\Database\Eloquent\Collection>', $collection->groupBy('id')); + assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $items->groupBy('id')); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->keyBy(fn (User $user, int $key): string => $user->email)); + + assertType('Hypervel\Support\Collection', $collection->keys()); + assertType('Hypervel\Support\Collection', $items->keys()); + + assertType('Hypervel\Support\Collection<(int|string), mixed>', $collection->pluck(['email'])); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $items->pluck('1')); + + assertType('Hypervel\Support\Collection', $customEloquentCollection->map(fn (Transaction $transaction): int => $transaction->id)); + // Hypervel: $user->id is int here and below, from the incrementing key's implicit cast. + assertType('Hypervel\Support\Collection', $secondCustomEloquentCollection->map(fn (User $user): int => $user->id)); + assertType('Hypervel\Support\Collection', $collection->map(fn (User $user): int => $user->id)); + assertType('Hypervel\Support\Collection', $collection->map(fn (User $user): ?User => $user->id > 5 ? $user : null)); + assertType('Hypervel\Support\Collection', $items->map(fn (int $value, string $key): int => $value)); + + assertType('App\TransactionCollection', $customEloquentCollection->map(fn (Transaction $transaction): Transaction => $transaction)); + assertType('App\UserCollection', $secondCustomEloquentCollection->map(fn (User $user): User => $user)); + assertType('Hypervel\Database\Eloquent\Collection', $collection->map(fn (User $user): User => $user)); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->mapToDictionary(fn (User $u) => [$u->id => $u->id])); + assertType('App\TransactionCollection>', $customEloquentCollection->mapToDictionary(fn (Transaction $t) => ['foo'=> $t->id])); + // Hypervel: known PHPStan limitation. A static<...> return keeps the fixed int keys and User items of a class + // like UserCollection, so results marked with this limitation do not match runtime. This one holds arrays under + // string keys. + assertType('App\UserCollection', $secondCustomEloquentCollection->mapToDictionary(fn (User $t) => ['foo'=> $t->id])); + assertType('Hypervel\Support\Collection>', $items->mapToDictionary(fn (int $v) => ['foo' => $v])); + + assertType('Hypervel\Support\Collection', $customEloquentCollection->mapWithKeys(fn (Transaction $transaction): array => [$transaction->id => 'foo'])); + assertType('Hypervel\Support\Collection', $secondCustomEloquentCollection->mapWithKeys(fn (User $user): array => [$user->id => 'foo'])); + assertType('Hypervel\Support\Collection', $collection->mapWithKeys(fn (User $user): array => [$user->id => $user->id])); + assertType('Hypervel\Support\Collection', $items->mapWithKeys(fn (int $value, string $key): array => ['foo' => $value])); + + assertType('App\TransactionCollection', $customEloquentCollection->mapWithKeys(fn (Transaction $transaction): array => [$transaction->id => $transaction])); + // Hypervel: known PHPStan limitation; this holds string keys. + assertType('App\UserCollection', $secondCustomEloquentCollection->mapWithKeys(fn (User $user): array => [$user->email => $user])); + assertType('Hypervel\Database\Eloquent\Collection', $collection->mapWithKeys(fn (User $user): array => [$user->name => $user])); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->mergeRecursive([2 => 'foo'])); + assertType('App\TransactionCollection', $customEloquentCollection->mergeRecursive([1 => new Transaction()])); + assertType('App\UserCollection', $secondCustomEloquentCollection->mergeRecursive([1 => new User()])); + // Hypervel: mergeRecursive() turns values under a repeated string key into arrays. + assertType('Hypervel\Support\Collection|int>', $items->mergeRecursive(['foo' => 2])); + + assertType('Hypervel\Database\Eloquent\Collection<(int|string), int>', $collection->combine([1])); + assertType('App\TransactionCollection<(int|string), int>', $customEloquentCollection->combine([1])); + // Hypervel: known PHPStan limitation; this holds the given values. + assertType('App\UserCollection', $secondCustomEloquentCollection->combine([1])); + // Hypervel: combine() keys keep the values' type when it is an array-key type. + assertType('Hypervel\Support\Collection', $items->combine(['foo'])); + + assertType('App\User|null', $collection->pop(1)); + assertType('App\TransactionCollection', $customEloquentCollection->pop(2)); + assertType('App\UserCollection', $secondCustomEloquentCollection->pop(2)); + assertType('Hypervel\Support\Collection', $items->pop(3)); + + // Hypervel: BelongsToMany results carry their pivot. + assertType('App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', User::firstOrFail(1)->roles->random()); + assertType('App\RoleCollection', User::firstOrFail(1)->roles->random(1)); + assertType('Hypervel\Database\Eloquent\Collection', $collection->random(1)); + assertType('App\TransactionCollection', $customEloquentCollection->random(2)); + assertType('App\UserCollection', $secondCustomEloquentCollection->random(2)); + assertType('Hypervel\Support\Collection', $items->random(3)); + + assertType('App\User|null', $collection->shift(1)); + assertType('App\TransactionCollection', $customEloquentCollection->shift(2)); + assertType('App\UserCollection', $secondCustomEloquentCollection->shift(2)); + assertType('Hypervel\Support\Collection', $items->shift(3)); + + // Hypervel: sliding() builds its windows through map(), which returns a base collection of collections. + assertType('Hypervel\Support\Collection>', $collection->sliding(1)); + assertType('Hypervel\Support\Collection>', $customEloquentCollection->sliding(2)); + assertType('Hypervel\Support\Collection', $secondCustomEloquentCollection->sliding(2)); + assertType('Hypervel\Support\Collection>', $items->sliding(3)); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->split(1)); + assertType('App\TransactionCollection>', $customEloquentCollection->split(2)); + // Hypervel: known PHPStan limitation; this holds UserCollection chunks. + assertType('App\UserCollection', $secondCustomEloquentCollection->split(2)); + assertType('Hypervel\Support\Collection>', $items->split(3)); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->splitIn(1)); + assertType('App\TransactionCollection>', $customEloquentCollection->splitIn(2)); + // Hypervel: known PHPStan limitation; this holds UserCollection chunks. + assertType('App\UserCollection', $secondCustomEloquentCollection->splitIn(2)); + assertType('Hypervel\Support\Collection>', $items->splitIn(3)); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->chunk(1)); + assertType('App\TransactionCollection>', $customEloquentCollection->chunk(2)); + // Hypervel: known PHPStan limitation; this holds UserCollection chunks. + assertType('App\UserCollection', $secondCustomEloquentCollection->chunk(2)); + assertType('Hypervel\Support\Collection>', $items->chunk(3)); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->chunkWhile(fn (User $u) => $u->id > 5)); + assertType('App\TransactionCollection>', $customEloquentCollection->chunkWhile(fn (Transaction $t) => $t->id > 5)); + // Hypervel: known PHPStan limitation; this holds UserCollection chunks. + assertType('App\UserCollection', $secondCustomEloquentCollection->chunkWhile(fn (User $t) => $t->id > 5)); + assertType('Hypervel\Support\Collection>', $items->chunkWhile(fn ($v) => $v > 5)); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->values()); + assertType('App\TransactionCollection', $customEloquentCollection->values()); + assertType('App\UserCollection', $secondCustomEloquentCollection->values()); + assertType('Hypervel\Support\Collection', $items->values()); + + assertType('Hypervel\Support\Collection>', $collection->zip([1])); + assertType('Hypervel\Support\Collection>', $customEloquentCollection->zip(['foo'])); + assertType('Hypervel\Support\Collection>', $secondCustomEloquentCollection->zip(['foo'])); + assertType('Hypervel\Support\Collection>', $items->zip(['foo', 'bar'])); + + assertType('Hypervel\Support\Collection, Hypervel\Support\Collection>', $items->partition('foo')); + + assertType('Hypervel\Support\Collection', $collection->pad(10, 10)); + assertType('Hypervel\Support\Collection', $customEloquentCollection->pad(10, 'foo')); + assertType('Hypervel\Support\Collection', $secondCustomEloquentCollection->pad(10, 'foo')); + // Hypervel: pad() keeps string keys, as array_pad() does. + assertType('Hypervel\Support\Collection', $items->pad(1, 'bar')); + + assertType('Hypervel\Support\Collection<(int|string), int>', $collection->countBy('email')); + assertType('Hypervel\Support\Collection<(int|string), int>', $customEloquentCollection->countBy('foo')); + assertType('Hypervel\Support\Collection<(int|string), int>', $secondCustomEloquentCollection->countBy('foo')); + assertType('Hypervel\Support\Collection<(int|string), int>', $items->countBy('bar')); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->concat([new User()])); + assertType('App\TransactionCollection', $customEloquentCollection->concat([new User()])); + assertType('App\UserCollection', $secondCustomEloquentCollection->concat([new User()])); + assertType('Hypervel\Support\Collection', $items->concat([new User()])); + + //////////////////////////// + // EnumeratesValues Trait // + //////////////////////////// + + assertType('Hypervel\Database\Eloquent\Collection', EloquentCollection::make([new User()])); + assertType('App\TransactionCollection', TransactionCollection::make([new Transaction()])); + assertType('Hypervel\Support\Collection', SupportCollection::make([1, 2, 3])); + + assertType('Hypervel\Database\Eloquent\Collection<(int|string), App\User>', EloquentCollection::wrap([new User()])); + assertType('App\TransactionCollection<(int|string), App\Transaction>', TransactionCollection::wrap([new Transaction()])); + assertType('Hypervel\Support\Collection<(int|string), int>', SupportCollection::wrap([1, 2, 3])); + + // Hypervel: times() maps through map(), which can return a base collection, so it declares the base collection. + assertType('Hypervel\Support\Collection', EloquentCollection::times(10, fn ($int) => new User)); + assertType('Hypervel\Support\Collection', TransactionCollection::times(10, fn ($int) => new Transaction)); + assertType('Hypervel\Support\Collection', SupportCollection::times(10, fn ($int) => 5)); + assertType('Hypervel\Support\LazyCollection', LazyCollection::times(10, fn ($int) => 5)); + + // Hypervel: matches the runtime, where map() returns a base collection of collections. + assertType( + 'Hypervel\Support\Collection>', + $collection->mapToGroups(fn (User $user, int $key): array => ['foo' => $user->id]) + ); + + // Hypervel: flatMap() collapses a map() of arrays, which is a base collection. + assertType( + 'Hypervel\Support\Collection', + $collection->flatMap(function (User $user, int $id) { + return [$user->id]; + }) + ); + + assertType( + 'Hypervel\Support\Collection', + $items->flatMap(function (int $int) { + return [$int * 2]; + }) + ); + + assertType( + 'Hypervel\Support\LazyCollection', + $lazyCollection->flatMap(function (User $user, int $id) { + return [$user->id]; + }) + ); + + assertType( + 'Hypervel\Support\LazyCollection', + LazyCollection::times(10, fn ($int) => 5)->flatMap(fn (int $i) => [$i * 2]), + ); + + assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', collect([ + [ + 'id' => 1, + 'type' => 'A', + ], + [ + 'id' => 1, + 'type' => 'B', + ], + ])->groupBy('type')); + + assertType('int|false', $enumerableIntUsers->search(fn(User $user) => $user->id === 1)); + assertType('string|false', $enumerableStringUsers->search(fn(User $user) => $user->id === 1)); +} diff --git a/tests/PHPStan/Type/data/collection-reject.php b/tests/PHPStan/Type/data/collection-reject.php new file mode 100644 index 000000000..898b5c198 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-reject.php @@ -0,0 +1,64 @@ + 1; +} + +/** @param EloquentCollection $users */ +function test(User $user, SupportCollection $users): void +{ + assertType("Hypervel\Support\Collection<(int|string), 0|0.0|''|'0'|array{}|false|null>", collect()->reject()); + + assertType("Hypervel\Support\Collection", collect(['foo', null, '', 'bar', null])->reject()); + + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->reject(function (int $value) { + return $value > 2; + })); + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->reject(fn (int $value) => $value > 2)); + + assertType("Hypervel\Database\Eloquent\Collection", $users->reject(function (User $user): bool { + return ! $user->blocked; + })); + assertType("Hypervel\Database\Eloquent\Collection", $users->reject(fn (User $user) => ! $user->blocked)); + + assertType( + 'Hypervel\Support\Collection', + collect($users->all()) + ->map(function (User $attachment): ?Account { + return convertToAccount($attachment); + }) + ->reject() + ); + + $accounts = $user->accounts()->active()->get(); + assertType('App\AccountCollection', $accounts); + + // Hypervel: calls this file's helper; upstream calls a CollectionStubs function that does not exist. + assertType('App\AccountCollection', $accounts->reject(function ($account) { + return dummyReject($account); + })); + + $accounts->reject(function ($account) { + return dummyReject($account); + }) + ->map(function ($account) { + assertType('App\Account', $account); + }); +} diff --git a/tests/PHPStan/Type/data/collection-where.php b/tests/PHPStan/Type/data/collection-where.php new file mode 100644 index 000000000..5a5103e08 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-where.php @@ -0,0 +1,65 @@ + 1; +} + +/** + * @param EloquentCollection $users + * @param SupportCollection $mixedCollection + */ +function test(User $user, SupportCollection $users, SupportCollection $mixedCollection): void +{ + assertType("Hypervel\Support\Collection<(int|string), mixed>", collect()->where('foo', '<>', 1)); + + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->where(function (int $value) { + return $value > 2; + })); + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->where(fn (int $value) => $value > 2)); + + assertType("Hypervel\Database\Eloquent\Collection", $users->where(function (User $user): bool { + return ! $user->blocked; + })); + assertType("Hypervel\Database\Eloquent\Collection", $users->where(fn (User $user) => ! $user->blocked)); + + assertType( + 'Hypervel\Support\Collection', + collect($users->all()) + ->map(function (User $attachment): ?Account { + return convertToAccount($attachment); + }) + ->where(fn($user) => $user) + ); + + $accounts = $user->accounts()->active()->get(); + assertType('App\AccountCollection', $accounts); + + // Hypervel: calls this file's helper; upstream calls a CollectionStubs function that does not exist. + assertType('App\AccountCollection', $accounts->where(function ($account) { + return dummyFilter($account); + })); + + $accounts->where(function ($account) { + return dummyFilter($account); + }) + ->map(function ($account) { + assertType('App\Account', $account); + }); +} diff --git a/tests/PHPStan/Type/data/eloquent-collection-map.php b/tests/PHPStan/Type/data/eloquent-collection-map.php new file mode 100644 index 000000000..dbdd17808 --- /dev/null +++ b/tests/PHPStan/Type/data/eloquent-collection-map.php @@ -0,0 +1,244 @@ + $users + * @param EloquentCollection $keyedUsers + * @param TransactionCollection $transactions + * @param OnlyValueGenericCollection $onlyValue + */ +function neverReturnsModel( + EloquentCollection $users, + EloquentCollection $keyedUsers, + TransactionCollection $transactions, + UserCollection $userCollection, + OnlyValueGenericCollection $onlyValue, +): void { + // Hypervel: $user->id is int here and below, from the incrementing key's implicit cast. + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => $user->id)); + assertType('Hypervel\Support\Collection', $users->map(function ($user) { + return $user->name; + })); + assertType('Hypervel\Support\Collection', $keyedUsers->map(fn ($user, $key) => $key)); + assertType('Hypervel\Support\Collection', $transactions->map(fn ($transaction) => $transaction->id)); + assertType('Hypervel\Support\Collection', $userCollection->map(fn ($user) => $user->name)); + assertType('Hypervel\Support\Collection', $onlyValue->map(fn ($model) => 1)); + assertType('Hypervel\Support\Collection>', $users->map(fn ($user) => $user->accounts)); + + // Constant values are generalized, so the resulting collection can be written to and returned. + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => 'foo')); + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => true)); + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => ['id' => $user->id, 'name' => $user->name])); + assertType('Hypervel\Support\Collection', User::query()->get()->map(fn ($user) => ['id' => $user->id, 'name' => $user->name])); + + assertType('Hypervel\Support\Collection', $users->mapWithKeys(fn ($user) => [$user->id => 0.0])); + assertType('Hypervel\Support\Collection', $users->mapWithKeys(fn ($user) => ['foo' => $user->id])); + assertType('Hypervel\Support\Collection', $users->mapWithKeys(fn ($user) => [$user->name => ['a' => 1]])); + assertType('Hypervel\Support\Collection', $users->mapWithKeys(fn ($user) => ['a' => 1, 'b' => 'x'])); + assertType('Hypervel\Support\Collection', $transactions->mapWithKeys(fn ($transaction) => [$transaction->id => 'foo'])); + assertType('Hypervel\Support\Collection', $onlyValue->mapWithKeys(fn ($model) => ['a' => 1])); +} + +/** + * A callback that always returns a model keeps the collection class it was called on. + * + * @param EloquentCollection $users + * @param EloquentCollection $keyedUsers + * @param TransactionCollection $transactions + * @param OnlyValueGenericCollection $onlyValue + */ +function alwaysReturnsModel( + EloquentCollection $users, + EloquentCollection $keyedUsers, + TransactionCollection $transactions, + UserCollection $userCollection, + OnlyValueGenericCollection $onlyValue, +): void { + assertType('Hypervel\Database\Eloquent\Collection', $users->map(fn ($user) => $user)); + assertType('Hypervel\Database\Eloquent\Collection', $users->map(fn ($user) => new Account())); + assertType('Hypervel\Database\Eloquent\Collection', $users->map(fn ($user) => $user->id > 5 ? $user : new Account())); + assertType('Hypervel\Database\Eloquent\Collection', $keyedUsers->map(fn ($user) => $user)); + assertType('App\TransactionCollection', $transactions->map(fn ($transaction) => $transaction)); + assertType('App\TransactionCollection', $transactions->map(fn ($transaction) => new User())); + assertType('App\UserCollection', $userCollection->map(fn ($user) => $user)); + assertType('App\OnlyValueGenericCollection', $onlyValue->map(fn ($model) => $model)); + assertType('App\OnlyValueGenericCollection', $onlyValue->map(fn ($model) => new User())); + + assertType('Hypervel\Database\Eloquent\Collection', $users->mapWithKeys(fn ($user) => [$user->name => $user])); + assertType('Hypervel\Database\Eloquent\Collection', $users->mapWithKeys(fn ($user) => [$user->name => $user, 5 => $user])); + assertType('App\TransactionCollection', $transactions->mapWithKeys(fn ($transaction) => [$transaction->id => $transaction])); + assertType('App\TransactionCollection', $transactions->mapWithKeys(fn ($transaction) => ['user' => new User()])); + // Hypervel: known PHPStan limitation. A static<...> return keeps each class's fixed int keys, so these two hold + // string keys at runtime. + assertType('App\UserCollection', $userCollection->mapWithKeys(fn ($user) => [$user->name => $user])); + assertType('App\OnlyValueGenericCollection', $onlyValue->mapWithKeys(fn ($model) => ['a' => $model])); +} + +/** + * One value that is not a model turns the whole result into a base collection, + * so a callback that might return either cannot promise an Eloquent collection. + * + * @param EloquentCollection $users + * @param EloquentCollection $keyedUsers + * @param TransactionCollection $transactions + * @param OnlyValueGenericCollection $onlyValue + * @param EloquentCollection|null $nullableUsers + * @param callable(User): (User|null) $nullableUserCallback + */ +function mightReturnModel( + EloquentCollection $users, + EloquentCollection $keyedUsers, + TransactionCollection $transactions, + OnlyValueGenericCollection $onlyValue, + EloquentCollection|null $nullableUsers, + callable $callback, + callable $nullableUserCallback, +): void { + assertType('Hypervel\Support\Collection', $users->map(fn (User $user): ?User => $user->id > 5 ? $user : null)); + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => $user->id > 5 ? $user : null)); + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => $user->group)); + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => $user->id > 5 ? $user : $user->name)); + assertType('Hypervel\Support\Collection', $users->map($callback)); + assertType('Hypervel\Support\Collection', $users->map($nullableUserCallback)); + assertType('Hypervel\Support\Collection', $keyedUsers->map(fn ($user) => $user->id > 5 ? $user : null)); + assertType('Hypervel\Support\Collection', $transactions->map(fn ($transaction) => $transaction->id > 5 ? $transaction : null)); + assertType('Hypervel\Support\Collection', $onlyValue->map(fn ($model) => $model->exists ? $model : null)); + assertType('Hypervel\Support\Collection|null', $nullableUsers?->map(fn ($user) => $user->id > 5 ? $user : null)); + + assertType('Hypervel\Support\Collection', $users->mapWithKeys(fn ($user) => [$user->name => $user->id > 5 ? $user : null])); + assertType('Hypervel\Support\Collection', $transactions->mapWithKeys(fn ($transaction) => [$transaction->id => $transaction->id > 5 ? $transaction : null])); + + // The result is a base collection even after the values that are not models are removed. + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => $user->id > 5 ? $user : null)->filter()); +} + +/** + * @param EloquentCollection $users + * @param EloquentCollection|EloquentCollection $usersOrAccounts + * @param EloquentCollection|SupportCollection $usersOrIntegers + * @param EloquentCollection|LazyCollection $usersOrLazyUsers + */ +function callablesAndReceivers( + EloquentCollection $users, + EloquentCollection $usersOrAccounts, + EloquentCollection|SupportCollection $usersOrIntegers, + EloquentCollection|LazyCollection $usersOrLazyUsers, +): void { + assertType('Hypervel\Support\Collection', $users->map(UserMapper::toId(...))); + assertType('Hypervel\Support\Collection', $users->map([UserMapper::class, 'toId'])); + assertType('Hypervel\Support\Collection', $users->map(UserMapper::toNullableUser(...))); + assertType('Hypervel\Support\Collection', $users->map(new InvokableUserMapper())); + // Hypervel: $user->id is int, from the incrementing key's implicit cast. + assertType('Hypervel\Support\Collection', $users->map(callback: fn ($user) => $user->id)); + assertType('Hypervel\Support\Collection', $users->map(callback: fn ($user) => $user->id > 5 ? $user : null)); + + assertType('Hypervel\Database\Eloquent\Collection', $usersOrAccounts->map(fn ($model) => $model)); + assertType('Hypervel\Support\Collection', $usersOrAccounts->map(fn ($model) => $model->exists ? $model : null)); + assertType('Hypervel\Support\Collection', $usersOrIntegers->map(fn ($value) => 'foo')); + assertType('Hypervel\Support\Collection', $usersOrIntegers->map(fn ($value) => $value)); + assertType('Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $usersOrLazyUsers->map(fn ($user) => $user->exists ? $user : null)); +} + +/** + * Laravel keeps the collection class when every mapped value is a model, whatever model that is. A class that pins + * its key or model type would stay in the result while claiming to hold its own model, so it is left out. + * + * @param OnlyValueGenericCollection $pinnedKey + * @param PinnedModelCollection $pinnedModel + */ +function collectionsThatPinTheirTypes( + UserCollection $pinnedKeyAndModel, + OnlyValueGenericCollection $pinnedKey, + PinnedModelCollection $pinnedModel, +): void { + assertType('Hypervel\Support\Collection', $pinnedKeyAndModel->map(fn ($user) => $user->exists ? $user : null)); + assertType('Hypervel\Support\Collection', $pinnedKeyAndModel->map(fn ($user) => $user->group)); + assertType('App\Group|null', $pinnedKeyAndModel->map(fn ($user) => $user->group)->first()); + assertType('Hypervel\Support\Collection', $pinnedKeyAndModel->map(fn ($user) => $user->id > 5 ? new Account() : null)); + assertType('Hypervel\Support\Collection', $pinnedKeyAndModel->mapWithKeys(fn ($user) => [$user->name => $user->id > 5 ? new Account() : null])); + + assertType('Hypervel\Support\Collection', $pinnedKey->mapWithKeys(fn ($model) => ['a' => $model->exists ? $model : null])); + assertType('Hypervel\Support\Collection', $pinnedModel->map(fn ($user) => $user->group)); +} + +/** + * @template TKey of array-key + * @template TModel of \Hypervel\Database\Eloquent\Model + * + * @extends EloquentCollection + */ +class InsideCollection extends EloquentCollection +{ + public function test(): void + { + assertType('static(EloquentCollectionMap\InsideCollection)', $this->map(fn ($model) => $model)); + assertType('Hypervel\Support\Collection', $this->map(fn ($model) => $model->exists)); + assertType('Hypervel\Support\Collection', $this->map(fn ($model) => $model->exists ? $model : null)); + } +} + +/** + * @template TKey of array-key + * + * @extends EloquentCollection + */ +class PinnedModelCollection extends EloquentCollection +{ +} + +/** @extends EloquentCollection */ +class OverridesMapCollection extends EloquentCollection +{ + /** + * @param callable(User, int): mixed $callback + * + * @return $this + */ + public function map(callable $callback): static + { + return $this->each($callback); + } +} + +function overriddenMethod(OverridesMapCollection $collection): void +{ + assertType('EloquentCollectionMap\OverridesMapCollection', $collection->map(fn ($user) => $user->id > 5 ? $user : null)); + assertType('Hypervel\Support\Collection', $collection->mapWithKeys(fn ($user) => [$user->name => $user->id > 5 ? $user : null])); +} + +class UserMapper +{ + public static function toId(User $user): int + { + return $user->id; + } + + public static function toNullableUser(User $user): User|null + { + return $user->id > 5 ? $user : null; + } +} + +class InvokableUserMapper +{ + public function __invoke(User $user): string + { + return $user->name; + } +} diff --git a/tests/PHPStan/application/app/RoleCollection.php b/tests/PHPStan/application/app/RoleCollection.php index 24a7a345f..d0cfeae80 100644 --- a/tests/PHPStan/application/app/RoleCollection.php +++ b/tests/PHPStan/application/app/RoleCollection.php @@ -12,5 +12,4 @@ */ class RoleCollection extends Collection { - // } diff --git a/tests/PHPStan/application/app/TransactionCollection.php b/tests/PHPStan/application/app/TransactionCollection.php index 64317b856..64971ef98 100644 --- a/tests/PHPStan/application/app/TransactionCollection.php +++ b/tests/PHPStan/application/app/TransactionCollection.php @@ -12,5 +12,4 @@ */ class TransactionCollection extends Collection { - // } diff --git a/tests/PHPStan/application/app/UserCollection.php b/tests/PHPStan/application/app/UserCollection.php new file mode 100644 index 000000000..fdfe7a3c1 --- /dev/null +++ b/tests/PHPStan/application/app/UserCollection.php @@ -0,0 +1,12 @@ + + */ +class UserCollection extends Collection +{ +} From 1624447fe6a5e41c7a456c0ab67fa36d5cd51b4b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:00:08 +0000 Subject: [PATCH 152/303] Verify keyed eager-loading callbacks and custom collection selection Cover per-key relation and aggregate inference, nested eager loads, morph aliases, conflicting class and alias keys, and strict callback unions. Register fixture morph mappings through the booted application and retain the custom collection attribute coverage. Restore model-property diagnostic expectations after rebuilding the fixture classmap: unavailable fixture classes previously hid valid diagnostics. Register the new type and integration fixtures and update existing assertions to the more precise callback types. Combined checkpoint validation: formatting passes, all three PHPStan configurations pass, and the PHPStan suite passes with 2,895 tests and 4,458 assertions. Independently reviewed before committing. --- tests/PHPStan/Integration/IntegrationTest.php | 51 +++++- .../Integration/data/eager-load-maps.php | 42 +++++ .../data/eager-loading-callbacks.php | 40 +++++ .../Integration/data/model-with-relations.php | 2 +- tests/PHPStan/Type/GeneralTypeTest.php | 6 + .../Type/data/custom-query-collections.php | 10 ++ tests/PHPStan/Type/data/eager-load-maps.php | 113 +++++++++++++ .../Type/data/eager-loading-callbacks.php | 157 ++++++++++++++++++ .../Type/data/model-collections-l11-28.php | 59 +++++++ .../Type/data/model-collections-l13-4.php | 122 ++++++++++++++ tests/PHPStan/Type/data/model-l11-42.php | 5 +- .../Type/data/model-with-relations.php | 8 +- tests/PHPStan/Type/data/relation-of-type.php | 3 +- tests/PHPStan/phpunit-bootstrap.php | 8 + 14 files changed, 610 insertions(+), 16 deletions(-) create mode 100644 tests/PHPStan/Integration/data/eager-load-maps.php create mode 100644 tests/PHPStan/Integration/data/eager-loading-callbacks.php create mode 100644 tests/PHPStan/Type/data/eager-load-maps.php create mode 100644 tests/PHPStan/Type/data/eager-loading-callbacks.php create mode 100644 tests/PHPStan/Type/data/model-collections-l11-28.php create mode 100644 tests/PHPStan/Type/data/model-collections-l13-4.php diff --git a/tests/PHPStan/Integration/IntegrationTest.php b/tests/PHPStan/Integration/IntegrationTest.php index 61b0a003e..d01ebff8e 100644 --- a/tests/PHPStan/Integration/IntegrationTest.php +++ b/tests/PHPStan/Integration/IntegrationTest.php @@ -108,19 +108,50 @@ public static function dataIntegrationTests(): iterable yield 'framework-docblock-types' => [__DIR__ . '/data/framework-docblock-types.php']; - // Hypervel: eager-loading constraints accept any callable, as Builder::combineConstraints() calls them. + // Hypervel: eager-loading constraints accept any callable, as Builder::combineConstraints() calls them, and + // each key's callback is checked against that key's own relation instead of a union of the keys' relations. yield 'model-with-relations' => [ __DIR__ . '/data/model-with-relations.php', [ - 27 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array|(callable(Hypervel\Database\Eloquent\Relations\Relation<*, *, *>): mixed)|string>|string, non-empty-array given.'], - 28 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array<\'accounts\'|int, array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string>|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void} given.'], - 29 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array<\'accounts\'|int, array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string>|string, array{accounts: Closure(int): void} given.'], + 22 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string, group: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsTo): mixed)|string}|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void, group: Closure(Hypervel\Database\Eloquent\Relations\HasMany): void} given.'], + 27 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array): mixed)|string>|string, non-empty-array given.'], + 28 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void} given.'], + 29 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(int): void} given.'], 30 => ['Relation \'missing\' is not found in App\User model.'], - 31 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array|(callable(Hypervel\Database\Eloquent\Relations\Relation<*, *, *>): mixed)|string>|string, non-empty-array given.'], + 31 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array): mixed)|string>|string, non-empty-array given.'], 34 => ['Parameter #1 $relation of function ModelWithRelationsIntegration\acceptsAccounts expects Hypervel\Database\Eloquent\Relations\HasMany, Hypervel\Database\Eloquent\Relations\BelongsTo given.'], ], ]; + // Hypervel: each key's callback is checked against that key's own relation, and eager-loading constraints + // accept any callable. + yield 'eager-loading-callbacks' => [ + __DIR__ . '/data/eager-loading-callbacks.php', + [ + 32 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Builder::with() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void} given.'], + 33 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Builder::withOnly() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(int): void} given.'], + 34 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Model::load() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void} given.'], + 35 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Model::loadMissing() expects array{group: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsTo): mixed)|string}|string, array{group: Closure(Hypervel\Database\Eloquent\Relations\HasMany): void} given.'], + 36 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Collection::load() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void} given.'], + 37 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Collection::loadMissing() expects array{group: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsTo): mixed)|string}|string, array{group: Closure(Hypervel\Database\Eloquent\Relations\HasMany): void} given.'], + 38 => ['Relation \'missing\' is not found in App\User model.'], + 39 => ['Parameter #2 $callback of method Hypervel\Database\Eloquent\Builder::with() expects (Closure(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string|null, Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void given.'], + ], + ]; + + // Hypervel: per-key aggregate and morph map callbacks, and callbacks that may receive either of two relations. + yield 'eager-load-maps' => [ + __DIR__ . '/data/eager-load-maps.php', + [ + 36 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Collection::loadSum() expects array{\'accounts as total\': array{}|(callable(Hypervel\Database\Eloquent\Builder): mixed)|string}|string, array{\'accounts as total\': Closure(App\PostBuilder): (App\PostBuilder&Hypervel\Database\Eloquent\Builder)} given.'], + 37 => ['Parameter #2 $relations of method Hypervel\Database\Eloquent\Collection::loadMorph() expects array{post: array{users: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsToMany): mixed)|string}|string}, array{post: array{users: Closure(Hypervel\Database\Eloquent\Relations\HasMany): Hypervel\Database\Eloquent\Relations\HasMany}} given.'], + 38 => ['Parameter #1 $with of method Hypervel\Database\Eloquent\Relations\MorphTo::morphWith() expects array{post: array{users: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsToMany): mixed)|string}|string}, array{post: array{users: Closure(Hypervel\Database\Eloquent\Relations\HasMany): Hypervel\Database\Eloquent\Relations\HasMany}} given.'], + 39 => ['Parameter #1 $callbacks of method Hypervel\Database\Eloquent\Relations\MorphTo::constrain() expects array{App\User: callable(Hypervel\Database\Eloquent\Builder): mixed}, array{App\User: Closure(App\PostBuilder): (App\PostBuilder&Hypervel\Database\Eloquent\Builder)} given.'], + 40 => ['Parameter #2 $relations of method Hypervel\Database\Eloquent\Collection::loadMorph() expects array{App\Group: array{users: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsToMany|Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string}, array{App\Group: array{users: Closure(Hypervel\Database\Eloquent\Relations\HasMany): Hypervel\Database\Eloquent\Relations\HasMany}} given.'], + 41 => ['Parameter #2 $callback of method Hypervel\Database\Eloquent\Builder::with() expects (Closure(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string|null, Closure(Hypervel\Database\Eloquent\Relations\HasMany): Hypervel\Database\Eloquent\Relations\HasMany given.'], + ], + ]; + // Hypervel: a where column may also be a builder or relation subquery, as the query builder runs it. yield 'eloquent-where' => [ __DIR__ . '/data/eloquent-where.php', @@ -379,11 +410,11 @@ public static function dataIntegrationTests(): iterable ], ]; - // Hypervel: a model without a readable table has no known schema, and properties that only some union - // alternatives have may exist. + // Hypervel: properties that only some union alternatives have may exist. yield [ __DIR__ . '/data/model-property-model.php', [ + 11 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::update() expects array, array given.'], 18 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::update() expects array, array given.'], 48 => ['Parameter #1 $property of method ModelPropertyModel\ModelPropertyCustomMethods::foo() expects model property of App\User, string given.'], 66 => ['Parameter #1 $property of method ModelPropertyModel\ModelPropertyCustomMethodsInNormalClass::foo() expects model property of App\User, string given.'], @@ -392,12 +423,13 @@ public static function dataIntegrationTests(): iterable ], ]; - // Hypervel: a model without a readable table has no known schema. yield 'fill' => [ __DIR__ . '/data/model-property-fill.php', [ 10 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::fill() expects array, array given.'], 11 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::forceFill() expects array, array given.'], + 29 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::fill() expects array, array given.'], + 30 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::forceFill() expects array, array given.'], ], ]; @@ -434,12 +466,13 @@ public static function dataIntegrationTests(): iterable ], ]; - // Hypervel: a model without a readable table has no known schema. yield [ __DIR__ . '/data/model-property-static-call.php', [ 10 => ['Parameter #1 $attributes of static method App\User::create() expects array, array given.'], 14 => ['Parameter #1 $attributes of static method App\User::create() expects array, array given.'], + 26 => ['Parameter #1 $attributes of static method ModelPropertyStaticCall\ModelPropertyStaticCallsInClass::create() expects array, array given.'], + 34 => ['Parameter #1 $attributes of static method ModelPropertyStaticCall\ModelPropertyStaticCallsInClass::create() expects array, array given.'], ], ]; diff --git a/tests/PHPStan/Integration/data/eager-load-maps.php b/tests/PHPStan/Integration/data/eager-load-maps.php new file mode 100644 index 000000000..bda43c73c --- /dev/null +++ b/tests/PHPStan/Integration/data/eager-load-maps.php @@ -0,0 +1,42 @@ + $users + * @param Collection $comments + * @param MorphTo $commentable + * @param array{accounts?: Closure(HasMany): void, group: Closure(BelongsTo): void} $optional + * @param 'accounts'|'group' $name + */ +function callbacks(User $user, Collection $users, Collection $comments, MorphTo $commentable, array $optional, string $name): void +{ + $user->loadCount(['posts' => fn (PostBuilder $query) => $query, 'accounts' => fn (Builder $query) => $query]); + $users->loadSum(['accounts as total' => fn (Builder $query) => $query], 'id'); + $comments->loadMorph('commentable', ['post' => ['users' => fn (BelongsToMany $relation) => $relation]]); + $commentable->constrain(['post' => fn (PostBuilder $query) => $query]); + $users->load($optional); + + $users->loadSum(['accounts as total' => fn (PostBuilder $query) => $query], 'id'); + $comments->loadMorph('commentable', ['post' => ['users' => fn (HasMany $relation) => $relation]]); + $commentable->morphWith(['post' => ['users' => fn (HasMany $relation) => $relation]]); + $commentable->constrain([User::class => fn (PostBuilder $query) => $query]); + $comments->loadMorph('commentable', [Group::class => ['users' => fn (HasMany $relation) => $relation]]); + User::query()->with($name, fn (HasMany $relation) => $relation); +} diff --git a/tests/PHPStan/Integration/data/eager-loading-callbacks.php b/tests/PHPStan/Integration/data/eager-loading-callbacks.php new file mode 100644 index 000000000..de9a07454 --- /dev/null +++ b/tests/PHPStan/Integration/data/eager-loading-callbacks.php @@ -0,0 +1,40 @@ + $builder + * @param Collection $users + */ +function callbacks(Builder $builder, User $user, Collection $users, string $dynamic): void +{ + $builder->with(['accounts' => function (HasMany $relation): void {}]); + $builder->with(['accounts' => function (Relation $relation): void {}, 'group']); + $builder->with([$dynamic => function (Relation $relation): void {}]); + $builder->with('accounts', function (HasMany $relation): void {}); + $builder->with('accounts', 'group'); + $builder->withOnly(['group' => function (BelongsTo $relation): void {}]); + User::withOnly(['group' => function (BelongsTo $relation): void {}]); + $user->load(['accounts' => function (HasMany $relation): void {}]); + $user->load('accounts', 'group'); + $user->loadMissing(['group' => function (BelongsTo $relation): void {}]); + $users->load(['accounts' => function (HasMany $relation): void {}]); + $users->loadMissing(['group' => function (BelongsTo $relation): void {}]); + $user->posts()->with(['comments' => function (Relation $relation): void {}]); + + $builder->with(['accounts' => function (BelongsTo $relation): void {}]); + $builder->withOnly(['accounts' => function (int $relation): void {}]); + $user->load(['accounts' => function (BelongsTo $relation): void {}]); + $user->loadMissing(['group' => function (HasMany $relation): void {}]); + $users->load(['accounts' => function (BelongsTo $relation): void {}]); + $users->loadMissing(['group' => function (HasMany $relation): void {}]); + $builder->with(['missing' => function ($relation): void {}]); + $builder->with('accounts', function (BelongsTo $relation): void {}); +} diff --git a/tests/PHPStan/Integration/data/model-with-relations.php b/tests/PHPStan/Integration/data/model-with-relations.php index 102859dc1..28dd74b9e 100644 --- a/tests/PHPStan/Integration/data/model-with-relations.php +++ b/tests/PHPStan/Integration/data/model-with-relations.php @@ -18,7 +18,7 @@ function callbacks(User $user, string $dynamic): void User::with(['accounts' => function (Relation $relation): void {}]); User::with([$dynamic => function (Relation $relation): void {}]); - // A benevolent union intentionally does not retain key-to-callback correlation. + // Hypervel: each key's callback is checked against that key's own relation. User::with([ 'accounts' => function (BelongsTo $relation): void {}, 'group' => function (HasMany $relation): void {}, diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php index 7a8d6bc3d..5db494829 100644 --- a/tests/PHPStan/Type/GeneralTypeTest.php +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -40,7 +40,11 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/data/container-make.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/contracts.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/custom-eloquent-builder.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/custom-eloquent-collection.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/custom-query-collections.php'); + // Hypervel: covers the per-key eager-load, aggregate and morph map types. + yield from self::gatherAssertTypes(__DIR__ . '/data/eager-load-maps.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/eager-loading-callbacks.php'); // REMOVED: eloquent-builder.php; it asserts Laravel releases before 11.42. yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-builder-l11-42.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-builder-l12-19.php'); @@ -61,6 +65,8 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/data/inherited-method-templates.php'); // REMOVED: model.php; it asserts Laravel releases before 11.42. yield from self::gatherAssertTypes(__DIR__ . '/data/model-l11-42.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-collections-l11-28.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-collections-l13-4.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-date-column-casts.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-increment-each-l13-3.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-increment-each-l13-20.php'); diff --git a/tests/PHPStan/Type/data/custom-query-collections.php b/tests/PHPStan/Type/data/custom-query-collections.php index 13800094b..4bd6c9d1e 100644 --- a/tests/PHPStan/Type/data/custom-query-collections.php +++ b/tests/PHPStan/Type/data/custom-query-collections.php @@ -30,6 +30,16 @@ function test(MemberBuilder $query, Member $member): void assertType('CustomQueryCollections\Members', $member->friends()->getResults()); } +/** + * The legacy PHPDoc union of the collection and an array of models reads as the generic collection. + * + * @param Collection|Member[] $members + */ +function legacyCollectionUnion(Collection $members): void +{ + assertType('Hypervel\Database\Eloquent\Collection', $members); +} + #[CollectedBy(Members::class)] class Member extends Model { diff --git a/tests/PHPStan/Type/data/eager-load-maps.php b/tests/PHPStan/Type/data/eager-load-maps.php new file mode 100644 index 000000000..940f5ff82 --- /dev/null +++ b/tests/PHPStan/Type/data/eager-load-maps.php @@ -0,0 +1,113 @@ + $users + */ +function eagerLoads(Collection $users): void +{ + // A nested array continues the path from its key's name, without the column list. + $users->load(['posts:id,title' => ['comments' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); + }]]); + + $users->fresh(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }]); +} + +/** + * Aggregate callbacks receive the related model's builder. + * + * @param Collection $users + */ +function relationAggregates(User $user, Collection $users): void +{ + $user->loadCount(['posts' => function ($query): void { + assertType('App\PostBuilder', $query); + }]); + + // withAggregate() splits an exact "{relation} as {alias}" name, ignoring the case of "as". + $users->loadSum(['accounts AS total' => function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }], 'id'); + + // A relation that does not resolve keeps Laravel's Builder<*> callback. + $users->loadExists(['accounts as' => function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); + }]); +} + +/** + * loadMorph() and its siblings read a model's class key before its morph alias. + * + * @param Collection $comments + * @param LengthAwarePaginator $paginator + */ +function morphEagerLoads(Comment $comment, Collection $comments, LengthAwarePaginator $paginator): void +{ + $comment->loadMorph('commentable', ['post' => ['users' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $relation); + }]]); + + // Group's class name is also Role's alias, so the entry's callback receives either model's relation. + $comments->loadMorph('commentable', [Group::class => ['users' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany|Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }]]); + + // Post's own class key wins, so its alias entry is never read. + $comments->loadMorph('commentable', [ + Post::class => ['users'], + 'post' => ['users' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }], + ]); + + $paginator->loadMorphCount('commentable', ['post' => ['users as people' => function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }]]); +} + +/** + * MorphTo resolves a key through the morph map before using it as a class name. + * + * @param MorphTo $commentable + */ +function morphTypeMaps(MorphTo $commentable): void +{ + $commentable->constrain([ + Group::class => function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }, + 'post' => function ($query): void { + assertType('App\PostBuilder', $query); + }, + 'unknown' => function ($query): void { + assertType('mixed', $query); + }, + ]); + + $commentable->morphWith(['post' => ['users' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $relation); + }]]); + + $commentable->morphWithCount([User::class => ['accounts as total' => function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }]]); +} diff --git a/tests/PHPStan/Type/data/eager-loading-callbacks.php b/tests/PHPStan/Type/data/eager-loading-callbacks.php new file mode 100644 index 000000000..b07fa93e0 --- /dev/null +++ b/tests/PHPStan/Type/data/eager-loading-callbacks.php @@ -0,0 +1,157 @@ + $builder + * @param 'accounts'|'group' $name + */ +function builderWith(Builder $builder, string $dynamic, string $name): void +{ + assertType('Hypervel\Database\Eloquent\Builder', User::query()->with(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation->where('active', true)->orderBy('id')); + }])); + + assertType('Hypervel\Database\Eloquent\Builder', User::query()->with('accounts')); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->with(['accounts', 'group'])); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->with('accounts', 'group')); + + User::where('id', 1)->with(relations: ['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }]); + + $builder->with(['accounts' => fn ($relation) => assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation)]); + + $builder->with(['posts.comments' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); + }]); + + // Hypervel: each key's callback receives that key's own relation, not a union of the keys' relations. + $builder->with([ + 'accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }, + 'group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }, + ]); + + // Hypervel: a key that may be either name receives either relation, as a plain union. + $builder->with([$name => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }]); + + $builder->with([$dynamic => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }]); + + $builder->with(['accounts', 'group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }]); + + $builder->with('accounts', function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }); +} + +/** @param Builder $builder */ +function builderWithOnly(Builder $builder): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $builder->withOnly(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }])); + + assertType('Hypervel\Database\Eloquent\Builder', User::withOnly(['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }])); + + assertType('Hypervel\Database\Eloquent\Builder', User::withOnly('accounts')); +} + +function modelLoad(User $user, string $dynamic): void +{ + assertType('App\User', $user->load(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }])); + + assertType('App\User', $user->load('accounts')); + assertType('App\User', $user->load('accounts', 'group')); + assertType('App\User', $user->load(['accounts', 'group'])); + + $user->load(['posts.comments' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); + }]); + + $user->load([$dynamic => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }]); + + assertType('App\User', $user->loadMissing(['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }])); + + assertType('App\User', $user->loadMissing('accounts')); +} + +/** + * @param Collection $users + * @param Collection $mixed + */ +function collectionLoad(Collection $users, Collection $mixed, string $dynamic): void +{ + assertType('Hypervel\Database\Eloquent\Collection', $users->load(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }])); + + assertType('Hypervel\Database\Eloquent\Collection', $users->load('accounts')); + assertType('Hypervel\Database\Eloquent\Collection', $users->load(['accounts', 'group'])); + + $users->load([$dynamic => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }]); + + assertType('Hypervel\Database\Eloquent\Collection', $users->loadMissing(['posts.comments' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); + }])); + + assertType('Hypervel\Database\Eloquent\Collection', $users->loadMissing('accounts')); + + // Hypervel: several possible relations form a strict union, not upstream's benevolent one. + $mixed->load(['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }]); + + // Hypervel: Account has no accounts relation, which a subclass or resolveRelationUsing() could supply, so the + // relation is unknown. + $mixed->load(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }]); +} + +function relationWith(User $user): void +{ + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $user->posts()->with(['comments' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); + }])); +} + +class ChildUser extends User +{ + public function insideClass(): void + { + static::query()->with(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }]); + $this->load(['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }]); + } +} diff --git a/tests/PHPStan/Type/data/model-collections-l11-28.php b/tests/PHPStan/Type/data/model-collections-l11-28.php new file mode 100644 index 000000000..c33cbd25b --- /dev/null +++ b/tests/PHPStan/Type/data/model-collections-l11-28.php @@ -0,0 +1,59 @@ +paginate()->getCollection()); + assertType('ModelCollectionsL1128\UserCollection', User::query()->simplePaginate()->getCollection()); + assertType('ModelCollectionsL1128\UserCollection', User::query()->cursorPaginate()->getCollection()); + assertType('ModelCollectionsL1128\UserCollection', User::query()->where('id', '>', 1)->get()); + assertType('ModelCollectionsL1128\TeamCollection', Team::all()); + assertType('ModelCollectionsL1128\PostCollection', Post::all()); +} + +#[CollectedBy(UserCollection::class)] +class User extends Model +{ + +} + +#[CollectedBy(UserCollection::class)] +class Team extends Model +{ + /** @param array $models */ + public function newCollection(array $models = []): TeamCollection + { + return new TeamCollection($models); + } +} + +#[CollectedBy('ModelCollectionsL1128\PostCollection')] +class Post extends Model +{ + +} + +/** @extends Collection */ +class UserCollection extends Collection +{ + +} + +/** @extends Collection */ +class TeamCollection extends Collection +{ + +} + +/** @extends Collection */ +class PostCollection extends Collection +{ + +} diff --git a/tests/PHPStan/Type/data/model-collections-l13-4.php b/tests/PHPStan/Type/data/model-collections-l13-4.php new file mode 100644 index 000000000..eae87f25c --- /dev/null +++ b/tests/PHPStan/Type/data/model-collections-l13-4.php @@ -0,0 +1,122 @@ +', User::all()); + assertType('ModelCollectionsL134\BaseCollection', User::query()->where('id', '>', 1)->get()); + assertType('ModelCollectionsL134\BaseCollection', Admin::all()); + assertType('ModelCollectionsL134\TeamCollection', Team::all()); + // Hypervel: the attribute is instantiated, so its named argument applies to the model and its child. + assertType('ModelCollectionsL134\TeamCollection', NamedArgumentParent::all()); + assertType('ModelCollectionsL134\TeamCollection', NamedArgumentChild::all()); + assertType('ModelCollectionsL134\StringCollection', StringChild::all()); + assertType('ModelCollectionsL134\StringCollection', LeadingBackslashString::all()); + assertType('ModelCollectionsL134\BaseCollection', TraitParentChild::all()); + assertType('ModelCollectionsL134\BaseCollection', TraitChild::all()); +} + +#[CollectedBy(BaseCollection::class)] +abstract class BaseModel extends Model +{ + +} + +class User extends BaseModel +{ + +} + +class Admin extends User +{ + +} + +class Team extends BaseModel +{ + /** @param array $models */ + public function newCollection(array $models = []): TeamCollection + { + return new TeamCollection($models); + } +} + +/** Laravel only reads a positional argument; Hypervel instantiates the attribute, so this one applies. */ +#[CollectedBy(collectionClass: TeamCollection::class)] +class NamedArgumentParent extends BaseModel +{ + +} + +class NamedArgumentChild extends NamedArgumentParent +{ + +} + +#[CollectedBy('ModelCollectionsL134\StringCollection')] +class StringParent extends Model +{ + +} + +class StringChild extends StringParent +{ + +} + +#[CollectedBy('\ModelCollectionsL134\StringCollection')] +class LeadingBackslashString extends Model +{ + +} + +#[CollectedBy(BaseCollection::class)] +abstract class TraitParent extends Model +{ + use HasCollection; +} + +class TraitParentChild extends TraitParent +{ + +} + +class TraitChild extends BaseModel +{ + use HasCollection; +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + */ +class BaseCollection extends Collection +{ + +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + */ +class StringCollection extends Collection +{ + +} + +/** @extends Collection */ +class TeamCollection extends Collection +{ + +} diff --git a/tests/PHPStan/Type/data/model-l11-42.php b/tests/PHPStan/Type/data/model-l11-42.php index c63b325b4..713f0b742 100644 --- a/tests/PHPStan/Type/data/model-l11-42.php +++ b/tests/PHPStan/Type/data/model-l11-42.php @@ -200,12 +200,13 @@ function test( return $relation->where('active', true); }])); + // Hypervel: each key's callback receives that key's own relation, not a union of the keys' relations. $user->with([ 'accounts' => function ($relation) { - assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $relation); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); }, 'group' => function ($relation) { - assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $relation); + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); }, ]); assertType('Hypervel\Database\Eloquent\Builder', (new User)->withGlobalScope('test', function () { diff --git a/tests/PHPStan/Type/data/model-with-relations.php b/tests/PHPStan/Type/data/model-with-relations.php index aa1efb328..c2644cdcf 100644 --- a/tests/PHPStan/Type/data/model-with-relations.php +++ b/tests/PHPStan/Type/data/model-with-relations.php @@ -22,12 +22,13 @@ function eagerLoad(User $user, string $dynamic, string $name): void assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); }]); + // Hypervel: each key's callback receives that key's own relation, not a union of the keys' relations. User::with([ 'accounts' => function ($relation): void { - assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $relation); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); }, 'group' => function ($relation): void { - assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $relation); + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); }, ]); @@ -36,8 +37,9 @@ function eagerLoad(User $user, string $dynamic, string $name): void assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); }]); + // Hypervel: a key that may be either name receives either relation, as a plain union. User::with([$name => function ($relation): void { - assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $relation); + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany', $relation); }]); User::with([$dynamic => function ($relation): void { diff --git a/tests/PHPStan/Type/data/relation-of-type.php b/tests/PHPStan/Type/data/relation-of-type.php index 8ff3c1824..bf7cd0729 100644 --- a/tests/PHPStan/Type/data/relation-of-type.php +++ b/tests/PHPStan/Type/data/relation-of-type.php @@ -25,7 +25,8 @@ function relations($accounts, $group, $nested, $columns, $union, $nullable, $int assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $group); assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $nested); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $columns); - assertType('(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany)', $union); + // Hypervel: several possible relations form a strict union, not upstream's benevolent one. + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany', $union); assertType('Hypervel\Database\Eloquent\Relations\HasMany|null', $nullable); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $intersection); } diff --git a/tests/PHPStan/phpunit-bootstrap.php b/tests/PHPStan/phpunit-bootstrap.php index d4ebd2824..cd75d31bf 100644 --- a/tests/PHPStan/phpunit-bootstrap.php +++ b/tests/PHPStan/phpunit-bootstrap.php @@ -3,11 +3,15 @@ declare(strict_types=1); use App\Account; +use App\Group; +use App\Post; +use App\Role; use App\User; use Composer\Autoload\ClassLoader; use Hypervel\Console\Application; use Hypervel\Container\Container; use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Console\ClosureCommand; use Hypervel\Testbench\Bootstrapper; @@ -58,6 +62,10 @@ // A dynamic relation, registered at boot as a provider would. User::resolveRelationUsing('dynamicAccounts', static fn (User $user): HasMany => $user->hasMany(Account::class)); +// Morph aliases, registered at boot as a provider would. Group's class name is also Role's alias, which +// loadMorph() reads by class first and MorphTo by alias first. +Relation::morphMap(['post' => Post::class, Group::class => Role::class]); + // Package configuration sources, which the config analysis reads from the loaded providers. Container::getInstance()->register(ConfigFixtureServiceProvider::class); Container::getInstance()->register(OverlappingConfigServiceProvider::class); From ca1d9f733551e4d2d1c7753d47798a9fafef0b1e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:00:08 +0000 Subject: [PATCH 153/303] Document collection inference boundaries and completed port flow Explain PHPStan pinned-static collection limitations with a truthful local type annotation example, and record the strict relation-union difference from Larastan. Mark the reviewed collection flow complete, remove its remaining-file checklist entries and record the shared eager-map design, native query-override boundary and fixture autoloader refresh requirement. Keep morph-map runtime inputs in the pending shared cache verification work. Combined checkpoint validation: formatting passes, all three PHPStan configurations pass, and the PHPStan suite passes with 2,895 tests and 4,458 assertions. Independently reviewed before committing. --- .../2026-11-04-hypervel-phpstan-checklist.md | 11 +++-------- docs/plans/2026-11-04-hypervel-phpstan.md | 18 ++++++++++-------- src/docs/static-analysis.md | 9 +++++++++ src/phpstan/README.md | 1 + 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md index 4d4cd850d..7bbfae263 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -25,11 +25,10 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` - copy: `Methods/ModelFactoryMethodsClassReflectionExtension.php` - omit: `Properties/ReflectionTypeContainer.php` (unused since upstream removed container ArrayAccess support) - copy: `ReturnTypes/{FactoryDynamicMethodReturnTypeExtension,ModelFactoryDynamicStaticMethodReturnTypeExtension}.php` -- copy with the Eloquent collection port: `ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php`, moving `common/EloquentCollection.stub`'s `map()`/`mapWithKeys()` conditionals into the Eloquent `Collection` source; also `toQuery()`/`load()`/`loadMissing()` typing, merging `Integration/data/eager-loading-callbacks.php` and its `Integration/IntegrationTest.php` entry (investigate the supported PHPStan hooks before choosing a mechanism) - copy: `Rules/*` except below and the ported `ArrKeyExistenceRule`, auth request-scope, config, console, `NoEnvCallsOutsideOfConfigRule`, `NoMissingTranslationsRule`, `NoUnnecessaryEnumerableToArrayCallsRule`, `RelationExistenceRule` (with `RelationExistenceHelper` and `ModelRuleHelper`), `UnusedViewsRule` and `UselessConstructs/` rules (including `Queue/`) - omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` - omit: `SQL/*` (`hypervel/schema-parser` replaces the parser boundary and its iamcal/phpMyAdmin adapters) -- copy: `Types/Factory/` with the factory flow; `Types/GenericEloquentCollectionTypeNodeResolverExtension.php` with the Eloquent collection port +- copy: `Types/Factory/` with the factory flow ## New Hypervel source @@ -39,7 +38,7 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## stubs -- copy with their topic flows: `common/{Attribute,Console/{Command,HasParameters},Contracts/{Cache,Container,Kernel,Mail,Queue,UrlGenerator},Cookie/SymfonyCookie,Database/Eloquent/Concerns/{HasAttributes,HasEvents,HasRelationships},Database/{ManagesTransactions,Migrations/Migrator},EloquentCollection,Events/Dispatcher,Factory,Http/RedirectResponse,Macroable,Notifications/Notification,Routing/{Redirector,UrlGenerator},Session/SessionManager,Support/{HtmlString,Manager,Optional},ThirdParty,Validation/Validator}.stub`, except below; `MorphTo`, `MorphToMany`, `Pagination` and `Contracts/Pagination` need none, because the source declares their types +- copy with their topic flows: `common/{Attribute,Console/{Command,HasParameters},Contracts/{Cache,Container,Kernel,Mail,Queue,UrlGenerator},Cookie/SymfonyCookie,Database/Eloquent/Concerns/{HasAttributes,HasEvents,HasRelationships},Database/{ManagesTransactions,Migrations/Migrator},Events/Dispatcher,Factory,Http/RedirectResponse,Macroable,Notifications/Notification,Routing/{Redirector,UrlGenerator},Session/SessionManager,Support/{HtmlString,Manager,Optional},ThirdParty,Validation/Validator}.stub`, except below; `EloquentCollection`, `MorphTo`, `MorphToMany`, `Pagination` and `Contracts/Pagination` need no separate stub: the source declares their types, with `toQuery()`'s return-type extension and `MorphTo`'s map refinements in the combined `Database/Eloquent/Relations.stub` - stubs repeat the source's native signatures except returns that name a collection or paginator, whose stubs fail stub validation (variance); `ModelPropertyColumnRule` checks the pagination and `pluck()` columns that upstream's stubs type - merge into `common/Helpers.stub` with their topics: the `cookie()`, `logger()`, `logs()`, `redirect()`, `request()`, `session()` and `url()` helper entries, whose conditional returns the source already declares (only `view()` is ported; `optional()` is typed in source and `OptionalExtension`; the stub records the omitted helpers) - merge with their topic flows: the upstream typing for the declaration-only stubs that `common/Facades.stub`'s mixins need for stub validation: Events `dispatch()`/`until()` conditionals, `Connection::getQueryLog()` shape, Schema `getColumnListing()`, and the Cache `Repository` `ArrayAccess` implements (check Hypervel's enum keys) @@ -52,15 +51,11 @@ Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/` ## tests - omit: `laravel-test.sh` -- copy with the Eloquent collection port: `application/app/UserCollection.php`; with the factory flow: `application/app/{Domain/,Shapes/}` and `application/database/factories/` +- copy with the factory flow: `application/app/{Domain/,Shapes/}` and `application/database/factories/` - omit: `application/app/{ModelWithConflictingInitializer.php,UserResource.php,Providers/}` (no upstream test or source references them) - copy: `Rules/*Test.php` except below and the ported `ArrKeyExistence`, auth, config, console, environment, enumerable `toArray()`, relation existence, view, translation and useless-construct rule tests, the remaining `Rules/data/*`, `Rules/Queue/data/*` - omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` - copy: `Type/*Test.php` except below, the ported collection, config and model-property tests, `GeneralTypeTest.php` and `MethodsClassReflectionExtensionTest.php`, with the remaining `Type/data/*` -- merge into `Type/BleedingEdgeTypeTest.php` with the Eloquent ports: `bleeding-edge/closure-usage-inference.php` (relation callbacks) and `bleeding-edge/eloquent-collection-map.php` -- merge into `Type/CollectionDynamicReturnTypeExtensionsTest.php` with the Eloquent collection port (they need model properties and the custom collections; its `phpstan-tests.neon` already matches upstream's `config-with-migrations.neon`): `collection-generic-static-methods{,-l12}.php`, `eloquent-collection-map.php` -- merge into `Type/CollectionDynamicReturnTypeExtensionTest.php` with the model fixtures: `collection-filter.php`, `collection-reject.php`, `collection-where.php` (they need `accounts()->active()->get()` to return `AccountCollection`) -- omit: `Type/data/collection-generic-static-methods-l11.php` (Laravel 11's Eloquent `partition()` returned `static`; Hypervel follows the Laravel 12 base collection) - merge: the remaining `Type/GeneralTypeTest.php` data entries (its `phpstan-tests.neon` already matches upstream's `config-with-migrations.neon`) - copy with the factory flow: `Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php`; merge `Integration/data/{model-factories,model-property-model-factory}.php` and their `Integration/IntegrationTest.php` entries - omit: `Unit/SQL/*` (parser factory and phpMyAdmin adapter tests; `hypervel/schema-parser` has its own) diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md index c136559ae..9ad378279 100644 --- a/docs/plans/2026-11-04-hypervel-phpstan.md +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -27,7 +27,8 @@ Remaining work: - Also completed: the Eloquent builder and relation forwarding flow (`BuilderHelper`, the `builder-of`, `collection-of` and `relation-of` types, `ClosureTypes/`, the forwarding return types, the 13.x stub merges, dynamic wheres and stub-validation compliance), with its type and integration fixtures. - Also completed: the upstream Integration merge (except the entries the checklist assigns to the Eloquent collection and factory flows), with its source corrections and `RelationExistenceRule`. - Also completed: Laravel's `@return $this` tags restored across Components (see Type inference and stubs). -- Next: port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules. The Eloquent collection port carries `EloquentCollectionMapDynamicReturnTypeExtension`, the `toQuery()`/`load()`/`loadMissing()` typing with `eager-loading-callbacks.php`, and the model-dependent collection fixtures listed in the checklist. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. +- Also completed: the Eloquent collection flow (`EloquentCollectionMapDynamicReturnTypeExtension`, `toQuery()` typing, the eager-load map types, the strict `relation-of` union and the model-dependent collection fixtures). +- Next: port the remaining checklist `src/` files as complete related flows, alphabetically within each flow. The Unit factory test and remaining fixture-app files port with the flows that use them (checklist). Shared concerns and helpers move with their first consumers, because PHPStan reports unused traits: `InspectsQueuedJobs` ports with the queue rules. The shared runtime-input tracking, warm-run reuse checks and final efficiency pass (Result-cache correctness and analysis cost) remain. - Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. ## Design decisions @@ -240,13 +241,14 @@ Port every applicable upstream inference feature, not just the rules in the next | Area | Required adaptation and coverage | |---|---| | Models | Native declarations and explicit PHPDoc take precedence appropriately; infer schema properties, relations, accessors, casts, separate read/write types, nullability, custom collections, factories and serialization shapes via `ModelSerializationDynamicMethodReturnTypeExtension`. Honor Hypervel attributes, visibility/appends and inheritance; Hypervel instantiates `CollectedBy`, so test a named `collectionClass:` argument and a parent's attribute. Unknown schema knowledge must not erase declared properties or known relationships. | -| Builder/relation forwarding | Preserve model generics, custom builders, relation-specific fluent returns, attributed scopes, SoftDeletes/Scout and macro callbacks; merge existing Hypervel capabilities with applicable upstream cases. **Builder selection** (`BuilderHelper`): when the model's `newQuery()` comes from `HasBuilder`, its `@use` type, even when another trait's `newEloquentBuilder()` wins the conflict (Nested Set's `HasNode`); otherwise `Model::newEloquentBuilder()`'s order: an override's declared return, a `#[UseEloquentBuilder]` `Builder` subclass (class, its traits, then parents; the argument read as instantiation evaluates it, including class constants), then the `$builder` default, which upstream ignores. A missing model class has no builder. The called model is bound through the builder's `setModel()` return, rebinding only the template mapped to `Builder`'s `TModel`. The Model stub types the `query()`/`newQuery()` family as `builder-of`; subclass overrides and other declared returns keep their declarations, so a callback's result, such as `withoutEvents()`'s, is not the called model's builder. **Dispatch** follows `Builder::__call()`: native methods; `macro()` registration, which returns null; local macros; global macros; named scopes (a real method only: a tag-only `@method scopeX` creates none); passthru; then query forwarding to native query methods, query macros, and any other `where…` name as a dynamic where. Eloquent discards forwarded results, except passthru, which returns the raw `toBase()` query with its declared rows. Forwarded methods are instance-only, because `Builder::__callStatic()` runs only the builder's global macros. It also accepts `macro()` and `mixin()`, which register global macros and return null, so `MacroRegistrationMethodReflection` describes both as static, without the query's closure binding: a local macro keeps its own `$this`. `MacroRegistrationExtension` binds a static `macro()` callback to the builder and returns the builder from an instance `mixin()`, which reaches the query's. Method existence must not depend on the model, or PHPStan reports a generic subclass's call as private, so `ModelPropertyColumnRule` reports a dynamic where's unknown column, with PHPStan's undefined-method message and identifier. Forwarded signatures name the model by the receiver's template, keeping late-static models. A non-union intersection resolves the model's scopes, then interface `scopeX` declarations. Through a relation, a builder method declared to return `static` or `$this` returns the relation, except `applyScopes()`, `clone()`, `cloneWithout()` and `cloneWithoutBindings()`; relations reach global builder macros through their `@mixin`, and `QueriesRelationships` drops Laravel's circular `@mixin Builder`. Ordinary forwarding uses `getQuery()`'s return, intersected with `toBase()`'s while `toBase()` is `Builder`'s own; passthru uses a native `toBase()` override's return, else the ordinary type. Dependencies cover the model hierarchy's files for `$builder`, both query classes and a class constant a builder or collection attribute reads. **Uncertainty**: `builder-of`/`relation-of` give the declaring model's builder only without a path. A path, or a model alternative at any path segment, without a resolvable relation is unknown, since a subclass or `resolveRelationUsing()` can supply it, and is kept beside resolved alternatives. An unknown model has `Builder<*>`, `Relation<*, *, *>` or the base model's collection rather than upstream's `never`. `HasOneOrManyThrough::getRelationExistenceQuery()` types its parent query as the far parent's builder. **Collections**: only framework builder and relation implementations get the model's collection class; custom overrides keep their declarations. Paginators carry the stored collection as a third, defaulted `TCollection` template that `getCollection()` returns, `setCollection()` updates and `through()` widens. Builder and relation `paginate()`-family returns name `EloquentCollection` in source, replaced with the model's collection; hand-built paginators keep the base collection, and upstream's model-value `getCollection()` conditional is omitted. `CursorPaginator` takes `array-key`-keyed input (`setItems()` renumbers) and keeps `TKey`/`TValue` required; a direct `new CursorPaginator(...)` is `CursorPaginator>`. `only()` keeps one key shape per first-argument alternative, a later multi-key argument makes keys optional, and an unknown key reads `mixed`. | -| Helpers and collections | Preserve conditional argument behavior, `Arr` shapes, `filter()` narrowing, higher-order proxy callbacks and generic collections. Compare inherited annotations before adding a stub. Keep Laravel's covariant collection `TValue` rather than upstream's invariant stubs: invariance rejects native `Collection` callback hints, `Collection` parameters given narrower collections and fresh-producer returns, and call-site `covariant` projections only cover read-only inputs. PHPStan 2.3.5's bleeding edge does not widen an already inferred covariant `TValue`, so upstream's bleeding-edge value-widening cases are recorded as `REMOVED:`; the docs give the `@var` remedy. | +| Builder/relation forwarding | Preserve model generics, custom builders, relation-specific fluent returns, attributed scopes, SoftDeletes/Scout and macro callbacks; merge existing Hypervel capabilities with applicable upstream cases. **Builder selection** (`BuilderHelper`): when the model's `newQuery()` comes from `HasBuilder`, its `@use` type, even when another trait's `newEloquentBuilder()` wins the conflict (Nested Set's `HasNode`); otherwise a `newModelQuery()` override whose native return names a `Builder` subclass, because every query entry point and `toQuery()` run it (only the native return: an undocumented override inherits `builder-of`, which would resolve back into selection, so a PHPDoc-only narrowing is not read); otherwise `Model::newEloquentBuilder()`'s order: an override's declared return, a `#[UseEloquentBuilder]` `Builder` subclass (class, its traits, then parents; the argument read as instantiation evaluates it, including class constants), then the `$builder` default, which upstream ignores. A missing model class has no builder. The called model is bound through the builder's `setModel()` return, rebinding only the template mapped to `Builder`'s `TModel`. The Model stub types the `query()`/`newQuery()` family as `builder-of`; subclass overrides and other declared returns keep their declarations, so a callback's result, such as `withoutEvents()`'s, is not the called model's builder. **Dispatch** follows `Builder::__call()`: native methods; `macro()` registration, which returns null; local macros; global macros; named scopes (a real method only: a tag-only `@method scopeX` creates none); passthru; then query forwarding to native query methods, query macros, and any other `where…` name as a dynamic where. Eloquent discards forwarded results, except passthru, which returns the raw `toBase()` query with its declared rows. Forwarded methods are instance-only, because `Builder::__callStatic()` runs only the builder's global macros. It also accepts `macro()` and `mixin()`, which register global macros and return null, so `MacroRegistrationMethodReflection` describes both as static, without the query's closure binding: a local macro keeps its own `$this`. `MacroRegistrationExtension` binds a static `macro()` callback to the builder and returns the builder from an instance `mixin()`, which reaches the query's. Method existence must not depend on the model, or PHPStan reports a generic subclass's call as private, so `ModelPropertyColumnRule` reports a dynamic where's unknown column, with PHPStan's undefined-method message and identifier. Forwarded signatures name the model by the receiver's template, keeping late-static models. A non-union intersection resolves the model's scopes, then interface `scopeX` declarations. Through a relation, a builder method declared to return `static` or `$this` returns the relation, except `applyScopes()`, `clone()`, `cloneWithout()` and `cloneWithoutBindings()`; relations reach global builder macros through their `@mixin`, and `QueriesRelationships` drops Laravel's circular `@mixin Builder`. Ordinary forwarding uses `getQuery()`'s return, intersected with `toBase()`'s while `toBase()` is `Builder`'s own; passthru uses a native `toBase()` override's return, else the ordinary type. Dependencies cover the model hierarchy's files for `$builder`, both query classes and a class constant a builder or collection attribute reads. **Uncertainty**: `builder-of`/`relation-of` give the declaring model's builder only without a path. A path, or a model alternative at any path segment, without a resolvable relation is unknown, since a subclass or `resolveRelationUsing()` can supply it, and is kept beside resolved alternatives. An unknown model has `Builder<*>`, `Relation<*, *, *>` or the base model's collection rather than upstream's `never`. `HasOneOrManyThrough::getRelationExistenceQuery()` types its parent query as the far parent's builder. **Collections**: only framework builder and relation implementations get the model's collection class; custom overrides keep their declarations. Paginators carry the stored collection as a third, defaulted `TCollection` template that `getCollection()` returns, `setCollection()` updates and `through()` widens. Builder and relation `paginate()`-family returns name `EloquentCollection` in source, replaced with the model's collection; hand-built paginators keep the base collection, and upstream's model-value `getCollection()` conditional is omitted. `CursorPaginator` takes `array-key`-keyed input (`setItems()` renumbers) and keeps `TKey`/`TValue` required; a direct `new CursorPaginator(...)` is `CursorPaginator>`. `only()` keeps one key shape per first-argument alternative, a later multi-key argument makes keys optional, and an unknown key reads `mixed`. | +| Eager-load maps | One late-resolvable `EagerLoadMapType` types relation-keyed maps per key. Its method template takes the whole received array (`@template TRelations of array`), so each key's callback gets that key's relation, where upstream's stubs union the keys and accept swapped callbacks. Integer keys, non-constant portions, non-constant maps and keys covering no model keep Laravel's value type for their own entry; optional keys stay optional; supplied callback types never enter the expected type. Names, all analyzer-only:
- `eager-loads`: `with`, `withOnly`, `load`, `loadMissing`, `fresh`. String keys expect `(callable(relation-of): mixed)\|string\|array`; nested arrays recurse with the runtime's prefix (the key's name before `:columns`, then `.`). `Builder::with($relation, $callback)` keeps `relation-of` for its direct callback.
- `relation-aggregates`: `loadAggregate` … `loadExists`. String keys split the exact three-segment, case-insensitive `{relation} as {alias}` as `withAggregate()` does and expect `array{}\|(callable(builder-of): mixed)\|string`; a relation that does not resolve keeps Laravel's value type (its `Builder<*>` callback), as an unknown eager load gets Laravel's `Relation<*, *, *>`; no recursion (an array value is only the empty no-constraint form).
- `morph-eager-loads`, `morph-aggregates`: `loadMorph`, `loadMorphAggregate` … `loadMorphAvg`, `loadMorphCount`. Class-first, as `$relations[get_class($model)] ?? $relations[$model->getMorphClass()]` reads them: entry K covers class K when it is a model, and `Relation::getMorphedModel(K)` when K is that model's `getMorphAlias()` and the map has no key for it.
- `morph-type-eager-loads`, `morph-type-aggregates`, `morph-type-constraints`: `MorphTo::morphWith`, `morphWithCount`, `constrain`. Alias-first, `getMorphedModel(K) ?? K`, as `resolveMorphTypes()`; constraints expect `callable(builder-of): mixed`.
Morph lookups read the booted morph map statically (a shared runtime input, see Result-cache correctness). | +| Helpers and collections | Preserve conditional argument behavior, `Arr` shapes, `filter()` narrowing, higher-order proxy callbacks and generic collections. Compare inherited annotations before adding a stub. Keep Laravel's covariant collection `TValue` rather than upstream's invariant stubs: invariance rejects native `Collection` callback hints, `Collection` parameters given narrower collections and fresh-producer returns, and call-site `covariant` projections only cover read-only inputs. PHPStan 2.3.5's bleeding edge does not widen an already inferred covariant `TValue`, so upstream's bleeding-edge value-widening cases are recorded as `REMOVED:`; the docs give the `@var` remedy. PHPStan keeps a subclass's fixed template types through `static<...>` returns, such as `UserCollection` (`@extends Collection`) after `map()`, `groupBy()` or `chunk()`. This is a known limitation handed off for a PHPStan fix; the owner chose no package workaround. Retained upstream assertions of it carry a `Hypervel: known PHPStan limitation` comment, and the docs state it with the `@var` remedy. | | Authentication | One shared guard-type helper serves `Auth::guard()`, `Factory::guard()` and `auth($guard)`, following `AuthManager::resolve()`: a booted custom creator's declared return (`ClosureTypeFactory`, never invoked), else the booted manager's `create{Driver}Driver()` declaration, else the declared return. `viaRequest()`'s creator declares `RequestGuard`, so request guards are typed; an untyped creator keeps the declared return. Never claim `StatefulGuard` for an unmodelled guard: only `SessionGuard` is stateful, and token/request/Sanctum/JWT/Passport guards are not. An omitted, null or empty name is the union of every configured guard's type, because `shouldUse()` changes the default per coroutine; so is an unknown or unpacked name, because resolving an unconfigured name throws. A known set of string or enum names gives the union of those guards. `auth()` and `auth(null)` type as the booted factory concrete; unlike `guard(null)`, a maybe-null or unpacked `auth()` argument can return the factory, so it keeps the declared conditional. Guard names come only from `guard()` on the factory or facade, the global `auth()` (resolved function identity) and `Request::user()`; enum cases map like `enum_value()` where the signature accepts enums. User models are provider-based inference: an Eloquent provider gives its model for any guard driver; a database provider gives `GenericUser` only for the stock session/token drivers (Sanctum accepts session-guard users when its provider has no `getModel()`). A guard without a modelled provider, or one whose provider driver has a custom creator, keeps the call's declaration. Preserve narrower declarations; manager and facade `user()` calls are typed only when they forward the `Guard` contract's method. Omit `AuthsMethodsExtension`: `AuthManager`'s mixins already forward guard methods, and contracts do not gain implementation methods. | | Console commands | Argument/option types and the undefined-input rule read the booted kernel's commands (`$kernel->all()`: `withCommands()` discovery, `command()` closures and providers' `starting()` registrations; a standalone application is the fallback when the kernel cannot list them). Look up the call's receiver, not the enclosing class; exactly `Command` and `ClosureCommand` receivers keep the declaration, because they name every command or unrelated per-instance signatures. Merge each matched command's application definition once at lookup, as `run()` does, so bulk shapes include `command` and the global options regardless of analysis order. A name lookup is the union over matching commands that define it (missing ones throw), else the declaration; option names follow `Input`, so negations resolve to their option and shortcuts are not names. Aliases list a command once. A definitely-null key is a bulk lookup; overridden methods keep their declarations for typing and diagnostics. The rule skips a call whose same-receiver `hasArgument()`/`hasOption()` guard is definitely true or unreachable in scope, because the call never reaches the commands without the name; Components' `GeneratorCommand` and `CreatesMatchingTest` guard `--force` this way. Scope keys the guard as written, so check its positional and `name:` forms built from the getter's value, never the getter's own argument nodes. A receiver union with an alternative that has no known definition (e.g. `FooCommand|ClosureCommand`) keeps the declaration. `@param-closure-this` for `command()` lives on the Kernel contract and Foundation Kernel. | | Views and translations | Retain `view-string` and live view-finder namespaces. Keep translation typing and the adapted missing-key rule. The optional unused-view rule reports absent statically discovered usage, not proof that a view can be deleted. | -| Custom PHPDoc types | Port `model-property`, `relation-of`, `builder-of`, `collection-of`, `view-string`, their parsers, templates and applicable tests. Keep these analyzer-only annotations in stubs where adding them to framework source would require this dev package. Unlike upstream, the late-resolvable types compose their position variance with invariant, so a `*` argument in a parameter resolves as it does in the generic class it describes instead of to `never`. `model-property` may be a supertype of an arbitrary string, as `accepts()` allows, because PHPStan rechecks an accepted intersection through `isSuperTypeOf()`. For a constant string, `isSuperTypeOf()` gives the model's actual property existence, so an abstract or template model is maybe rather than `accepts()`'s yes: union simplification would otherwise drop alternatives such as `'*'` from stub types. | -| Stub validation | On a full analysis PHPStan validates stubs outside the project's vendor directory (this repo, and symlinked path-repository installs such as the owner's apps) against the stubs alone, and its errors cannot be ignored. Stubs therefore declare every class they reference: an empty declaration for a non-generic one (throwables with their real parent chain), and for a generic one its verbatim source class docblock, matching imports and native `extends`/`implements`, with no methods unless an analyzer override or validation needs them (the Query `Builder`, `ModelNotFoundException`, `Arrayable`). A class declared in a stub takes the nearest ancestor stub's PHPDoc for a method its own stub omits, including PHPStan's `ArrayAccess`/`IteratorAggregate`/`Countable` stubs (`PhpClassReflectionExtension::findMethodPhpDocIncludingAncestors()`), so a stub also declares, verbatim, each such method whose own source docblock that would replace (the Config and Cache `Repository` `offset*()` methods, with a diagnostic fixture for their key types). The support and Eloquent collections and the paginators cannot be declared: their verbatim docblocks fail validation (covariant `TValue` in `ArrayAccess`/`ArrayIterator`, accepted in source only through the `generics.variance` ignore), and editing them would replace the source declarations. Stubs therefore do not reference them: `BelongsToMany`'s `TResult` is `collection-of` (its results come from the related model's `newCollection()`), and the `paginate()` family and `pluck()` stay unstubbed, their returns coming from source through `EloquentBuilderExtension`/`RelationCollectionExtension`, while `ModelPropertyColumnRule` (enabled by `checkModelProperties`) checks their column arguments as the stubs' `model-property` types did, for builder, model-forwarded and relation-forwarded calls. A stub class docblock replaces the source's, and a stub method docblock replaces the source method's, so a stubbed method repeats the source's native signature and every source PHPDoc tag besides its analyzer-only refinements. `StubMirrorsTest` compares an explicit mapping of the verbatim class docblocks (the declaration mirrors and the Eloquent builder/relation stubs except `BelongsToMany`) and mirrored method docblocks with source; stubs whose class docblocks intentionally differ, such as the facade mixins and `BelongsToMany`, are not mapped. Extension code reached while validating stubs must not assume project reflection, such as methods the stubs omit; an unreflectable provider counts as unknown writes, which also covers anonymous providers. | +| Custom PHPDoc types | Port `model-property`, `relation-of`, `builder-of`, `collection-of`, `view-string`, their parsers, templates and applicable tests. Analyzer-only annotations go in stubs. Where a valid stub cannot preserve the class contract (the collections and paginators, see Stub validation), they are PHPStan-prefixed source refinements (`@phpstan-param`) beside Laravel's ordinary `@param`. Plain PHPStan reads those types as `mixed`, so the docs state that framework-aware analysis requires `hypervel/phpstan`. The eager-load map types are framework-internal, not documented public types. Unlike upstream, the late-resolvable types compose their position variance with invariant, so a `*` argument in a parameter resolves as it does in the generic class it describes instead of to `never`. `model-property` may be a supertype of an arbitrary string, as `accepts()` allows, because PHPStan rechecks an accepted intersection through `isSuperTypeOf()`. For a constant string, `isSuperTypeOf()` gives the model's actual property existence, so an abstract or template model is maybe rather than `accepts()`'s yes: union simplification would otherwise drop alternatives such as `'*'` from stub types. `relation-of` gives a strict union when several relations may apply, unlike upstream's benevolent one, because the callback receives whichever relation applies (README difference). `Eloquent\Collection::toQuery()` uses a return-type extension resolving `builder-of` (builder selection covers a `newModelQuery()` override; an unknown model gives `Builder<*>`): the collection cannot be stubbed, and a source return refinement would read as `mixed` under plain PHPStan. | +| Stub validation | On a full analysis PHPStan validates stubs outside the project's vendor directory (this repo, and symlinked path-repository installs such as the owner's apps) against the stubs alone, and its errors cannot be ignored. Stubs therefore declare every class they reference: an empty declaration for a non-generic one (throwables with their real parent chain), and for a generic one its verbatim source class docblock, matching imports and native `extends`/`implements`, with no methods unless an analyzer override or validation needs them (the Query `Builder`, `ModelNotFoundException`, `Arrayable`). A class declared in a stub takes the nearest ancestor stub's PHPDoc for a method its own stub omits, including PHPStan's `ArrayAccess`/`IteratorAggregate`/`Countable` stubs (`PhpClassReflectionExtension::findMethodPhpDocIncludingAncestors()`), so a stub also declares, verbatim, each such method whose own source docblock that would replace (the Config and Cache `Repository` `offset*()` methods, with a diagnostic fixture for their key types). The support and Eloquent collections and the paginators cannot be declared: their verbatim docblocks fail validation (covariant `TValue` in `ArrayAccess`/`ArrayIterator`, accepted in source only through the `generics.variance` ignore), and editing them would replace the source declarations. Stubs therefore do not reference them: `BelongsToMany`'s `TResult` is `collection-of` (its results come from the related model's `newCollection()`), and the `paginate()` family and `pluck()` stay unstubbed, their returns coming from source through `EloquentBuilderExtension`/`RelationCollectionExtension`, while `ModelPropertyColumnRule` (enabled by `checkModelProperties`) checks their column arguments as the stubs' `model-property` types did, for builder, model-forwarded and relation-forwarded calls. Their eager-load parameters use source refinements (see Custom PHPDoc types). A stub class docblock replaces the source's, and a stub method docblock replaces the source method's, so a stubbed method repeats the source's native signature and every source PHPDoc tag besides its analyzer-only refinements. `StubMirrorsTest` compares an explicit mapping of the verbatim class docblocks (the declaration mirrors and the Eloquent builder/relation stubs except `BelongsToMany`) and mirrored method docblocks with source; stubs whose class docblocks intentionally differ, such as the facade mixins and `BelongsToMany`, are not mapped. Extension code reached while validating stubs must not assume project reflection, such as methods the stubs omit; an unreflectable provider counts as unknown writes, which also covers anonymous providers. | | Dates and environment | Keep native/source `CarbonInterface` boundaries for configurable dates and exact immutable types for framework-owned values. Do not infer configurable dates from `get_class(now())`, or assume an `env()` default dictates the supplied environment value's type. `env()` and `Env::get()` declare `bool|string|TDefault|null` in source PHPDoc (`TDefault` from a value or `Closure` default; native signatures unchanged), matching `Env::getOption()` and `value()`; no stub or return-type extension. | Do not copy `ContractsMethodsExtension`, container ArrayAccess/dynamic-property support, Lumen paths, deferred-provider machinery, or unsupported Laravel integrations. Remove compatibility branches for old Laravel releases, but first merge applicable newer-version stub refinements into the single supported Hypervel surface. Deleting version directories without examining their overrides would lose features. @@ -396,7 +398,7 @@ Track connection/table identity where it changes the result, schema changes acro - Schema sources besides migrations and dumps: constants naming tables, columns or connections, `foreignIdFor()` model metadata and a migration's parent class. `SchemaAggregator` records their files: column-level ones (column names, allowed values, `foreignIdFor()` models) on the table, so only models reading that table depend on them; identity-level ones (table names, a rename's target, migration parents) on every model through `Model`. A guard (`hasTable()`, `hasColumn()` and the like) reads one table's facts to decide what happens to any table, so that table's sources become sources of every fact. `MigrationCache` stores their hashes, validates them on a hit and replays them. Regressions: warm edits of a constant a migration reads and of one a guard reads. - Model metadata. PHPStan's exported declarations omit property defaults and method bodies, and a changed constant reanalyses only the files naming its class. `ModelHelper::getInstance()` therefore tracks, once per model on the model class, the files declaring it and its parents below `Model` with their traits, and the files of constants their property defaults read. A constant read through a class depends on that class and its parents up to the declaring one, which can override it, and on the constants its value reads (`SourceFiles`). An overriding `getConnectionName()` can read any config value and is not interpreted, so that model also depends on every booted config source (`ConfigSourceResolver::getAllSources()`: the config directories, the framework config directory and package merge files), computed once. Regressions: warm edits of a parent's `$connection` default and of the config such an override reads. - Forwarded builder methods. A caller depends only on the class it calls through and the classes its types name, so a call through a model or relation never depends on the model's custom builder or query class. `BuilderDependencies` declares, per builder instantiation (`getCacheKey()`, since the query class can come from active template types), class dependencies on a custom builder and query class and the file declaring a custom `$passthru` (a property default, so not a declaration). It records them on the owner every caller depends on: the model for model calls, the related model for relation calls (never the shared relation class), the builder for direct calls. Both forwarding extensions register before resolving the method, so restoring a removed method clears the stale error. Macro registrations stay with the shared tracking below. Regressions: warm builder (passthru alone, then a declaration) and query-class edits with recovery, each route calling through its own classes so one route's inputs cannot mask another's. -- Track runtime inputs explicitly; normal class dependencies alone do not capture registration code. For macros/mixins, track registered closure/method source files through reflection, plus registration providers and the mixin class itself. For container string keys, track loaded provider source, `bootstrap/app.php`, `bootstrap/providers.php`, Composer's installed metadata/root discovery configuration and package discovery cache, Testbench YAML, `.env` and the relevant pre-boot environment identity. Resolved concrete classes retain normal class dependencies. For console definitions, track command class files and `routes/console.php`. Auth configuration and view/translation namespace registrations inherit their config/provider/resource dependencies. Include files called by registration helpers where they supply a consumed fact; retain an explicit dependency at that boundary rather than assuming the analyzed caller references them. Compute shared inputs once per process, not per symbol. +- Track runtime inputs explicitly; normal class dependencies alone do not capture registration code. For macros/mixins, track registered closure/method source files through reflection, plus registration providers and the mixin class itself. For container string keys, track loaded provider source, `bootstrap/app.php`, `bootstrap/providers.php`, Composer's installed metadata/root discovery configuration and package discovery cache, Testbench YAML, `.env` and the relevant pre-boot environment identity. Resolved concrete classes retain normal class dependencies. For console definitions, track command class files and `routes/console.php`. The booted morph map, which the eager-load map types read, comes from provider registrations and joins these inputs. Auth configuration and view/translation namespace registrations inherit their config/provider/resource dependencies. Include files called by registration helpers where they supply a consumed fact; retain an explicit dependency at that boundary rather than assuming the analyzed caller references them. Compute shared inputs once per process, not per symbol. - Package-mode boot paths point into Testbench's per-process runtime copy, so tracked config (including the schema facts' connection config), view and translation dependencies vanish between runs. Resolve provenance once at this shared tracking boundary, for all three: track the actual source inputs of copied files and registrations, and keep conservative invalidation where provenance is unknown (the analyzer fixture overwriting runtime `config/auth.php` and `resources/views`, other Testbench file changes, borrowed runtimes, a predefined `BASE_PATH`). No per-parser remapping or blanket prefix substitution. Required before final integration signoff. - Boot-derived config mutation targets (the recorded `ConfigMutationTracker` keys) depend on external conditions, such as Testbench rewriting `database.default` only when the SQLite database file is missing. A stale result can keep a wrong narrow type or an unnecessarily broad one, and provider hashes and environment identity do not notice the file appearing or disappearing. Include that condition (file presence) in the shared tracking, and cover a warm-cache presence transition in both directions against a cold run. No new tracking in the config parser, no boot during cache restoration and no database content hashing. - `HypervelStubFilesExtension` discovers stub files from a directory. PHPStan rechecks the hashes of stubs recorded in the cache but not a changed list from an extension, so track stub-directory membership with the shared inputs and verify that adding a stub with unchanged extension PHP/config invalidates a warm cache. @@ -556,7 +558,7 @@ Port upstream rule/type/reflection/unit/integration tests under `tests/PHPStan`, The process keeps one shared analyzer app. Its PHPUnit bootstrap, `tests/PHPStan/phpunit-bootstrap.php`, loads the extension's bootstrap before any test, so the app's error and exception handlers predate PHPUnit's per-test handler checks (`failOnRisky`); unit-style analyzer tests extend `Hypervel\Tests\PHPStan\TestCase`, which keeps those handlers rather than flushing them through `HandleExceptions::flushState()`. Tests that deliberately change its config, paths, bindings or macros restore their own changes; no blanket flush. Do not run Hypervel Testbench test cases in this process: their teardown flushes the shared app. Boot, cache and install checks run as subprocess fixtures with their own bootstrap/autoload; source runtime fixes stay in their owning framework suites. Every `setUp()` override calls the PHPStan parent initializer. Drop upstream `NoEnvCallsOutsideOfConfigRuleTest::setUp()`/`overrideConfigPath()`: it skips that initializer and permanently changes the app config path, which its explicit `configDirectories` never consume. Verify multiple tests and files in one reused process with existing macro/container cases (a focused ParaTest `-p1` run); no generic lifecycle test framework. -Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime for its assets instead of modifying its committed skeleton, writing into vendor, or always-loading package routes. Keep upstream's `App\`, `Database\Factories\` and `Database\Migrations\` namespaces: `Factory::resolveFactoryName()` derives factories from the app namespace that `Application::getNamespace()` reads from the Testbench skeleton (`App\`). Register those prefixes on Composer's existing loader from `tests/PHPStan/phpunit-bootstrap.php` before PHPStan reflection starts; PHPStan's `AutoloadSourceLocator` resolves them through `ClassLoader::findFile()`. No root `autoload-dev` mapping for these fixture-app prefixes, and no custom autoloader. Root `autoload-dev.classmap` lists `tests/PHPStan/{Integration,Rules,Type,Unit}/data`: PHPStan's test source locator discovers functions and inline helper classes declared in analysis fixtures through Composer classmap directories, without executing the fixtures, as upstream's `tests/` classmap does. Keep existing `types/` fixtures as public typing regressions and update moved references. +Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime for its assets instead of modifying its committed skeleton, writing into vendor, or always-loading package routes. Keep upstream's `App\`, `Database\Factories\` and `Database\Migrations\` namespaces: `Factory::resolveFactoryName()` derives factories from the app namespace that `Application::getNamespace()` reads from the Testbench skeleton (`App\`). Register those prefixes on Composer's existing loader from `tests/PHPStan/phpunit-bootstrap.php` before PHPStan reflection starts; PHPStan's `AutoloadSourceLocator` resolves them through `ClassLoader::findFile()`. No root `autoload-dev` mapping for these fixture-app prefixes, and no custom autoloader. Root `autoload-dev.classmap` lists `tests/PHPStan/{Integration,Rules,Type,Unit}/data`: PHPStan's test source locator discovers functions and inline helper classes declared in analysis fixtures through Composer classmap directories, without executing the fixtures, as upstream's `tests/` classmap does. Rebuild Composer's autoloader after adding fixture classes and before verification. Keep existing `types/` fixtures as public typing regressions and update moved references. Keep type fixtures that intentionally exercise still-supported APIs. In `phpstan.types.neon.dist`, set `noLegacyModelScopes`, `noLegacyModelProperties`, `noLegacyQueueProperties` and `noPipeSeparatedValidationRules` to `false`, with one comment that these fixtures assert supported types rather than authoring policy. Also set `checkModelMethodVisibility` and `noUnnecessaryEnumerableToArrayCalls` to `false` in this harness: `CustomBuilderForwarding.php` deliberately declares public scopes, and Collection/LazyCollection fixtures assert conversion return types. These preferences retain their normal defaults in source/consumer analysis and their dedicated `RuleTestCase` coverage. Preserve type assertions; do not delete compatibility cases. For upstream always-registered diagnostics without a switch, use a local identifier suppression with a fixture-specific reason only at the intentional call (for example the existing `value('foo', 42)` and `with(new User)` helper fixtures). Do not invent public switches or a second rule-registration system solely for the harness. @@ -601,7 +603,7 @@ For e2e baselines, preserve known intentional analyzer limitations with reasons - Audit deprecated-API usage in those same consumers, including calls to PHP, Symfony, Carbon and other vendor APIs, not just Hypervel declarations. Use the supported replacement when it preserves the contract. Keep a reasoned identifier-specific ignore when deprecated usage remains necessary, including compatibility with a still-supported public API. Do not remove supported APIs, strip truthful deprecation tags, mark unrelated code deprecated to silence errors, or add runtime workarounds merely to satisfy analysis. Dependency defects follow the normal owner decision workflow. - Add `src/docs/static-analysis.md`, titled Static Analysis, as the canonical page, with navigation links, following existing Laravel-style documentation. Cover installation, app/Testbench boot (package analysis types auth users from the configured Testbench app, whose users model `AUTH_MODEL` or the workbench `User` selects), supported versions, configuration/defaults, optional integrations, custom PHPDoc types, diagnostics, suppressions, safe fixes, cache behavior and the distinction between authoring recommendations and runtime API removals. - Authoring guidance: a small overview table links to short topic sections; each topic gives the intended pattern with a compact example and its legitimate exception. Keep detailed examples out of wide tables and do not copy the configuration catalog. Fix first; ignore only a legitimate exception or analyzer limitation with `@phpstan-ignore identifier (reason)`, never a baseline for a real defect. A fact inferred from one boot or a shipped default does not prove a branch dead in every consumer, so a truthful consumer-contract PHPDoc may widen an over-specific inferred type. Use typed getters for single-type config values and `get()` only for intentionally nullable, union or mixed ones. Each new diagnostic's guidance lands with its implementation slice. - - Boundaries: config types come from static expressions, never `.env` values; container resolution, auth guards and user models, console commands, views, translations and the default locale come from the booted application, whose environment can select drivers and models. Package mode sees the Testbench application's registrations, not every consuming application. State the inference limits: unexecuted config writes through unrecognized flows, and the env rule's named-call matching. State in the page and release notes that adopting the documented setup diagnoses existing reason-less/blanket ignores and uses the included deprecation extension for known deprecated APIs across dependencies, including PHP, Symfony and Carbon. Explain its standard loading and opt-out mechanisms and upstream deprecated-scope behavior; do not promise detection of every runtime deprecation regardless of available metadata. Keep the package README minimal. + - Boundaries: framework-aware analysis requires `hypervel/phpstan` (plain PHPStan reads the framework's analyzer-only source refinements as `mixed`); config types come from static expressions, never `.env` values; container resolution, auth guards and user models, console commands, views, translations and the default locale come from the booted application, whose environment can select drivers and models. Package mode sees the Testbench application's registrations, not every consuming application. State the inference limits: unexecuted config writes through unrecognized flows, and the env rule's named-call matching. State in the page and release notes that adopting the documented setup diagnoses existing reason-less/blanket ignores and uses the included deprecation extension for known deprecated APIs across dependencies, including PHP, Symfony and Carbon. Explain its standard loading and opt-out mechanisms and upstream deprecated-scope behavior; do not promise detection of every runtime deprecation regardless of available metadata. Keep the package README minimal. - Add a concise `src/docs/porting-from-laravel.md` entry: replace `larastan/larastan` with `hypervel/phpstan`, update the extension include, migrate Larastan-owned ignore identifiers (`larastan.*`, `rules.modelAppends`) to the documented `hypervel.*` mapping, and retain PHPStan core identifiers. Explain that retained parameter names remain compatible while defaults and omitted Octane/deferred-provider settings differ; link to the Static Analysis page. - Update affected feature docs, skeleton setup, testing/type-fixture instructions, component manifests and package READMEs for centralized extension ownership. Amend `docs/todo.md` by removing completed items and the superseded instruction to keep extensions distributed. Preserve still-unimplemented runtime-removal and unrelated todos; update their references where this package now supplies checks. Never rewrite historical plans. - Record intentionally omitted upstream features in the package README, a concise source comment at the natural registration/insertion point and `REMOVED:` comments at matching test locations, as required by the porting policy. Do not create empty placeholder classes for omitted features or annotate routine namespace changes and bug fixes as public API differences. diff --git a/src/docs/static-analysis.md b/src/docs/static-analysis.md index e861fb77c..fd813c466 100644 --- a/src/docs/static-analysis.md +++ b/src/docs/static-analysis.md @@ -117,6 +117,15 @@ Declare the type the collection is meant to hold when you create it: $ids = collect([1, 2, 3]); ``` +A custom collection class that fixes its item type, such as a `UserCollection` that extends `Collection`, keeps that type when methods such as `map()`, `mapWithKeys()`, `groupBy()` and `chunk()` return the same class holding other items. This is a PHPStan limitation. For example, `$users->groupBy('team_id')` is typed as a `UserCollection` of users, although it holds user collections. Declare the result's type where your code relies on it: + +```php +use Hypervel\Support\Collection; + +/** @var Collection $teams */ +$teams = $users->groupBy('team_id'); +``` + ### Model Properties diff --git a/src/phpstan/README.md b/src/phpstan/README.md index 03ee8c1d0..c681c3217 100644 --- a/src/phpstan/README.md +++ b/src/phpstan/README.md @@ -22,6 +22,7 @@ Documentation: https://hypervel.org/docs/static-analysis - A `@method scopeActive()` tag does not add an `active()` scope, because Eloquent only calls scope methods that exist. Upstream adds one. - A dynamic where, such as `whereEmail()`, that names an unknown column is still reported as an undefined method, with the column in a tip. Unlike upstream, the call keeps the builder or relation type rather than becoming an error type. - Calls an Eloquent builder forwards to its query follow `Builder::__call()`. A query macro returns the builder, or the relation it was called through, instead of the macro's result. A passthru method such as `dumpRawSql()` returns the raw query, whose rows are not models. `macro()` and a static `mixin()` return null, and a forwarded query method cannot be called statically on a builder class. Upstream keeps the query macro's and passthru results, returns the builder from both registration methods, and allows those static calls. A local macro keeps its own `$this`, while `Builder::macro()` binds a global macro to the builder. Upstream binds both as the query's `macro()` declares. +- `relation-of` gives a strict union when several relations may apply, so an eager-loading callback must accept each of them. Upstream gives a benevolent union. - A relation path is unknown when any model in a union lacks the relation, because a subclass or `resolveRelationUsing()` can supply it. `builder-of` and `collection-of` of an unknown model give `Builder<*>` and the base model collection. Upstream drops that model, or gives `never`. - A custom builder's or relation's override of a method such as `get()` keeps its declared collection type. Upstream replaces it with the model's collection class. From 5c91287164c57d9312aea5fb0608e6748863a2d3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:19:28 +0000 Subject: [PATCH 154/303] Isolate factory eager-loading suppression and honor model collections Replace the factory make path's worker-global eager-loading toggle with scoped CoroutineContext suppression. Preserve nested state, callback returns and exception restoration without changing the boot setting seen by other coroutines. Build createMany and makeMany results through the model's collection hook while suppressing relationship autoloading, matching counted make/create results. Cover custom collections, quiet events, unsaved-model autoloading and coroutine isolation. Reviewed combined checkpoint: formatting and all three PHPStan configurations pass; 3,020 analyzer tests pass. Runtime validation includes the Database and Integration/Database suites and final focused factory, coroutine-safety and relationship-autoload tests. --- .../src/Eloquent/Factories/Factory.php | 45 ++++++----- src/database/src/Eloquent/Model.php | 31 ++++++- .../Database/DatabaseEloquentFactoryTest.php | 81 +++++++++++++++++++ .../Eloquent/ModelCoroutineSafetyTest.php | 68 ++++++++++++++-- 4 files changed, 195 insertions(+), 30 deletions(-) diff --git a/src/database/src/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php index 3874b019f..a78a9d732 100644 --- a/src/database/src/Eloquent/Factories/Factory.php +++ b/src/database/src/Eloquent/Factories/Factory.php @@ -256,13 +256,17 @@ public function createMany(int|iterable|null $records = null): EloquentCollectio $records = array_fill(0, $records, []); } - return new EloquentCollection( - (new Collection($records))->map(function ($record) { - /** @var TModel $model */ - $model = $this->state($record)->create(); + $models = (new Collection($records))->map(function ($record) { + /** @var TModel $model */ + $model = $this->state($record)->create(); - return $model; - }) + return $model; + }); + + // Unlike Laravel, use the model's own collection rather than a plain Eloquent collection, as make() does + // for a count. Like make(), build it without automatic eager loading. + return Model::withoutAutomaticallyEagerLoadingRelationships( + fn () => $this->newModel()->newCollection($models->all()), ); } @@ -383,13 +387,8 @@ public function makeOne(callable|array $attributes = []): Model */ public function make(callable|array $attributes = [], ?Model $parent = null): EloquentCollection|Model { - $autoEagerLoadingEnabled = Model::isAutomaticallyEagerLoadingRelationships(); - - if ($autoEagerLoadingEnabled) { - Model::automaticallyEagerLoadRelationships(false); - } - - try { + // Unlike Laravel, which toggles the worker-wide setting, suppress it only for this coroutine. + return Model::withoutAutomaticallyEagerLoadingRelationships(function () use ($attributes, $parent) { if (! empty($attributes)) { return $this->state($attributes)->make([], $parent); } @@ -411,9 +410,7 @@ public function make(callable|array $attributes = [], ?Model $parent = null): El $this->callAfterMaking($instances); return $instances; - } finally { - Model::automaticallyEagerLoadRelationships($autoEagerLoadingEnabled); - } + }); } /** @@ -432,13 +429,17 @@ public function makeMany(iterable|int|null $records = null): EloquentCollection $records = array_fill(0, $records, []); } - return new EloquentCollection( - (new Collection($records))->map(function ($record) { - /** @var TModel $model */ - $model = $this->state($record)->make(); + $models = (new Collection($records))->map(function ($record) { + /** @var TModel $model */ + $model = $this->state($record)->make(); - return $model; - }) + return $model; + }); + + // Unlike Laravel, use the model's own collection rather than a plain Eloquent collection, as make() does + // for a count. Like make(), build it without automatic eager loading. + return Model::withoutAutomaticallyEagerLoadingRelationships( + fn () => $this->newModel()->newCollection($models->all()), ); } diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index b023b0e98..8301eef4a 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -82,6 +82,11 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt */ protected const string IGNORE_ON_TOUCH_CONTEXT_KEY = '__database.model.ignore_on_touch'; + /** + * Context key for suppressing automatic eager loading of relationships. + */ + protected const string AUTOMATIC_EAGER_LOADING_SUPPRESSED_CONTEXT_KEY = '__database.model.automatic_eager_loading_suppressed'; + /** * Context key for storing whether broadcasting is enabled. */ @@ -707,6 +712,29 @@ public static function automaticallyEagerLoadRelationships(bool $value = true): static::$modelsShouldAutomaticallyEagerLoadRelationships = $value; } + /** + * Execute a callback without automatically eager loading relationships for the models it retrieves or makes. + * + * Uses Context, so concurrent coroutines keep the boot setting. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn + */ + public static function withoutAutomaticallyEagerLoadingRelationships(callable $callback): mixed + { + $wasSuppressed = CoroutineContext::get(self::AUTOMATIC_EAGER_LOADING_SUPPRESSED_CONTEXT_KEY, false); + + CoroutineContext::set(self::AUTOMATIC_EAGER_LOADING_SUPPRESSED_CONTEXT_KEY, true); + + try { + return $callback(); + } finally { + CoroutineContext::set(self::AUTOMATIC_EAGER_LOADING_SUPPRESSED_CONTEXT_KEY, $wasSuppressed); + } + } + /** * Register a callback that is responsible for handling lazy loading violations. * @@ -3019,7 +3047,8 @@ public static function preventsLazyLoading(): bool */ public static function isAutomaticallyEagerLoadingRelationships(): bool { - return static::$modelsShouldAutomaticallyEagerLoadRelationships; + return static::$modelsShouldAutomaticallyEagerLoadRelationships + && ! CoroutineContext::get(self::AUTOMATIC_EAGER_LOADING_SUPPRESSED_CONTEXT_KEY, false); } /** diff --git a/tests/Database/DatabaseEloquentFactoryTest.php b/tests/Database/DatabaseEloquentFactoryTest.php index 740dd7d20..52e70ac37 100644 --- a/tests/Database/DatabaseEloquentFactoryTest.php +++ b/tests/Database/DatabaseEloquentFactoryTest.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Eloquent\Attributes\CollectedBy; use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder; use Hypervel\Database\Eloquent\Attributes\UseFactory; use Hypervel\Database\Eloquent\Builder; @@ -28,6 +29,7 @@ use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Schema\Builder as SchemaBuilder; +use Hypervel\Events\Dispatcher; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Str; use Hypervel\Tests\Database\Fixtures\Models\Money\Price; @@ -248,6 +250,62 @@ public function testMakeManyCreatesUnpersistedModelInstances(): void $this->assertCount(0, User::all()); } + public function testCreateManyAndMakeManyReturnTheModelCollection(): void + { + $users = CollectedUserFactory::new()->createMany(2); + $this->assertInstanceOf(CollectedUserCollection::class, $users); + $this->assertCount(2, $users); + + $users = CollectedUserFactory::new()->createMany([]); + $this->assertInstanceOf(CollectedUserCollection::class, $users); + $this->assertCount(0, $users); + + $users = CollectedUserFactory::new()->makeMany(2); + $this->assertInstanceOf(CollectedUserCollection::class, $users); + $this->assertCount(2, $users); + + $users = CollectedUserFactory::new()->makeMany([]); + $this->assertInstanceOf(CollectedUserCollection::class, $users); + $this->assertCount(0, $users); + + $this->assertCount(2, CollectedUser::all()); + } + + public function testCreateManyQuietlyReturnsTheModelCollectionWithoutEvents(): void + { + Eloquent::setEventDispatcher(new Dispatcher); + + $created = 0; + CollectedUser::created(function () use (&$created) { + ++$created; + }); + + $users = CollectedUserFactory::new()->createManyQuietly(2); + + $this->assertInstanceOf(CollectedUserCollection::class, $users); + $this->assertCount(2, $users); + $this->assertSame(0, $created); + } + + public function testCreateManyAndMakeManyDoNotAutomaticallyEagerLoadRelationships(): void + { + Eloquent::automaticallyEagerLoadRelationships(); + + try { + foreach ([CollectedUserFactory::new()->createMany(2), CollectedUserFactory::new()->makeMany(2)] as $users) { + $this->assertInstanceOf(CollectedUserCollection::class, $users); + + foreach ($users as $user) { + $this->assertFalse($user->hasRelationAutoloadCallback()); + } + } + + $this->assertTrue(Eloquent::isAutomaticallyEagerLoadingRelationships()); + } finally { + Eloquent::automaticallyEagerLoadRelationships(false); + } + } + public function testBasicModelAttributesCanBeCreated(): void { $user = UserFactory::new()->raw(); @@ -1792,6 +1850,29 @@ public function definition(): array } } +#[CollectedBy(CollectedUserCollection::class)] +class CollectedUser extends Eloquent +{ + protected ?string $table = 'users'; +} + +/** @extends Collection */ +class CollectedUserCollection extends Collection +{ +} + +class CollectedUserFactory extends Factory +{ + protected ?string $model = CollectedUser::class; + + public function definition(): array + { + return [ + 'name' => 'Taylor Otwell', + ]; + } +} + class UserWithMutator extends User { public bool $timestamps = false; diff --git a/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php b/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php index 4793c2016..51d2dc48c 100644 --- a/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php +++ b/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php @@ -13,15 +13,12 @@ use RuntimeException; use function Hypervel\Coroutine\go; +use function Hypervel\Coroutine\parallel; /** - * Tests coroutine safety of Model state methods. - * - * These tests verify that withoutEvents(), withoutBroadcasting(), - * withoutTouching(), and withoutTimestamps() use Context - * (per-coroutine storage) rather than static properties - * (process-global), ensuring concurrent requests don't interfere - * with each other. + * Tests that Model's scoped state methods use Context (per-coroutine storage) + * rather than static properties (process-global), so concurrent requests + * don't interfere with each other. */ class ModelCoroutineSafetyTest extends DatabaseTestCase { @@ -408,6 +405,63 @@ public function testWithoutTimestampsIsCoroutineIsolated(): void $this->assertFalse($results[2], 'Coroutine 2 should NOT be ignoring timestamps (isolated context)'); } + public function testWithoutAutomaticallyEagerLoadingRelationshipsRestoresNestedStateAfterException(): void + { + Model::automaticallyEagerLoadRelationships(); + $expectedException = new RuntimeException('Test exception'); + $caughtException = null; + + try { + $result = Model::withoutAutomaticallyEagerLoadingRelationships(function () use ($expectedException, &$caughtException) { + try { + Model::withoutAutomaticallyEagerLoadingRelationships(function () use ($expectedException): never { + $this->assertFalse(Model::isAutomaticallyEagerLoadingRelationships()); + + throw $expectedException; + }); + } catch (RuntimeException $exception) { + $caughtException = $exception; + } + + $this->assertFalse(Model::isAutomaticallyEagerLoadingRelationships()); + + return 'result'; + }); + + $this->assertSame('result', $result); + $this->assertSame($expectedException, $caughtException); + $this->assertTrue(Model::isAutomaticallyEagerLoadingRelationships()); + } finally { + Model::automaticallyEagerLoadRelationships(false); + } + } + + public function testWithoutAutomaticallyEagerLoadingRelationshipsIsCoroutineIsolated(): void + { + Model::automaticallyEagerLoadRelationships(); + + try { + [$suppressed, $isolated] = parallel([ + fn () => Model::withoutAutomaticallyEagerLoadingRelationships(function () { + usleep(50000); + + return Model::isAutomaticallyEagerLoadingRelationships(); + }), + function () { + usleep(10000); + + return Model::isAutomaticallyEagerLoadingRelationships(); + }, + ]); + + $this->assertFalse($suppressed, 'Coroutine 1 should not automatically eager load relationships'); + $this->assertTrue($isolated, 'Coroutine 2 should keep the boot setting (isolated context)'); + $this->assertTrue(Model::isAutomaticallyEagerLoadingRelationships()); + } finally { + Model::automaticallyEagerLoadRelationships(false); + } + } + public function testWithoutRecursionIsCoroutineIsolated(): void { $model = new RecursionTestModel; From 1e12ee69139c19b8214fb4974ce0c48badaf394d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:19:28 +0000 Subject: [PATCH 155/303] Track factory count state through supported creation flows Port the count-aware factory type with state-sensitive equality, subtype comparisons and cache identity. Infer concrete factory new/times results, reset state for new and forEachSequence, and retain declarations or unknown state for custom implementations. Follow batch methods' receiver mutation through PHPStan's statement and assignment type-specification hook, excluding fresh call receivers. Preserve native generic factory types instead of adding a second state hierarchy. Keep unrelated return types intact and retain the documented nested-call inference boundary. Reviewed combined checkpoint: formatting and all three PHPStan configurations pass; 3,020 analyzer tests pass. Runtime validation includes the Database and Integration/Database suites and final focused factory, coroutine-safety and relationship-autoload tests. --- ...actoryDynamicMethodReturnTypeExtension.php | 122 ++++++++++++++++++ ...DynamicStaticMethodReturnTypeExtension.php | 80 ++++++++++++ .../src/Types/Factory/ModelFactoryType.php | 82 ++++++++++++ ...ctoryCountResetTypeSpecifyingExtension.php | 87 +++++++++++++ 4 files changed, 371 insertions(+) create mode 100644 src/phpstan/src/ReturnTypes/FactoryDynamicMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/ReturnTypes/FactoryDynamicStaticMethodReturnTypeExtension.php create mode 100644 src/phpstan/src/Types/Factory/ModelFactoryType.php create mode 100644 src/phpstan/src/Types/FactoryCountResetTypeSpecifyingExtension.php diff --git a/src/phpstan/src/ReturnTypes/FactoryDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/FactoryDynamicMethodReturnTypeExtension.php new file mode 100644 index 000000000..4199b426d --- /dev/null +++ b/src/phpstan/src/ReturnTypes/FactoryDynamicMethodReturnTypeExtension.php @@ -0,0 +1,122 @@ +getType($methodCall->var); + + if (! $calledOnType instanceof ModelFactoryType) { + return null; + } + + $methodName = $methodReflection->getName(); + + // Unlike upstream, a factory's own implementation of these methods is not treated as the base one. + $isBaseMethod = $methodReflection->getDeclaringClass()->getName() === Factory::class; + + if ($isBaseMethod && in_array($methodName, ['count', 'times'], true)) { + // Unlike upstream's error type, a missing count keeps the declaration beside PHPStan's own report. + if ($methodCall->getArgs() === []) { + return null; + } + + $argType = $scope->getType($methodCall->getArgs()[0]->value); + + if ($argType->isInteger()->yes()) { + return $calledOnType->withSingleModel(TrinaryLogic::createNo()); + } + + if ($argType->isNull()->yes()) { + return $calledOnType->withSingleModel(TrinaryLogic::createYes()); + } + + return $calledOnType->withSingleModel(TrinaryLogic::createMaybe()); + } + + // Unlike upstream, which keeps the receiver's count, new() starts a factory without a count, and + // forEachSequence() sets one. + if ($isBaseMethod && $methodName === 'new') { + return $calledOnType->withSingleModel(TrinaryLogic::createYes()); + } + + if ($isBaseMethod && $methodName === 'forEachSequence') { + return $calledOnType->withSingleModel(TrinaryLogic::createNo()); + } + + $originalReturnType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + + if (in_array($methodName, ['create', 'createQuietly', 'make'], true)) { + if (! $isBaseMethod) { + return null; + } + + if ($calledOnType->isSingleModel()->yes()) { + return TypeCombinator::remove($originalReturnType, new ObjectType(Collection::class)); + } + + if ($calledOnType->isSingleModel()->no()) { + return TypeCombinator::remove($originalReturnType, new ObjectType(Model::class)); + } + } + + // Unlike upstream, only a method returning the factory's own class keeps its count: a broader return, + // such as mixed or another factory, is not this factory. + if ($originalReturnType->getObjectClassNames() !== [$calledOnType->getClassName()] + || ! $originalReturnType->isSuperTypeOf($calledOnType)->yes()) { + return null; + } + + // A factory's own count(), times(), new() or forEachSequence() may set the count differently. Returning + // null would not help: PHPStan resolves a declared static return to the receiver, with its old count. + if (in_array($methodName, ['count', 'times', 'new', 'forEachSequence'], true)) { + return $calledOnType->withSingleModel(TrinaryLogic::createMaybe()); + } + + return $calledOnType; + } +} diff --git a/src/phpstan/src/ReturnTypes/FactoryDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/FactoryDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 000000000..ac38b98d4 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/FactoryDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,80 @@ +getDeclaringClass()->getName() === Factory::class + && in_array($methodReflection->getName(), ['new', 'times'], true); + } + + /** + * Get the factory, which new() creates without a count and times() with one. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + // self::, static:: and parent:: forward the late static class, so their declared static stays. + if (! $methodCall->class instanceof Name || $methodCall->class->isSpecialClassName()) { + return null; + } + + // A missing count keeps the declaration beside PHPStan's own report. + if ($methodReflection->getName() === 'times' && $methodCall->getArgs() === []) { + return null; + } + + $factoryReflections = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants()) + ->getReturnType() + ->getObjectClassReflections(); + + // A factory that declares its own templates keeps its generic declaration, as in Model::factory(). + if (count($factoryReflections) !== 1 || $factoryReflections[0]->isGeneric()) { + return null; + } + + return new ModelFactoryType( + $factoryReflections[0]->getName(), + null, + $factoryReflections[0], + TrinaryLogic::createFromBoolean($methodReflection->getName() === 'new'), + ); + } +} diff --git a/src/phpstan/src/Types/Factory/ModelFactoryType.php b/src/phpstan/src/Types/Factory/ModelFactoryType.php new file mode 100644 index 000000000..af5db73ae --- /dev/null +++ b/src/phpstan/src/Types/Factory/ModelFactoryType.php @@ -0,0 +1,82 @@ +isSingleModel = $isSingleModel ?? TrinaryLogic::createMaybe(); + } + + /** + * Determine whether the factory makes a single model rather than a collection. + */ + public function isSingleModel(): TrinaryLogic + { + return $this->isSingleModel; + } + + /** + * Get the same factory with the given count. + */ + public function withSingleModel(TrinaryLogic $isSingleModel): self + { + return new self($this->getClassName(), null, $this->getClassReflection(), $isSingleModel); + } + + /** + * Determine whether the type is a supertype of the given type. + * + * Unlike upstream, a known count covers another factory only when that factory is known to have the same + * count, so merging branches whose counts differ keeps them apart instead of keeping whichever came first. + */ + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + $result = parent::isSuperTypeOf($type); + + if (! $result->yes() || $this->isSingleModel->maybe() || $type instanceof CompoundType) { + return $result; + } + + return $type instanceof self && $type->isSingleModel->equals($this->isSingleModel) + ? $result + : IsSuperTypeOfResult::createMaybe(); + } + + /** + * Determine whether the type equals the given type, including its count. + */ + public function equals(Type $type): bool + { + return parent::equals($type) && $type instanceof self && $type->isSingleModel->equals($this->isSingleModel); + } + + /** + * Get the count, which keeps PHPStan's cached comparisons apart for factories whose counts differ. + */ + protected function describeAdditionalCacheKey(): string + { + return '-' . $this->isSingleModel->describe(); + } +} diff --git a/src/phpstan/src/Types/FactoryCountResetTypeSpecifyingExtension.php b/src/phpstan/src/Types/FactoryCountResetTypeSpecifyingExtension.php new file mode 100644 index 000000000..c3287d26b --- /dev/null +++ b/src/phpstan/src/Types/FactoryCountResetTypeSpecifyingExtension.php @@ -0,0 +1,87 @@ +null() + && $methodReflection->getDeclaringClass()->getName() === Factory::class + && in_array($methodReflection->getName(), ['createMany', 'createManyQuietly', 'makeMany'], true); + } + + /** + * Mark the receiving factory as making a single model. + */ + public function specifyTypes( + MethodReflection $methodReflection, + MethodCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes { + $factoryType = $scope->getType($node->var); + + // A call such as User::factory() returns a new factory each time, so it must not keep a type. + if ($node->var instanceof CallLike || ! $factoryType instanceof ModelFactoryType) { + return new SpecifiedTypes; + } + + return $this->typeSpecifier->create( + $node->var, + $factoryType->withSingleModel(TrinaryLogic::createYes()), + TypeSpecifierContext::createTrue(), + $scope, + )->setAlwaysOverwriteTypes(); + } + + /** + * Set the type specifier used to narrow the factory. + */ + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } +} From 868b9b4026af9cea2c6e2fa102091bb64beda146 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:19:28 +0000 Subject: [PATCH 156/303] Resolve model factories through declarations and runtime selection rules Resolve class-string receivers, declared factory overrides, static factory defaults, UseFactory attributes and conventional names. Use PHPStan reflection instead of autoloading analyzed factory files, track property-default dependencies, and preserve unknown union alternatives. Keep selected generic factory classes and their resolved arguments or defaults through PHPStan's native object types. Retain the upstream count-input unit coverage using the analyzer container. Reviewed combined checkpoint: formatting and all three PHPStan configurations pass; 3,020 analyzer tests pass. Runtime validation includes the Database and Integration/Database suites and final focused factory, coroutine-safety and relationship-autoload tests. --- ...DynamicStaticMethodReturnTypeExtension.php | 287 ++++++++++++++++++ ...micStaticMethodReturnTypeExtensionTest.php | 121 ++++++++ 2 files changed, 408 insertions(+) create mode 100644 src/phpstan/src/ReturnTypes/ModelFactoryDynamicStaticMethodReturnTypeExtension.php create mode 100644 tests/PHPStan/Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php diff --git a/src/phpstan/src/ReturnTypes/ModelFactoryDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ModelFactoryDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 000000000..6d3973f6a --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ModelFactoryDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,287 @@ +getName() === 'factory'; + } + + /** + * Get the model's factory, recording whether it makes one model or several. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $class = $methodCall->class; + + // Unlike upstream, which gives never, a class-string receiver names its model. + $calledOnType = $class instanceof Name + ? new ObjectType($scope->resolveName($class)) + : $scope->getType($class)->getObjectTypeOrClassStringObjectType(); + + if ($calledOnType->getObjectClassReflections() === []) { + return null; + } + + // PHPStan reports a receiver union in which a model has no factory(). + foreach ($calledOnType->getObjectClassReflections() as $classReflection) { + if (! $classReflection->hasMethod('factory')) { + return null; + } + } + + if (count($methodCall->getArgs()) === 0) { + $isSingleModel = TrinaryLogic::createYes(); + } elseif ($methodCall->getArgs()[0]->unpack) { + // PHPStan does not expand an unpacked argument for extensions. + $isSingleModel = TrinaryLogic::createMaybe(); + } else { + $argType = $scope->getType($methodCall->getArgs()[0]->value); + + $numericType = TypeCombinator::union( + new IntegerType, + new FloatType, + TypeCombinator::intersect( + new StringType, + new AccessoryNumericStringType, + ), + ); + + $isSingleModel = $numericType->isSuperTypeOf($argType)->negate()->result; + } + + return TypeCombinator::union(...array_map( + function (ClassReflection $classReflection) use ($scope, $isSingleModel): Type { + // Unlike upstream's error type, a model whose factory is not known, or whose own factory() + // implementation decides it, keeps the declared return. + $factoryReflection = $this->usesHasFactoryMethod($classReflection, 'factory') + ? $this->getFactoryReflection($classReflection, $scope) + : null; + + if ($factoryReflection === null) { + return $classReflection->getMethod('factory', $scope)->getVariants()[0]->getReturnType(); + } + + // A factory that declares its own templates keeps its generic type, whose create() and make() + // declare a model or a collection, rather than a count that its template arguments would lose. + if ($factoryReflection->isGeneric()) { + return $factoryReflection->getObjectType(); + } + + return new ModelFactoryType($factoryReflection->getName(), null, $factoryReflection, $isSingleModel); + }, + $calledOnType->getObjectClassReflections(), + )); + } + + /** + * Get the factory the model's factory() call returns. + */ + private function getFactoryReflection( + ClassReflection $modelReflection, + Scope $scope, + ): ?ClassReflection { + // An override of newFactory() is the most specific declaration. When the model does not + // override it, factory() returns the HasFactory TFactory generic. + $factoryReflection = $this->getFactoryFromMethodReturnType($modelReflection, 'newFactory', $scope); + + if ($factoryReflection !== null) { + return $factoryReflection; + } + + // Hypervel: HasFactory's own newFactory() selects a static $factory default, then the model's own + // #[UseFactory] attribute, before the naming convention. One it cannot resolve leaves the TFactory + // declaration rather than a lower-priority choice. + if ($this->usesHasFactoryMethod($modelReflection, 'newFactory')) { + $selection = $this->getSelectedFactory($modelReflection); + + if ($selection !== false) { + return $selection ?? $this->getFactoryFromMethodReturnType($modelReflection, 'factory', $scope); + } + } + + // TFactory is bound on the class that uses HasFactory. A subclass that inherits the trait + // gets the factory of its own conventional name. + if (! $this->isFactoryGenericBoundTo($modelReflection)) { + return $this->getFactoryFromNamingConvention($modelReflection) + ?? $this->getFactoryFromMethodReturnType($modelReflection, 'factory', $scope); + } + + return $this->getFactoryFromMethodReturnType($modelReflection, 'factory', $scope) + ?? $this->getFactoryFromNamingConvention($modelReflection); + } + + /** + * Determine whether the HasFactory TFactory generic applies to the model itself. + */ + private function isFactoryGenericBoundTo(ClassReflection $modelReflection): bool + { + // Callers handle a factory() override and a static $factory default before this. + return $modelReflection->getNativeReflection()->getMethod('factory')->getDeclaringClass()->getName() === $modelReflection->getName(); + } + + /** + * Determine whether the model's method is HasFactory's own rather than an override. + */ + private function usesHasFactoryMethod(ClassReflection $modelReflection, string $methodName): bool + { + return $modelReflection->hasNativeMethod($methodName) + && $modelReflection->getNativeReflection()->getMethod($methodName)->getFileName() === $this->reflectionProvider->getClass(HasFactory::class)->getFileName(); + } + + /** + * Get the factory a static $factory default or the model's own #[UseFactory] attribute selects. + * + * Returns false when neither selects one, and null when the selection cannot be resolved. + */ + private function getSelectedFactory(ClassReflection $modelReflection): ClassReflection|false|null + { + if ($modelReflection->hasNativeProperty('factory') && $modelReflection->getNativeProperty('factory')->isStatic()) { + // A property default is not part of the model's declarations. + foreach (SourceFiles::forInstance($this->reflectionProvider, $modelReflection, Model::class) as $file) { + $this->declarationDependencyTracker->trackFileDependency($modelReflection, $file); + } + + $factoryClass = $modelReflection->getNativeReflection()->getDefaultProperties()['factory'] ?? null; + + // newFactory() only reads the property when it is set. + if ($factoryClass !== null) { + return is_string($factoryClass) ? $this->findFactory($factoryClass) : null; + } + } + + // PHP attributes are not inherited, and newFactory() reads only the model's own. + $attributes = $modelReflection->getNativeReflection()->getAttributes(UseFactory::class); + + if ($attributes === []) { + return false; + } + + $arguments = $attributes[0]->getArgumentsExpressions(); + $factoryClass = $this->attributeClassNames->resolve($arguments[0] ?? $arguments['factoryClass'] ?? null, $modelReflection, $modelReflection); + + return $factoryClass !== null ? $this->findFactory($factoryClass) : null; + } + + /** + * Get the factory named by the factory name resolver. + */ + private function getFactoryFromNamingConvention(ClassReflection $modelReflection): ?ClassReflection + { + return $this->findFactory(Factory::resolveFactoryName($modelReflection->getName())); + } + + /** + * Get the concrete factory class of the given name. + */ + private function findFactory(string $className): ?ClassReflection + { + // Unlike upstream's class_exists(), this does not load the analysed factory file. + if (! $this->reflectionProvider->hasClass($className)) { + return null; + } + + $factoryReflection = $this->reflectionProvider->getClass($className); + + if (! $factoryReflection->is(Factory::class) || $factoryReflection->isAbstract()) { + return null; + } + + // A class named without template arguments takes their defaults or bounds, as PHPStan resolves + // a bare generic class name. + return $factoryReflection->isGeneric() ? $factoryReflection->withTypes([]) : $factoryReflection; + } + + /** + * Get the concrete factory a model method declares it returns. + */ + private function getFactoryFromMethodReturnType( + ClassReflection $modelReflection, + string $methodName, + Scope $scope, + ): ?ClassReflection { + if (! $modelReflection->hasMethod($methodName)) { + return null; + } + + // Read through the model's type, so a declared static, as in GenericFactory, is the called model. + $factoryReflections = $modelReflection->getObjectType() + ->getMethod($methodName, $scope) + ->getVariants()[0] + ->getReturnType() + ->getObjectClassReflections(); + + if (count($factoryReflections) !== 1) { + return null; + } + + foreach ($factoryReflections as $factoryReflection) { + if ( + $factoryReflection->is(Factory::class) + && ! $factoryReflection->isAbstract() + ) { + return $factoryReflection; + } + } + + return null; + } +} diff --git a/tests/PHPStan/Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php b/tests/PHPStan/Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php new file mode 100644 index 000000000..878bc27eb --- /dev/null +++ b/tests/PHPStan/Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php @@ -0,0 +1,121 @@ +reflectionProvider = $this->createReflectionProvider(); + } + + #[Test] + public function itSetsTheIsSingleModelFlagToTrueIfNoArgsGiven(): void + { + $class = new Name(User::class); + + $scope = $this->createMock(Scope::class); + $scope->expects($this->once())->method('resolveName')->with($class)->willReturn(User::class); + + $extension = self::getContainer()->getByType(ModelFactoryDynamicStaticMethodReturnTypeExtension::class); + + $type = $extension->getTypeFromStaticMethodCall( + $this->reflectionProvider->getClass(User::class)->getNativeMethod('factory'), + new StaticCall($class, 'factory', []), + $scope, + ); + + $this->assertInstanceOf(ModelFactoryType::class, $type); + $this->assertTrue(TrinaryLogic::createYes()->equals($type->isSingleModel())); + } + + #[Test] + #[DataProvider('argumentProvider')] + public function itSetsTheIsSingleModelFlagCorrectly(Type $phpstanType, TrinaryLogic $expected): void + { + $class = new Name(User::class); + + $scope = $this->createMock(Scope::class); + $scope->expects($this->once())->method('resolveName')->with($class)->willReturn(User::class); + $scope->method('getType')->willReturn($phpstanType); + + $extension = self::getContainer()->getByType(ModelFactoryDynamicStaticMethodReturnTypeExtension::class); + + $type = $extension->getTypeFromStaticMethodCall( + $this->reflectionProvider->getClass(User::class)->getNativeMethod('factory'), + new StaticCall($class, 'factory', [new Arg(new Int_(1))]), // args doesn't matter + $scope, + ); + + $this->assertInstanceOf(ModelFactoryType::class, $type); + $this->assertSame($expected->describe(), $type->isSingleModel()->describe()); + } + + public static function argumentProvider(): Generator + { + yield [new ConstantIntegerType(1), TrinaryLogic::createNo()]; + yield [new ConstantIntegerType(0), TrinaryLogic::createNo()]; + yield [new ConstantIntegerType(2), TrinaryLogic::createNo()]; + yield [new ConstantIntegerType(-1), TrinaryLogic::createNo()]; + yield [new ConstantFloatType(1.2), TrinaryLogic::createNo()]; + yield [new IntegerType, TrinaryLogic::createNo()]; + yield [new FloatType, TrinaryLogic::createNo()]; + yield [ + new IntersectionType([ + new ConstantStringType('1'), + new AccessoryNumericStringType, + ]), + TrinaryLogic::createNo(), + ]; + + yield [new UnionType([new FloatType, new ConstantIntegerType(1)]), TrinaryLogic::createNo()]; + + yield [new NullType, TrinaryLogic::createYes()]; + yield [new ObjectType('App\User'), TrinaryLogic::createYes()]; + yield [new ConstantStringType('foo'), TrinaryLogic::createYes()]; + + yield [new MixedType, TrinaryLogic::createMaybe()]; + yield [new UnionType([new NullType, new ConstantIntegerType(1)]), TrinaryLogic::createMaybe()]; + yield [new UnionType([new ArrayType(new IntegerType, new IntegerType), new IntegerType]), TrinaryLogic::createMaybe()]; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} From ae3c54049b6667433db3c88b714de3f0af6d844f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:19:28 +0000 Subject: [PATCH 157/303] Validate factory relationships and refine creation attributes Port dynamic has/for factory methods with inherited model templates and relation-return checks. Accept sequence arrays for child factories and the optional deleted-at value for trashed on soft-deleting models. Refine factory definitions, creation attributes and batch records through model-property types. Resolve returned collections through the model's collection type while keeping source-native signatures where stub validation permits them. Reviewed combined checkpoint: formatting and all three PHPStan configurations pass; 3,020 analyzer tests pass. Runtime validation includes the Database and Integration/Database suites and final focused factory, coroutine-safety and relationship-autoload tests. --- ...FactoryMethodsClassReflectionExtension.php | 245 ++++++++++++++++++ src/phpstan/stubs/common/Factory.stub | 106 ++++++++ 2 files changed, 351 insertions(+) create mode 100644 src/phpstan/src/Methods/ModelFactoryMethodsClassReflectionExtension.php create mode 100644 src/phpstan/stubs/common/Factory.stub diff --git a/src/phpstan/src/Methods/ModelFactoryMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/ModelFactoryMethodsClassReflectionExtension.php new file mode 100644 index 000000000..c0cfc91f2 --- /dev/null +++ b/src/phpstan/src/Methods/ModelFactoryMethodsClassReflectionExtension.php @@ -0,0 +1,245 @@ +is(Factory::class)) { + return false; + } + + // Unlike upstream, which reads only the class and its direct parent, a factory extending another + // factory reads TModel too. + $modelType = $classReflection->getAncestorWithClassName(Factory::class)?->getActiveTemplateTypeMap()->getType('TModel'); + + if ($modelType === null) { + return false; + } + + if ($modelType->getObjectClassReflections() !== []) { + $modelReflection = $modelType->getObjectClassReflections()[0]; + } else { + $modelReflection = $this->reflectionProvider->getClass(Model::class); + } + + if ($methodName === 'trashed' && array_key_exists(SoftDeletes::class, $modelReflection->getTraits(true))) { + return true; + } + + if (! Str::startsWith($methodName, ['for', 'has'])) { + return false; + } + + $relationship = Str::camel(Str::substr($methodName, 3)); + + if (! $modelType->hasMethod($relationship)->yes()) { + return false; + } + + // Unlike upstream, the method must be able to return a relation, whose related model __call() reads. + $returnType = $modelType->getMethod($relationship, new OutOfClassScope)->getVariants()[0]->getReturnType(); + + return ! (new ObjectType(Relation::class))->isSuperTypeOf($returnType)->no(); + } + + /** + * Get the dynamic factory method. + */ + public function getMethod( + ClassReflection $classReflection, + string $methodName, + ): MethodReflection { + return new class($classReflection, $methodName) implements MethodReflection { + /** + * Create the dynamic factory method reflection. + */ + public function __construct(private ClassReflection $classReflection, private string $methodName) + { + } + + /** + * Get the class declaring the method. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->classReflection; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Get the method's doc comment. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Get the method name. + */ + public function getName(): string + { + return $this->methodName; + } + + /** + * Get the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Get the method's signatures. + * + * @return ParametersAcceptor[] + */ + public function getVariants(): array + { + $returnType = new ObjectType($this->classReflection->getName(), null, $this->classReflection); + + $variants = [ + new FunctionVariant(TemplateTypeMap::createEmpty(), null, [], false, $returnType), + ]; + + // Unlike upstream, which gives it the has* signatures, trashed() takes the deleted-at value, + // which set() would take. + if ($this->methodName === 'trashed') { + $valueParameter = $this->classReflection->getMethod('set', new OutOfClassScope)->getVariants()[0]->getParameters()[1]; + + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$valueParameter], false, $returnType); + + return $variants; + } + + $stateParameter = $this->classReflection->getMethod('state', new OutOfClassScope)->getVariants()[0]->getParameters()[0]; + + if (Str::startsWith($this->methodName, 'for')) { + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$stateParameter], false, $returnType); + + return $variants; + } + + $countParameter = $this->classReflection->getMethod('count', new OutOfClassScope)->getVariants()[0]->getParameters()[0]; + $sequenceParameter = $this->classReflection->getMethod('forEachSequence', new OutOfClassScope)->getVariants()[0]->getParameters()[0]; + + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$countParameter], false, $returnType); + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$stateParameter], false, $returnType); + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$countParameter, $stateParameter], false, $returnType); + // Unlike upstream, several arrays are accepted: __call() passes them to forEachSequence(). + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$sequenceParameter], true, $returnType); + + return $variants; + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Get the deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return null; + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Get the type the method throws. + */ + public function getThrowType(): ?Type + { + return null; + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + }; + } +} diff --git a/src/phpstan/stubs/common/Factory.stub b/src/phpstan/stubs/common/Factory.stub new file mode 100644 index 000000000..7336f0ef4 --- /dev/null +++ b/src/phpstan/stubs/common/Factory.stub @@ -0,0 +1,106 @@ +, mixed> + */ + abstract public function definition(): array; + + /** + * Get a new factory instance for the given attributes. + * + * @param array, mixed>|(callable(array): array) $attributes + */ + public static function new(callable|array $attributes = []): static; + + /** + * Create a single model and persist it to the database. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return TModel + */ + public function createOne(callable|array $attributes = []): Model; + + /** + * Create a single model and persist it to the database without dispatching any model events. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return TModel + */ + public function createOneQuietly(callable|array $attributes = []): Model; + + /** + * Create a collection of models and persist them to the database. + * + * @param null|int|iterable, mixed>> $records + * @return collection-of + */ + public function createMany(int|iterable|null $records = null); + + /** + * Create a collection of models and persist them to the database without dispatching any model events. + * + * @param null|int|iterable, mixed>> $records + * @return collection-of + */ + public function createManyQuietly(int|iterable|null $records = null); + + /** + * Create a collection of models and persist them to the database. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return collection-of|TModel + */ + public function create(callable|array $attributes = [], ?Model $parent = null); + + /** + * Create a collection of models and persist them to the database without dispatching any model events. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return collection-of|TModel + */ + public function createQuietly(callable|array $attributes = [], ?Model $parent = null); + + /** + * Make a single instance of the model. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return TModel + */ + public function makeOne(callable|array $attributes = []): Model; + + /** + * Create a collection of models. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return collection-of|TModel + */ + public function make(callable|array $attributes = [], ?Model $parent = null); + + /** + * Create a collection of models. + * + * @param null|int|iterable, mixed>> $records + * @return collection-of + */ + public function makeMany(iterable|int|null $records = null); +} From 2268678300f8fb73728226893a6be02392e4ccb5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:19:29 +0000 Subject: [PATCH 158/303] Port factory selection fixture models and factories Add the upstream domain and shape fixture families and the user factory. Cover conventional names, inherited factory declarations, attributes, static factory properties and custom factory methods without changing application namespaces. Reviewed combined checkpoint: formatting and all three PHPStan configurations pass; 3,020 analyzer tests pass. Runtime validation includes the Database and Integration/Database suites and final focused factory, coroutine-safety and relationship-autoload tests. --- .../application/app/Domain/Foo/User.php | 11 ++++ .../PHPStan/application/app/Shapes/Circle.php | 7 +++ tests/PHPStan/application/app/Shapes/Cube.php | 7 +++ .../application/app/Shapes/Ellipse.php | 15 ++++++ .../application/app/Shapes/Hexagon.php | 7 +++ tests/PHPStan/application/app/Shapes/Oval.php | 7 +++ .../application/app/Shapes/Polygon.php | 23 ++++++++ .../application/app/Shapes/Rectangle.php | 14 +++++ .../application/app/Shapes/Rhombus.php | 13 +++++ .../PHPStan/application/app/Shapes/Shape.php | 13 +++++ .../PHPStan/application/app/Shapes/Square.php | 7 +++ .../application/app/Shapes/Triangle.php | 15 ++++++ .../PHPStan/application/app/Shapes/Wedge.php | 7 +++ .../factories/Domain/Foo/UserFactory.php | 15 ++++++ .../factories/Shapes/CircleFactory.php | 20 +++++++ .../database/factories/Shapes/CubeFactory.php | 20 +++++++ .../factories/Shapes/EllipseFactory.php | 20 +++++++ .../factories/Shapes/HexagonFactory.php | 20 +++++++ .../database/factories/Shapes/OvalFactory.php | 20 +++++++ .../factories/Shapes/PolygonFactory.php | 23 ++++++++ .../factories/Shapes/RectangleFactory.php | 20 +++++++ .../factories/Shapes/RhombusFactory.php | 20 +++++++ .../factories/Shapes/ShapeFactory.php | 20 +++++++ .../factories/Shapes/TriangleFactory.php | 20 +++++++ .../factories/Shapes/WedgeFactory.php | 20 +++++++ .../database/factories/UserFactory.php | 52 +++++++++++++++++++ 26 files changed, 436 insertions(+) create mode 100644 tests/PHPStan/application/app/Domain/Foo/User.php create mode 100644 tests/PHPStan/application/app/Shapes/Circle.php create mode 100644 tests/PHPStan/application/app/Shapes/Cube.php create mode 100644 tests/PHPStan/application/app/Shapes/Ellipse.php create mode 100644 tests/PHPStan/application/app/Shapes/Hexagon.php create mode 100644 tests/PHPStan/application/app/Shapes/Oval.php create mode 100644 tests/PHPStan/application/app/Shapes/Polygon.php create mode 100644 tests/PHPStan/application/app/Shapes/Rectangle.php create mode 100644 tests/PHPStan/application/app/Shapes/Rhombus.php create mode 100644 tests/PHPStan/application/app/Shapes/Shape.php create mode 100644 tests/PHPStan/application/app/Shapes/Square.php create mode 100644 tests/PHPStan/application/app/Shapes/Triangle.php create mode 100644 tests/PHPStan/application/app/Shapes/Wedge.php create mode 100644 tests/PHPStan/application/database/factories/Domain/Foo/UserFactory.php create mode 100644 tests/PHPStan/application/database/factories/Shapes/CircleFactory.php create mode 100644 tests/PHPStan/application/database/factories/Shapes/CubeFactory.php create mode 100644 tests/PHPStan/application/database/factories/Shapes/EllipseFactory.php create mode 100644 tests/PHPStan/application/database/factories/Shapes/HexagonFactory.php create mode 100644 tests/PHPStan/application/database/factories/Shapes/OvalFactory.php create mode 100644 tests/PHPStan/application/database/factories/Shapes/PolygonFactory.php create mode 100644 tests/PHPStan/application/database/factories/Shapes/RectangleFactory.php create mode 100644 tests/PHPStan/application/database/factories/Shapes/RhombusFactory.php create mode 100644 tests/PHPStan/application/database/factories/Shapes/ShapeFactory.php create mode 100644 tests/PHPStan/application/database/factories/Shapes/TriangleFactory.php create mode 100644 tests/PHPStan/application/database/factories/Shapes/WedgeFactory.php create mode 100644 tests/PHPStan/application/database/factories/UserFactory.php diff --git a/tests/PHPStan/application/app/Domain/Foo/User.php b/tests/PHPStan/application/app/Domain/Foo/User.php new file mode 100644 index 000000000..5c2f0201e --- /dev/null +++ b/tests/PHPStan/application/app/Domain/Foo/User.php @@ -0,0 +1,11 @@ + */ + use HasFactory; +} diff --git a/tests/PHPStan/application/app/Shapes/Hexagon.php b/tests/PHPStan/application/app/Shapes/Hexagon.php new file mode 100644 index 000000000..0459e93db --- /dev/null +++ b/tests/PHPStan/application/app/Shapes/Hexagon.php @@ -0,0 +1,7 @@ + */ + use HasFactory; + + /** @return PolygonFactory|null */ + protected static function newFactory(): PolygonFactory|null + { + if (static::class !== self::class) { + return null; + } + + return PolygonFactory::new(); + } +} diff --git a/tests/PHPStan/application/app/Shapes/Rectangle.php b/tests/PHPStan/application/app/Shapes/Rectangle.php new file mode 100644 index 000000000..540628b0d --- /dev/null +++ b/tests/PHPStan/application/app/Shapes/Rectangle.php @@ -0,0 +1,14 @@ + */ + use HasFactory; +} diff --git a/tests/PHPStan/application/app/Shapes/Shape.php b/tests/PHPStan/application/app/Shapes/Shape.php new file mode 100644 index 000000000..6c4580f5c --- /dev/null +++ b/tests/PHPStan/application/app/Shapes/Shape.php @@ -0,0 +1,13 @@ + */ + use HasFactory; +} diff --git a/tests/PHPStan/application/app/Shapes/Square.php b/tests/PHPStan/application/app/Shapes/Square.php new file mode 100644 index 000000000..d4418f77f --- /dev/null +++ b/tests/PHPStan/application/app/Shapes/Square.php @@ -0,0 +1,7 @@ + */ + use HasFactory; + + protected static string $factory = TriangleFactory::class; +} diff --git a/tests/PHPStan/application/app/Shapes/Wedge.php b/tests/PHPStan/application/app/Shapes/Wedge.php new file mode 100644 index 000000000..09b51a4a2 --- /dev/null +++ b/tests/PHPStan/application/app/Shapes/Wedge.php @@ -0,0 +1,7 @@ + */ +class UserFactory extends Factory +{ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/Shapes/CircleFactory.php b/tests/PHPStan/application/database/factories/Shapes/CircleFactory.php new file mode 100644 index 000000000..48d1e1291 --- /dev/null +++ b/tests/PHPStan/application/database/factories/Shapes/CircleFactory.php @@ -0,0 +1,20 @@ + */ +class CircleFactory extends Factory +{ + protected ?string $model = Circle::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/Shapes/CubeFactory.php b/tests/PHPStan/application/database/factories/Shapes/CubeFactory.php new file mode 100644 index 000000000..2a1f80675 --- /dev/null +++ b/tests/PHPStan/application/database/factories/Shapes/CubeFactory.php @@ -0,0 +1,20 @@ + */ +class CubeFactory extends Factory +{ + protected ?string $model = Cube::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/Shapes/EllipseFactory.php b/tests/PHPStan/application/database/factories/Shapes/EllipseFactory.php new file mode 100644 index 000000000..75f7d760c --- /dev/null +++ b/tests/PHPStan/application/database/factories/Shapes/EllipseFactory.php @@ -0,0 +1,20 @@ + */ +class EllipseFactory extends Factory +{ + protected ?string $model = Ellipse::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/Shapes/HexagonFactory.php b/tests/PHPStan/application/database/factories/Shapes/HexagonFactory.php new file mode 100644 index 000000000..61a716337 --- /dev/null +++ b/tests/PHPStan/application/database/factories/Shapes/HexagonFactory.php @@ -0,0 +1,20 @@ + */ +class HexagonFactory extends Factory +{ + protected ?string $model = Hexagon::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/Shapes/OvalFactory.php b/tests/PHPStan/application/database/factories/Shapes/OvalFactory.php new file mode 100644 index 000000000..5cbfb6f8c --- /dev/null +++ b/tests/PHPStan/application/database/factories/Shapes/OvalFactory.php @@ -0,0 +1,20 @@ + */ +class OvalFactory extends Factory +{ + protected ?string $model = Oval::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/Shapes/PolygonFactory.php b/tests/PHPStan/application/database/factories/Shapes/PolygonFactory.php new file mode 100644 index 000000000..65373b572 --- /dev/null +++ b/tests/PHPStan/application/database/factories/Shapes/PolygonFactory.php @@ -0,0 +1,23 @@ + + */ +class PolygonFactory extends Factory +{ + protected ?string $model = Polygon::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/Shapes/RectangleFactory.php b/tests/PHPStan/application/database/factories/Shapes/RectangleFactory.php new file mode 100644 index 000000000..de85c0af2 --- /dev/null +++ b/tests/PHPStan/application/database/factories/Shapes/RectangleFactory.php @@ -0,0 +1,20 @@ + */ +class RectangleFactory extends Factory +{ + protected ?string $model = Rectangle::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/Shapes/RhombusFactory.php b/tests/PHPStan/application/database/factories/Shapes/RhombusFactory.php new file mode 100644 index 000000000..7753db381 --- /dev/null +++ b/tests/PHPStan/application/database/factories/Shapes/RhombusFactory.php @@ -0,0 +1,20 @@ + */ +class RhombusFactory extends Factory +{ + protected ?string $model = Rhombus::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/Shapes/ShapeFactory.php b/tests/PHPStan/application/database/factories/Shapes/ShapeFactory.php new file mode 100644 index 000000000..d2953086f --- /dev/null +++ b/tests/PHPStan/application/database/factories/Shapes/ShapeFactory.php @@ -0,0 +1,20 @@ + */ +class ShapeFactory extends Factory +{ + protected ?string $model = Shape::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/Shapes/TriangleFactory.php b/tests/PHPStan/application/database/factories/Shapes/TriangleFactory.php new file mode 100644 index 000000000..9d8a636ba --- /dev/null +++ b/tests/PHPStan/application/database/factories/Shapes/TriangleFactory.php @@ -0,0 +1,20 @@ + */ +class TriangleFactory extends Factory +{ + protected ?string $model = Triangle::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/Shapes/WedgeFactory.php b/tests/PHPStan/application/database/factories/Shapes/WedgeFactory.php new file mode 100644 index 000000000..a2e2ab7ea --- /dev/null +++ b/tests/PHPStan/application/database/factories/Shapes/WedgeFactory.php @@ -0,0 +1,20 @@ + */ +class WedgeFactory extends Factory +{ + protected ?string $model = Wedge::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} diff --git a/tests/PHPStan/application/database/factories/UserFactory.php b/tests/PHPStan/application/database/factories/UserFactory.php new file mode 100644 index 000000000..b573d1b66 --- /dev/null +++ b/tests/PHPStan/application/database/factories/UserFactory.php @@ -0,0 +1,52 @@ + + */ +class UserFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected ?string $model = User::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + 'email' => $this->faker->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + * + * @return self + */ + public function unverified() + { + return $this->state(function (array $attributes) { + return [ + 'email_verified_at' => null, + ]; + }); + } +} From 19b10febba1a09bdccbe249120cfb475e094322a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:19:29 +0000 Subject: [PATCH 159/303] Cover factory inference and attribute diagnostics Register factory type and integration fixtures for single/count-based results, merged count states, count-changing overrides, batch receiver resets, class-string receivers, generic factories and unknown factory alternatives. Check invalid model attributes, supported sequence and trashed calls, and non-relationship method names. Update public type assertions to the precise single-model results now inferred from a concrete factory new call. Reviewed combined checkpoint: formatting and all three PHPStan configurations pass; 3,020 analyzer tests pass. Runtime validation includes the Database and Integration/Database suites and final focused factory, coroutine-safety and relationship-autoload tests. --- tests/PHPStan/Integration/IntegrationTest.php | 20 + .../Integration/data/model-factories.php | 18 + .../data/model-property-model-factory.php | 10 + tests/PHPStan/Type/GeneralTypeTest.php | 1 + tests/PHPStan/Type/data/model-factories.php | 402 ++++++++++++++++++ types/Database/Eloquent/Factories/Factory.php | 18 +- 6 files changed, 460 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Integration/data/model-factories.php create mode 100644 tests/PHPStan/Integration/data/model-property-model-factory.php create mode 100644 tests/PHPStan/Type/data/model-factories.php diff --git a/tests/PHPStan/Integration/IntegrationTest.php b/tests/PHPStan/Integration/IntegrationTest.php index d01ebff8e..3b96e006d 100644 --- a/tests/PHPStan/Integration/IntegrationTest.php +++ b/tests/PHPStan/Integration/IntegrationTest.php @@ -201,6 +201,16 @@ public static function dataIntegrationTests(): iterable ], ]; + // Hypervel: also covers the has* sequence and trashed() value forms, trashed() on a model that does not + // soft delete, and a has* name whose method returns no relation. + yield [ + __DIR__ . '/data/model-factories.php', + [ + 16 => ['Call to an undefined method Database\Factories\Post\PostFactory::trashed().'], + 17 => ['Call to an undefined method Database\Factories\UserFactory::hasGetTable().'], + ], + ]; + yield [__DIR__ . '/data/blade-view.php']; yield [ @@ -433,6 +443,16 @@ public static function dataIntegrationTests(): iterable ], ]; + // Hypervel: createQuietly() and createMany() check the attributes too. + yield [ + __DIR__ . '/data/model-property-model-factory.php', + [ + 7 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Factories\Factory::createOne() expects array|(callable(array): array), array{foo: \'bar\'} given.'], + 8 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Factories\Factory::createQuietly() expects array|(callable(array): array), array{foo: \'bar\'} given.'], + 9 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Factories\Factory::createMany() expects int|iterable>|null, array> given.'], + ], + ]; + yield [ __DIR__ . '/data/model-property-relation.php', [ diff --git a/tests/PHPStan/Integration/data/model-factories.php b/tests/PHPStan/Integration/data/model-factories.php new file mode 100644 index 000000000..95913fd22 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-factories.php @@ -0,0 +1,18 @@ +create(new Sequence()); + User::factory()->createQuietly(new Sequence()); + User::factory()->make(new Sequence()); + + User::factory()->hasPosts(['title' => 'a'], ['title' => 'b']); + User::factory()->trashed('2026-01-01 00:00:00'); + Post::factory()->trashed(); + User::factory()->hasGetTable(); +} diff --git a/tests/PHPStan/Integration/data/model-property-model-factory.php b/tests/PHPStan/Integration/data/model-property-model-factory.php new file mode 100644 index 000000000..69de497c0 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-model-factory.php @@ -0,0 +1,10 @@ +createOne(['foo' => 'bar']); + \App\User::factory()->createQuietly(['foo' => 'bar']); + \App\User::factory()->createMany([['foo' => 'bar']]); +} diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php index 5db494829..e03027680 100644 --- a/tests/PHPStan/Type/GeneralTypeTest.php +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -68,6 +68,7 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/data/model-collections-l11-28.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-collections-l13-4.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-date-column-casts.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-factories.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-increment-each-l13-3.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-increment-each-l13-20.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/model-methods.php'); diff --git a/tests/PHPStan/Type/data/model-factories.php b/tests/PHPStan/Type/data/model-factories.php new file mode 100644 index 000000000..df3fe44a4 --- /dev/null +++ b/tests/PHPStan/Type/data/model-factories.php @@ -0,0 +1,402 @@ + $userClass + */ +function test(?int $foo, bool $many, UserFactory $plainFactory, string $userClass, Tag|Gizmo $tagOrGizmo, User|Account $userOrAccount): void { + assertType('Database\Factories\UserFactory', User::factory()); + assertType('Database\Factories\UserFactory', User::factory()->new()); + assertType('App\User', User::factory()->createOne()); + assertType('App\User', User::factory()->createOneQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->createMany([])); + assertType('App\User', User::factory()->makeOne()); + + assertType('App\User', User::factory()->create()); + assertType('App\User', User::factory()->createQuietly()); + assertType('App\User', User::factory()->make()); + + assertType('App\User', User::factory()->configure()->create()); + assertType('App\User', User::factory()->configure()->createQuietly()); + assertType('App\User', User::factory()->configure()->make()); + + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->create()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->createQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->make()); + assertType('App\User', User::factory(2)->createOneQuietly()); + + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->count(1)->create()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->count(1)->createQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->count(1)->make()); + assertType('App\User', User::factory()->count(2)->createOneQuietly()); + + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->times(1)->create()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->times(1)->createQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->times(1)->make()); + assertType('App\User', User::factory()->times(2)->createOneQuietly()); + + assertType('App\User', User::factory(2)->count(null)->create()); + assertType('App\User', User::factory(2)->count(null)->createQuietly()); + assertType('App\User', User::factory(2)->count(null)->createOneQuietly()); + assertType('App\User', User::factory(2)->count(null)->make()); + + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->times(3)->create()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->times(3)->createQuietly()); + assertType('App\User', User::factory(2)->times(3)->createOneQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->times(3)->make()); + + assertType('App\User', User::factory(2)->state(['foo'])->count(null)->create()); + assertType('App\User', User::factory(2)->state(['foo'])->count(null)->createQuietly()); + assertType('App\User', User::factory(2)->state(['foo'])->count(null)->createOneQuietly()); + assertType('App\User', User::factory(2)->state(['foo'])->count(null)->make()); + + assertType('Database\Factories\UserFactory', User::factory()->hasPosts()); + assertType('Database\Factories\UserFactory', User::factory()->hasPosts(3)); + assertType('Database\Factories\UserFactory', User::factory()->hasPosts(['active' => 0])); + assertType('App\User', User::factory()->hasPosts(3, ['active' => 0])->createOne()); + assertType('Database\Factories\UserFactory', User::factory()->forParent()); + assertType('Database\Factories\UserFactory', User::factory()->forParent(['meta' => ['foo']])); + + assertType('Database\Factories\UserFactory', User::factory()->unverified()); + assertType('Database\Factories\UserFactory', User::factory()->afterMaking(fn (User $user) => $user)); + assertType('Database\Factories\UserFactory', User::factory()->afterCreating(fn (User $user) => $user)); + assertType('Database\Factories\Domain\Foo\UserFactory', \App\Domain\Foo\User::factory()); + + assertType('Database\Factories\Post\PostFactory', Post::factory()); + assertType('Database\Factories\Post\PostFactory', Post::factory()->new()); + assertType('App\Post', Post::factory()->createOne()); + assertType('App\Post', Post::factory()->createOneQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', Post::factory()->createMany([])); + assertType('App\Post', Post::factory()->makeOne()); + assertType('Database\Factories\Post\PostFactory', Post::factory()->afterMaking(fn (Post $post) => $post)); + assertType('Database\Factories\Post\PostFactory', Post::factory()->afterCreating(fn (Post $post) => $post)); + + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory()->count($foo)->create()); + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory()->count($foo)->createQuietly()); + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory()->count($foo)->make()); + + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory($foo)->create()); + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory($foo)->createQuietly()); + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory($foo)->make()); + + assertType('Database\Factories\UserFactory', User::factory()->trashed()); + assertType('*ERROR*', Post::factory()->trashed()); + + assertType('ModelFactories\TagFactory', Tag::factory()); + assertType('ModelFactories\Tag', Tag::factory()->create()); + assertType('Hypervel\Database\Eloquent\Collection', Tag::factory(3)->create()); + assertType('ModelFactories\TagFactory', SpecialTag::factory()); + assertType('ModelFactories\VideoFactory', Video::factory()); + assertType('ModelFactories\Video', Video::factory()->create()); + assertType('ModelFactories\ImageFactory', Image::factory()); + + assertType('Database\Factories\Shapes\ShapeFactory', Shape::factory()); + assertType('Database\Factories\Shapes\CircleFactory', Circle::factory()); + assertType('App\Shapes\Circle', Circle::factory()->create()); + assertType('Database\Factories\Shapes\ShapeFactory', Square::factory()); + // Hypervel: a generic factory keeps its template arguments. + assertType('Database\Factories\Shapes\PolygonFactory', Polygon::factory()); + assertType('Database\Factories\Shapes\HexagonFactory', Hexagon::factory()); + assertType('Database\Factories\Shapes\EllipseFactory', Ellipse::factory()); + assertType('Database\Factories\Shapes\OvalFactory', Oval::factory()); + assertType('Database\Factories\Shapes\TriangleFactory', Wedge::factory()); + assertType('Database\Factories\Shapes\ShapeFactory', Rhombus::factory()); + assertType('Database\Factories\Shapes\RectangleFactory', Cube::factory()); + + // Hypervel: merged branches with different counts keep both. + $factory = User::factory(); + if ($many) { + $factory = $factory->count(3); + } + assertType('App\User|Hypervel\Database\Eloquent\Collection', $factory->create()); + + $factory = User::factory(3); + if ($many) { + $factory = User::factory(); + } + assertType('App\User|Hypervel\Database\Eloquent\Collection', $factory->create()); + + $factory = User::factory(); + if ($many) { + $factory = $plainFactory; + } + assertType('App\User|Hypervel\Database\Eloquent\Collection', $factory->create()); + + // Hypervel: new() starts without a count, forEachSequence() sets one, and so do the static entry points. + assertType('App\User', User::factory(3)->new()->create()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->forEachSequence(['name' => 'a'], ['name' => 'b'])->create()); + assertType('App\User', UserFactory::new()->create()); + assertType('Hypervel\Database\Eloquent\Collection', UserFactory::times(3)->create()); + + // Hypervel: createMany(), createManyQuietly() and makeMany() clear the receiver's count, but a call + // receiver returns a new factory each time. + $factory = User::factory(2); + $users = $factory->createMany(1); + assertType('Hypervel\Database\Eloquent\Collection', $users); + assertType('App\User', $factory->create()); + $factory = User::factory(2); + $factory->createManyQuietly(1); + assertType('App\User', $factory->createQuietly()); + $factory = User::factory(2); + $factory->makeMany(1); + assertType('App\User', $factory->make()); + User::factory(2)->createMany(1); + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->create()); + + // Hypervel: a class-string receiver names its model, and an unpacked count is unknown. + assertType('Database\Factories\UserFactory', $userClass::factory()); + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory(...[3])->create()); + + // Hypervel: newFactory() selects a static $factory default, then the model's own #[UseFactory]. + assertType('ModelFactories\GadgetFactory', Gadget::factory()); + assertType('ModelFactories\Gadget', Gadget::factory()->create()); + assertType('ModelFactories\WidgetFactory', Widget::factory()); + assertType('Hypervel\Database\Eloquent\Factories\Factory', $tagOrGizmo::factory()); + // Hypervel: a receiver union with a model that has no factory() keeps PHPStan's own handling. + assertType('Hypervel\Database\Eloquent\Factories\Factory', $userOrAccount::factory()); + + // Hypervel: only a method returning the factory's own class keeps the factory. + assertType('mixed', Gadget::factory()->payload()); + + // Hypervel: a factory's own count() may set the count differently. + assertType('App\User|Hypervel\Database\Eloquent\Collection', CountingUserFactory::new()->count(2)->create()); + + // Hypervel: a factory that declares its own templates keeps its generic type and declared results, so + // merged factories keep each model, and a selected one keeps its class and default model. + assertType('ModelFactories\GenericFactory', Sprocket::factory()); + assertType('Hypervel\Database\Eloquent\Collection|ModelFactories\Sprocket', Sprocket::factory()->make()); + $genericFactory = Sprocket::factory(); + if ($many) { + $genericFactory = Cog::factory(); + } + assertType('Hypervel\Database\Eloquent\Collection|Hypervel\Database\Eloquent\Collection|ModelFactories\Cog|ModelFactories\Sprocket', $genericFactory->make()); + assertType('Hypervel\Database\Eloquent\Collection|ModelFactories\Lever', Lever::factory()->polished()->make()); + assertType('Hypervel\Database\Eloquent\Collection|ModelFactories\Lever', LeverFactory::new()->make()); +} + +class Comment extends Model +{ + use HasFactory; + + /** @return CommentFactory */ + protected static function newFactory(): Factory + { + return CommentFactory::new(); + } + + private function test(): void + { + assertType('ModelFactories\CommentFactory', static::factory()); + } +} + +/** @extends Factory */ +class CommentFactory extends Factory +{ + public function definition(): array + { + return []; + } +} + +class Tag extends Model +{ + /** @use HasFactory */ + use HasFactory; + + protected static string $factory = TagFactory::class; +} + +class SpecialTag extends Tag +{ +} + +/** @extends Factory */ +class TagFactory extends Factory +{ + protected ?string $model = Tag::class; + + public function definition(): array + { + return []; + } +} + +#[UseFactory(VideoFactory::class)] +class Video extends Model +{ + /** @use HasFactory */ + use HasFactory; +} + +/** @extends Factory