Skip to content

chore(phpstan): climb to level 5 (argument types)#3566

Merged
marcelfolaron merged 2 commits into
masterfrom
chore/phpstan-level-5
Jun 21, 2026
Merged

chore(phpstan): climb to level 5 (argument types)#3566
marcelfolaron merged 2 commits into
masterfrom
chore/phpstan-level-5

Conversation

@marcelfolaron

Copy link
Copy Markdown
Collaborator

Follows #3565 (level 4, now merged). Continues the campaign: #3559 (L2) → #3561 (L3) → #3565 (L4) → this (L5).

Level 5 enables argument-type checking (every call's args vs the callee's param types). Against the green level-4 tree it surfaced 38 errors, all argument.type, spread 1–4 per file. Fixed forward, no baseline.

Breakdown (all 38 triaged before fixing)

Category Count Nature
Call-site casts/guards 12 low risk
Framework type-gaps 11 vendor contract looseness
Type-hint / docblock widenings 10 wrong/too-narrow declared types
Genuine latent bugs 4 worth fixing regardless of level
False positive 1 fix belonged in the callee docblock

The 4 real bugs

  • Ldap::getEmail / getSingleUser called ldap_error() on a connection handle statically known to be false in the no-connection guard — a PHP 8 TypeError that crashed the failure path instead of degrading. Now logs and short-circuits (return ''/false; callers already handle both).
  • DTO built a nested array into implode() (→ "Array.x" + warning) for nested property paths; now flattens the path parts in the correct order.
  • StaticAsset set Content-length from filesize() which is int|false; the false case is now guarded.

Framework gaps

Symfony Cookie::withSameSite now gets lowercase 'lax'/'strict' (it strtolowers anyway — no behavior change); cache TTLs that passed an absolute Carbon datetime now pass an equivalent new \DateInterval('P7D'/'P30D'); three narrow @phpstan-ignore-next-line argument.type for genuine vendor contract looseness (Sanctum model class-string, set_error_handler callback return, RequestHandled Symfony-vs-Illuminate Response).

Verification

  • ./vendor/bin/phpstan analyse -c .phpstan/phpstan.neon[OK] No errors at level: 5
  • Laravel Pint clean (639 files)
  • Every fix triaged for ripple (signature changes traced to all callers) before applying

🤖 Generated with Claude Code

Level 5 turns on argument-type checking (every call's args vs the callee's param types).
Against the green level-4 tree this surfaced exactly 38 errors, all `argument.type`, thinly
spread (1-4 per file). No baseline — fixed forward. Triaged + fixed by category:

Genuine latent bugs (4):
- Ldap::getEmail/getSingleUser called ldap_error() on a connection handle statically known to be
  `false` in the no-connection guard — a PHP 8 TypeError that crashed the failure path instead of
  degrading. Now log + short-circuit (return ''/false; callers already handle both).
- DTO placement built a nested array into implode() (-> "Array.x"); flatten the path parts.
- StaticAsset Content-length used filesize() which is int|false; guard the false case.

Type-hint / docblock corrections (10): mergeCanvas $mergeId, getComments $orderByState,
getProjectsUserHasAccessTo $clientId int-ified; Notifications getAllNotifications $showNewOnly
bool; stale `@param false` docblocks on Mailer::setHtml / Users::getAll widened to bool;
Tickets getOpenUserTicketsThisWeekAndLater @param widened; RequestRateLimiter getHeaders $limit int.

Call-site casts/guards (12): Auth (string)time(); Timesheets (int) clientId/ticketFilter;
ChangeCurrentProject (int) id; InviteTeamStep guards createUserInvite()'s false; Jsonrpc
json_decode associative true (was JSON_OBJECT_AS_ARRAY int); MigrateCommand (string) cast;
I18n drops a stray bool arg; Ldap Log::error drops a stray int context.

Framework type-gaps (11): Symfony Cookie::withSameSite lowercased 'lax'/'strict' (Symfony
strtolowers anyway); cache TTLs passed a Carbon datetime -> new \DateInterval('P7D'/'P30D');
three narrow `@phpstan-ignore-next-line argument.type` for vendor contract looseness
(Sanctum model class-string, set_error_handler callback, RequestHandled Symfony-vs-Illuminate Response).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 21, 2026 14:07
@marcelfolaron marcelfolaron requested a review from a team as a code owner June 21, 2026 14:07
@marcelfolaron marcelfolaron requested review from broskees and removed request for a team June 21, 2026 14:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Raises PHPStan to level 5 (argument-type checking) and updates various call sites, signatures, and TTL handling across the codebase to satisfy stricter type expectations while also fixing several runtime edge cases uncovered by the new analysis level.

