chore(phpstan): raise static analysis to level 3 (46→0, fix-forward)#3561
Conversation
…/guards (level 3) The UserProvider/Guard contracts require ?Authenticatable, but AuthUser::retrieveById/ retrieveByToken and ApiGuard::user() returned a `(object)` stdClass cast — a contract violation PHPStan level 3 flags (return.type), and the reason ApiGuard/WebGuard::id() couldn't safely call getAuthIdentifier(). Adds Leantime\Domain\Auth\Models\AuthenticatableUser: a #[AllowDynamicProperties] wrapper that is a behavioural drop-in for the old stdClass cast (same dynamic property reads, same json/array serialization, truthy even when empty) and only ADDS the Authenticatable accessor methods. Both guards' id() go back to getAuthIdentifier(). Auth::getRememberToken returns '' (the contract types it as string). setRememberToken is a no-op, matching Leantime's existing not-implemented remember-token behaviour. Also fixes a latent bug in AuthUser::getOrCreateUser: it overwrote the $user param (external/OAuth profile) with the email lookup result, then built the new user from the now-empty result — so auto-provisioned users got empty fields. Uses a separate $existingUser variable. NOTE: auth is regression-prone (see Bearer/Sanctum history) and these paths aren't covered by local static analysis alone — relies on the api/login/bearer/user acceptance groups in CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves the remaining level-3 findings (return.type, assign.propertyType, return.void, offsetAccess, property.defaultValue) and sets `.phpstan/phpstan.neon` level 2 → 3. `make phpstan` is green at level 3. return.type — declared types reconciled with what methods actually return (widen or correct): getClassPath/getToggleState/getTokenUrl → string|false; Db::__get → ?PDO; Cast::castDateTime → CarbonImmutable; Template::get → mixed; Files\Services::upload → array|string|false; createUserInvite → +string; Sprints::getUpcomingSprint → the Sprints model; ExceptionHandler reportable/whoopsHandler → the concrete/contract types actually returned; StartSession::cache → the Cache contract (and the lock() stub moves to Illuminate\Contracts\Cache\Repository accordingly). Repository bool methods (editUser/removeFromClient/patchUser/updateTicketStatus/removeCollaborators) return `update()/delete() > 0` so the declared bool matches (was returning the int row count). return.void — EventDispatcher::listen no longer returns the Collection (Dispatcher contract is void); CsvImport::getValues @return void → array|false. assign.propertyType — Frontcontroller::$config resolved via app(Environment::class) (config() is typed as the Laravel Repository); HandleExceptions::$app @var → nullable. Real bugs fixed (flagged): - Format::timestamp/jsTimestamp returned '' (string) for the empty case under an int|bool signature → return false. - PathManifestRepository::shouldRefresh did `$manifest[$manifest]` (array used as an array key — a runtime TypeError) → compares `$manifest != $paths`. - Files/routes.php /download.php redirect did `redirect()->to(...)`, but Leantime's redirect() helper returns a Symfony RedirectResponse with no ->to() (would fatal) → redirect($url, 301). - DelMilestone/ShowClient/ShowTicket read $result['msg'] on a falsy/non-array result → guarded. - Tickets::sortItemsHierarchically returned a non-array fallback under an : array signature → []. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Raises PHPStan strictness from level 2 to level 3 and fix-forwards newly surfaced findings (mostly return/property type correctness), including a sensitive auth-path change to return real Authenticatable instances instead of stdClass.
Changes:
- Bumps
.phpstan/phpstan.neonto level 3 and updates stubs/docs/signatures to satisfy stricter type expectations. - Fixes a handful of runtime bugs uncovered by analysis (e.g., manifest refresh comparison, redirect helper misuse, safe array access guards).
- Refactors auth provider/guards to return an
Authenticatablewrapper and restoresid()togetAuthIdentifier().
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| app/helpers.php | Updates redirect() return type docs (but still contains duplicate helper definitions). |
| app/Domain/Users/Services/Users.php | Adjusts createUserInvite() return type to match reality. |
| app/Domain/Users/Repositories/Users.php | Makes update-style repository methods return bool (> 0). |
| app/Domain/Tickets/Services/Tickets.php | Ensures sortItemsHierarchically() always returns an array. |
| app/Domain/Tickets/Repositories/Tickets.php | Makes update/delete methods return bool (> 0). |
| app/Domain/Tickets/Controllers/ShowTicket.php | Guards notification message access for non-array results. |
| app/Domain/Tickets/Controllers/DelMilestone.php | Guards notification message access for non-array results. |
| app/Domain/Sprints/Services/Sprints.php | Fixes getUpcomingSprint() declared return type to match repo model return. |
| app/Domain/Oidc/Services/Oidc.php | Corrects getTokenUrl() declared return type to include false. |
| app/Domain/Files/Services/Files.php | Widens upload() return type to include false. |
| app/Domain/Files/routes.php | Fixes redirect usage to match project redirect() helper signature. |
| app/Domain/Dashboard/Services/Dashboard.php | Avoids undefined index on parsed URL path. |
| app/Domain/CsvImport/Services/CsvImport.php | Corrects getValues() doc return type. |
| app/Domain/Clients/Controllers/ShowClient.php | Guards notification message access for non-array results. |
| app/Domain/Blueprints/Repositories/Blueprints.php | Casts new canvas id to int to satisfy declared return type. |
| app/Domain/Auth/Services/AuthUser.php | Returns AuthenticatableUser wrappers (needs null-on-miss handling). |
| app/Domain/Auth/Services/Auth.php | Makes remember token accessors comply with string contract. |
| app/Domain/Auth/Models/AuthenticatableUser.php | Adds Authenticatable-compliant dynamic-property user wrapper. |
| app/Domain/Auth/Guards/WebGuard.php | Restores id() via getAuthIdentifier(). |
| app/Domain/Auth/Guards/ApiGuard.php | Wraps API-key user data in AuthenticatableUser; restores id() contract. |
| app/Core/UI/Template.php | Updates return type for getToggleState() (docblock needs sync). |
| app/Core/Support/Format.php | Returns false instead of '' for invalid timestamps to match signature. |
| app/Core/Support/Cast.php | Updates doc return type for datetime casting. |
| app/Core/Sessions/PathManifestRepository.php | Fixes manifest refresh comparison and normalizes missing-manifest to null. |
| app/Core/Middleware/TrustProxies.php | Corrects $headers doc type to int. |
| app/Core/Middleware/StartSession.php | Adjusts cache return type to contract interface. |
| app/Core/Exceptions/HandleExceptions.php | Makes $app doc type nullable. |
| app/Core/Exceptions/ExceptionHandler.php | Corrects reportable/whoops handler return types. |
| app/Core/Events/EventDispatcher.php | Aligns listen() behavior with void return expectations. |
| app/Core/Db/Db.php | Corrects __get doc return type to `\PDO |
| app/Core/Controller/Frontcontroller.php | Fixes $config typing and widens getClassPath() to `string |
| app/Core/Bootloader.php | Removes incorrect static-instance docblock. |
| .phpstan/stubs/laravel-gaps.stub | Moves cache lock() gap to Illuminate\\Contracts\\Cache\\Repository for analysis. |
| .phpstan/phpstan.neon | Raises PHPStan analysis level from 2 to 3. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * @param string $controllerType The type of controller. Possible values are 'Controllers' or 'Hxcontrollers'. | ||
| **/ | ||
| public function getClassPath(string $controllerType, string $moduleName, string $actionName): string | ||
| public function getClassPath(string $controllerType, string $moduleName, string $actionName): string|false | ||
| { | ||
|
|
There was a problem hiding this comment.
Fixed in da42e23: getValidControllerCall now guards $classPath === false and throws NotFoundHttpException (a 404, consistent with getValidControllerMethod) before passing it on — no more TypeError on the disabled-plugin / missing-controller path.
| public function retrieveById($identifier) | ||
| { | ||
| return (object) $this->userRepo->getUser($identifier); | ||
| return new AuthenticatableUser((array) $this->userRepo->getUser($identifier)); | ||
| } | ||
|
|
||
| public function retrieveByToken($identifier, $token) | ||
| { | ||
| return (object) $this->authService->getUserByToken($token); | ||
| return new AuthenticatableUser((array) $this->authService->getUserByToken($token)); | ||
| } |
There was a problem hiding this comment.
Good catch — fixed in da42e23: retrieveById/retrieveByToken now return null when the lookup fails and only build an AuthenticatableUser from a real row, so a 'not found' can't yield a non-null (authenticated-looking) user.
| * @param string $name - the name of the submenu toggle | ||
| * @return string - the toggle state of the submenu (either "true" or "false") | ||
| * |
| * @return bool | ||
| */ | ||
| public function shouldRefresh($manifest, $paths) | ||
| { | ||
| return is_null($manifest) || $manifest[$manifest] != $paths; | ||
| return is_null($manifest) || $manifest != $paths; |
There was a problem hiding this comment.
Fixed in da42e23: shouldRefresh @param $manifest → array|null.
| * @param int $http_response_code | ||
| * @param array $headers | ||
| * @param bool|null $secure | ||
| * @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse | ||
| * @return \Symfony\Component\HttpFoundation\RedirectResponse | ||
| */ |
There was a problem hiding this comment.
Fixed in da42e23: removed the duplicate (dead) redirect()/currentRoute() definitions — the second block sat behind the same function_exists guard and never ran. Single canonical definition kept.
| * @api | ||
| */ | ||
| #[RequiresPermission(UsersPermissions::CREATE, global: true)] | ||
| public function createUserInvite(array $values): bool|int | ||
| public function createUserInvite(array $values): bool|int|string | ||
| { |
There was a problem hiding this comment.
Fixed in da42e23: signature + docblock → false|string (addUser returns false|string; the method never returns true or an int).
- Frontcontroller::getValidControllerCall: getClassPath() can return false (plugin disabled / controller missing); guard it and throw NotFoundHttpException (a 404, consistent with getValidControllerMethod) instead of passing false into the string-typed method (TypeError). - AuthUser::retrieveById/retrieveByToken: return null when the lookup fails instead of wrapping false in an (empty) AuthenticatableUser — the UserProvider contract is ?Authenticatable, and a non-null empty user could let the guard treat the request as authenticated. - Users::createUserInvite: signature + docblock → false|string (addUser returns false|string; it never returns true or an int, so bool|int|string was misleading). - Template::getToggleState: docblock @return → string|false to match the signature. - PathManifestRepository::shouldRefresh: @param $manifest → array|null (loadManifest can return null). - helpers.php: remove the duplicate (dead) redirect()/currentRoute() definitions — they were declared a second time behind the same function_exists guard, so the second copy never ran. phpstan level 3 still green; pint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
app/Domain/Oidc/Services/Oidc.php:462
getTokenUrl()now advertisesstring|false, but its only caller (requestTokens()) passes the return value directly intoHttp::post(), which expects a string URL. If endpoint discovery fails, returningfalsewill cause a runtimeTypeError(bool passed where string is required) during the OIDC callback.
Consider making getTokenUrl() always return a non-empty string and throw a descriptive exception when the token endpoint can’t be resolved (so the failure is explicit and can be handled/logged cleanly).
private function getTokenUrl(): string|false
{
if (! empty($this->tokenUrl || $this->loadEndpoints())) {
return $this->tokenUrl;
}
Raise PHPStan from level 2 → 3
Follow-up to #3559 (level 2). Climbs static analysis to level 3 (config
level: 2→3), fix-forward, no baseline.make phpstanis green at level 3 (0 errors). Clean master had 46 level-3 findings.Level 3 checks return-type and property-type correctness — i.e. the value a method actually returns / assigns matches its declared type. Most fixes are reconciling declared types with reality.
What changed
return.type — declared types corrected/widened to match what's actually returned:
getClassPath/getToggleState/getTokenUrl→string|false;Db::__get→?PDO;Cast::castDateTime→CarbonImmutable;Template::get→mixed;Files\Services::upload→array|string|false;Sprints::getUpcomingSprint→ theSprintsmodel the repo returns;ExceptionHandler::reportable/whoopsHandler→ the concrete/contract types actually returned;StartSession::cache→ the Cache contractRepository(Cache::store()'s real return — thelock()gap stub moves toIlluminate\Contracts\Cache\Repositoryto match).Repository bool methods (
editUser/removeFromClient/patchUser/updateTicketStatus/removeCollaborators) now returnupdate()/delete() > 0, so the declaredboolmatches (they were returning the int row count).return.void / assign.propertyType:
EventDispatcher::listenno longer returns itsCollection(theDispatchercontract isvoid);Frontcontroller::$configis resolved viaapp(Environment::class)(theconfig()helper is typed as the LaravelRepository);HandleExceptions::$app@varmade nullable;CsvImport::getValues@return void→array|false.🔐 Sensitive: user provider/guards now return a real
AuthenticatableThe
UserProvider/Guardcontracts require?Authenticatable, butAuthUser::retrieveById/retrieveByTokenandApiGuard::user()returned a(object)stdClass cast — a contract violation, and the reason the guards'id()couldn't callgetAuthIdentifier().New
Leantime\Domain\Auth\Models\AuthenticatableUser: a#[AllowDynamicProperties]wrapper that is a behavioural drop-in for the old stdClass cast (same dynamic property reads, same json/array serialization, truthy even when empty) and only adds theAuthenticatableaccessor methods. Both guards'id()go back togetAuthIdentifier().Auth::getRememberTokenreturns''(the contract types itstring);setRememberTokenstays a no-op (Leantime doesn't persist remember tokens).🐛 Real bugs surfaced by level 3 (fixed here)
PathManifestRepository::shouldRefreshdid$manifest[$manifest]— an array used as an array key (runtimeTypeError). Now$manifest != $paths.Files/download.phproute usedredirect()->to(...), but Leantime'sredirect()helper returns a SymfonyRedirectResponse(no->to()) — it would fatal. Changed toredirect($url, 301)(the helper's real signature; it was the onlyredirect()->to()caller in the codebase).AuthUser::getOrCreateUseroverwrote its$userparam with the empty email lookup, then built the new user from the empty result — auto-provisioned (OAuth) users got empty fields.Format::timestamp/jsTimestampreturned''(string) for the empty case under anint|boolsignature →false.Tickets::sortItemsHierarchicallyreturned a non-array fallback under an: arraysignature →[].DelMilestone/ShowClient/ShowTicketread$result['msg']on a falsy/non-array result → guarded.🤖 Generated with Claude Code