Changes:

  • Bumps PHPStan configuration from level 4 to level 5 and resolves newly surfaced argument.type findings.
  • Normalizes argument types via casts / signature adjustments (e.g., bool vs int flags, int IDs, TTLs via DateInterval).
  • Fixes a few concrete runtime hazards (e.g., LDAP no-connection path, DTO property path flattening, guarding filesize()).

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
app/Domain/Widgets/Services/Widgets.php Switches cache TTL to DateInterval for PSR-16 set() compatibility.
app/Domain/Users/Services/Users.php Cleans up incorrect docblock param annotation.
app/Domain/Timesheets/Services/Timesheets.php Casts string filters to int for repository argument types.
app/Domain/Tickets/Services/Tickets.php Widens docblock type for optional $projectId.
app/Domain/Projects/Repositories/Projects.php Tightens clientId parameter type/default for access query.
app/Domain/Projects/Controllers/ChangeCurrentProject.php Normalizes project id parsing to (int) cast.
app/Domain/Plugins/Services/Registration.php Uses DateInterval for cache TTLs; removes Carbon usage.
app/Domain/Notifications/Services/Notifications.php Changes showNewOnly argument type to bool.
app/Domain/Ldap/Services/Ldap.php Fixes no-connection failure path and logging argument types.
app/Domain/Help/Services/InviteTeamStep.php Guards invite flow when user creation fails; casts id.
app/Domain/Comments/Repositories/Comments.php Changes orderByState to int and uses strict comparison.
app/Domain/Blueprints/Repositories/Blueprints.php Fixes mergeCanvas() signature/docblock type for merge id.
app/Domain/Auth/Services/Auth.php Matches repository signature by casting time() to string.
app/Domain/Api/Services/I18n.php Removes incorrect boolean default argument to Language::__().
app/Domain/Api/Controllers/StaticAsset.php Guards filesize() result before setting Content-Length.
app/Domain/Api/Controllers/Jsonrpc.php Uses associative json_decode(..., true) for argument typing.
app/Core/UI/Theme.php Normalizes Cookie SameSite strings to lowercase.
app/Core/Middleware/RequestRateLimiter.php Casts rate-limit values and updates header helper signature.
app/Core/Mailer.php Removes incorrect docblock param annotation.
app/Core/Language.php Normalizes Cookie SameSite string to lowercase.
app/Core/Http/HttpKernel.php Adds targeted PHPStan ignore for framework event dispatch typing gap.
app/Core/Exceptions/HandleExceptions.php Adds targeted PHPStan ignore for set_error_handler callback typing.
app/Core/Domains/DTO.php Fixes nested property-path assembly for attribute filtering.
app/Core/Auth/Tokens/SanctumServiceProvider.php Adds targeted PHPStan ignore for Sanctum model type contract looseness.
app/Command/MigrateCommand.php Casts install status before writing to console output.
.phpstan/phpstan.neon Raises PHPStan level from 4 to 5.
Comments suppressed due to low confidence (2)

app/Domain/Api/Controllers/Jsonrpc.php:117

  • get() decodes $params['params'] and then checks if ($params == null), which will never be null (it’s always an array). Also, when params is omitted, $paramsDecoded becomes an array and is passed to json_decode(), which throws a TypeError in PHP 8+. This makes GET JSON-RPC requests without params (or with invalid base64/JSON) fail incorrectly.
        $params['params'] = json_decode($params['params'], true);

        // check if decode failed
        if ($params == null) {
            return $this->returnParseError('JSON is invalid and was not able to be parsed');

app/Core/Middleware/RequestRateLimiter.php:142

  • X-RateLimit-Limit is currently populated with the current attempt count ($this->limiter->attempts($key)), not the configured limit. This makes the header misleading for clients trying to respect rate limiting.
        return [
            'X-RateLimit-Remaining' => $this->limiter->retriesLeft($key, $limit),
            'X-RateLimit-Retry-After' => $this->limiter->availableIn($key),
            'X-RateLimit-Limit' => $this->limiter->attempts($key),
            'Retry-After' => $this->limiter->availableIn($key),

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +53 to 55
$size = filesize($response->getFile()->getPathname());
$response->headers->set('Content-length', (string) ($size !== false ? $size : 0));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 49ad5d4 — now only sets Content-length when filesize() returns an int; on failure it's omitted and BinaryFileResponse computes it during prepare(). No more bogus 0-length body.

Comment on lines 91 to 94
if ($installStatus !== true) {
$io->text($installStatus);
$io->text((string) $installStatus);

return Command::FAILURE;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 49ad5d4 — setupDB() returns bool, so (string) false was printing an empty line. Replaced with $io->error('Database installation failed. Check the application logs for details.') (setupDB logs the underlying cause).

…nt-length

- StaticAsset: only set Content-length when filesize() succeeds; on failure let
  BinaryFileResponse compute it rather than advertising a 0-length body (which clients/
  proxies may treat as empty even though the file still streams).
- MigrateCommand: setupDB() returns bool, so (string) false printed nothing on failure;
  emit a concrete error pointing CLI users at the logs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@marcelfolaron marcelfolaron merged commit fc53b19 into master Jun 21, 2026
12 checks passed
@marcelfolaron marcelfolaron deleted the chore/phpstan-level-5 branch June 21, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants