在 Laravel 上使用 PHPStan 的最高级别

发布于 作者:

Running PHPStan on max with Laravel image

在过去的几年里,PHP 中的静态分析,更具体地说是在 Laravel 中,变得越来越流行。随着越来越多的人将其采用到他们的软件交付生命周期中,我认为现在是时候写一篇关于如何在 Laravel 项目中添加它的教程了。

早在 2019 年,Nuno Maduro 发布了一个名为 Larastan 的包,它是一组适用于 Laravel 应用程序的 PHPStan 规则,我对此非常兴奋。在此之前,我一直难以在 Laravel 中使用 PHPStanPsalm 来获得良好的静态分析覆盖率。Larastans 规则允许我开始对我的代码库应用更多静态分析,从而对我的代码更有信心。快进到今天,有了 PHP 8.1 和 Laravel 9 - 由于我可以使用大量的优秀工具,我对编写的代码比以往任何时候都更有信心。

在本教程中,我将逐步介绍将 Larastan 添加到一个新的 Laravel 项目中,将级别提升到最高级别,并查看我们需要解决什么问题。然后,我们将开始向应用程序添加一些逻辑,并查看在添加逻辑时需要做些什么才能保持对代码的信心。

就像所有事情一样,你需要一个起点 - 所以创建一个名为 larastan-test 的新 Laravel 项目,无论你通常用什么方式创建它,我个人使用 Laravel 安装程序,但我知道并非所有人都是这样

laravel new larastan-test

在您选择的代码编辑器中打开这个项目,我们就可以开始了。首先,我们将安装 Larastan,运行以下 composer 命令

composer require nunomaduro/larastan --dev

安装完这个依赖项作为开发依赖项后,我们可以设置配置。我们希望将其作为开发依赖项的原因是,因为在生产环境中,我们不应该运行任何静态分析 - 它仅用于开发目的,以确保您的代码尽可能类型安全。PHPStan 使用名为 neon 的配置格式,这在某种程度上类似于 yaml。所以,我们将在应用程序的根目录中创建一个新文件,名为 ./phpstan.neon - 如果你正在构建一个包,推荐的方法是在这些配置文件的末尾添加 .dist,但是对于本地应用程序开发来说,这就可以了。在这个文件里面,我们将开始定义运行 phpstan 所需的配置,以及我们可能想要强加的规则,将以下代码添加到配置文件中,我们就可以逐步了解它的含义

includes:
- ./vendor/nunomaduro/larastan/extension.neon
parameters:
paths:
- app
level: 9
ignoreErrors:
excludePaths:

我们从 includes 开始,这些通常是我们想要包含在我们的基本 phpstan 规则集中来自包的规则。然后我们转向参数,第一个选项 paths 允许我们定义我们想要 phpstan 检查的范围 - 在我们的例子中,我们只对 app 目录感兴趣,因为我们的应用程序代码就位于那里。如果你愿意,可以将它扩展到涵盖其他区域,但要小心你包含的内容,因为事情将变得严格起来!接下来是我们的 level PHPStan 可以检查不同的级别,0 是最低级别,而 9 是目前最高级别。正如您所看到的,我们已将我们的级别设置为 9,我建议您在现有应用程序上这样做,因为理想情况下您应该逐步提高到这个级别 - 但由于这是一个全新的项目,我们可以很舒服地将它设置为 9。接下来我们有 ignoreErrorsexcludePaths,这两个选项允许我们基本上告诉 PHPStan 忽略文件或特定的错误,这些错误是我们不感兴趣的,或者我们现在无法控制或修复。也许您正在进行一些代码重构,并且遇到了错误,您可能以一种静态分析稍后会没问题的方式重构了这些代码,并且您希望忽略这些错误,直到您完成为止。

所以让我们对默认的 Laravel 应用程序运行 phpstan,看看我们是否遇到了任何错误。在您的终端中运行以下命令

./vendor/bin/phpstan analyse

我们从默认的 Laravel 应用程序获得的输出如下所示

Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon.
18/18 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
 
------ ----------------------------------------------------------------------------------------------------------------------------
Line Providers/RouteServiceProvider.php
------ ----------------------------------------------------------------------------------------------------------------------------
49 Parameter #1 $key of method Illuminate\Cache\RateLimiting\Limit::by() expects string, int<min, -1>|int<1, max>|string|null
given.
------ ----------------------------------------------------------------------------------------------------------------------------
 
[ERROR] Found 1 error

如您所见,即使我们的严格程度设置得尽可能高,我们也只得到了默认 Laravel 应用程序的一个错误。这做得不错,对吧?当然,如果您将其添加到现有项目中,您可能会看到不同的结果,但按照本教程,您将学习如何处理这些问题,以便您有一个不错的流程可以遵循。

您可以将一个脚本添加到您的 composer 文件中来运行它,如果您更喜欢有一个方便的方式来运行它,那么现在就添加它,这样我们就可以更容易地运行这个命令了,将以下代码块添加到您的 composer.json 文件中

"scripts": {
"phpstan": [
"./vendor/bin/phpstan analyse"
]
},
"scripts-descriptions": {
"phpstan": "Run PHPStan static analysis against your application."
},

您的 composer 文件中的 scripts 下应该已经有一些记录了 - 只需要将 phpstan 脚本追加到代码块的末尾即可。现在我们可以再次运行 PHPStan,但是这次使用 composer,这比键入命令更容易

composer phpstan

所以我们有 1 个错误,让我们看一下那一行代码,看看它目前的样子

protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}

我们的静态分析抱怨的具体部分是这部分

$request->user()?->id ?: $request->ip()

所以我们想获取请求用户,如果有的话,返回其 ID,或者如果第一部分为空,则返回 IP 地址。因此,实际上没有办法确保这将始终是一个字符串,用户可能为空,请求 IP 也可能为空。这种情况就是您想要消除错误的情况,因为您无法强制执行它,因为它是在 vendor 代码中。在这种特定情况下,最好的办法是告诉 PHPStan 忽略错误,但不是全局性地忽略。我们在这里要做的就是添加一个命令块,而不是设置规则,告诉 PHPStan 在分析这段代码时,忽略这一行。将这个方法重构为以下样子

protected function configureRateLimiting(): void
{
RateLimiter::for('api', static function (Request $request): Limit {
/** @phpstan-ignore-next-line */
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}

我们已经向该方法添加了一个返回类型,将回调做成了一个静态闭包 - 并且对返回值进行了类型提示。但是,然后我们在返回语句的上方添加了一个命令块,告诉 PHPStan 我们想要忽略下一行。如果我们现在再次在命令行中运行 PHPStan,您将看到以下输出

Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon.
18/18 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
 
[OK] No errors

所以我们让默认的 Laravel 应用程序在 PHPStan 的最高级别上运行,现在我们需要开始向应用程序添加一些实际的逻辑,以便在添加功能和逻辑时确保类型安全。为此,我们将创建一个简单的应用程序来存储书签,没什么特别的。

让我们开始使用 artisan 添加一个模型,并使用 CLI 标志同时创建迁移和工厂

php artisan make:model Bookmark -mf

我们想要让我们的迁移 up 方法看起来像这样

Schema::create('bookmarks', static function (Blueprint $table): void {
$table->id();
 
$table->string('name');
$table->string('url');
 
$table->boolean('starred')->default(false);
 
$table->foreignId('user_id')->index()->constrained()->cascadeOnDelete();
 
$table->timestamps();
});

现在我们可以将它添加到我们的模型中

class Bookmark extends Model
{
use HasFactory;
 
protected $fillable = [
'name',
'url',
'starred',
'user_id',
];
 
protected $casts = [
'starred' => 'boolean',
];
 
/**
* @return BelongsTo
*/
public function user(): BelongsTo
{
return $this->belongsTo(
related: User::class,
foreignKey: 'user_id',
);
}
}

如您所见,我们这里只关心名称、URL、用户是否想要收藏书签以及书签是否属于用户。现在我们可以把它放在这里,但我个人喜欢向模型属性添加类型定义 - 因为目前在 Laravel 9 中,我无法对它们进行类型提示。所以将您的模型重构为以下样子

class Bookmark extends Model
{
use HasFactory;
 
/**
* @var array<int,string>
*/
protected $fillable = [
'name',
'url',
'starred',
'user_id',
];
 
/**
* @var array<string,string>
*/
protected $casts = [
'starred' => 'boolean',
];
 
/**
* @return BelongsTo
*/
public function user(): BelongsTo
{
return $this->belongsTo(
related: User::class,
foreignKey: 'user_id',
);
}
}

我们在这里做的只是告诉 PHP 和我们的 IDE,可填充的数组是一个没有键的字符串数组 - 这意味着它将默认设置为整数。然后,我们的 casts 数组是一个键值为字符串的关联数组,其中键也是字符串。现在,即使没有类型定义,在运行它时也不会发生静态分析错误 - 但养成这样的习惯是个好习惯,这样您的 IDE 在您工作时就可以获得尽可能多的信息。

让我们继续讨论路由和控制器,这样我们就可以在开发过程中不断运行静态分析检查。我非常喜欢可调用控制器 - 我发现它们很适合我的代码风格,但是你可能不喜欢它们,或者有不同的偏好,所以接下来的部分,如果你更习惯其他方式,可以随意跳过我的步骤。

我们现在将创建一个控制器,所以运行以下 artisan 命令来创建书签的索引控制器。

php artisan make:controller Bookmarks/IndexController --invokable

这是我们需要用于路由的索引控制器,所以我们可以添加一个新的路由块到 routes/web.php 中,以便开始使用它。

Route::middleware(['auth'])->prefix('bookmarks')->as('bookmarks:')->group(static function (): void {
Route::get('/', App\Http\Controllers\Bookmarks\IndexController::class)->name('index');
});

我们希望将这个块包含在我们的 auth 中间件中,以便我们控制作者对书签的访问权限,我们还希望将所有路由都放在 bookmarks 下,并为这个分组设置命名策略为 bookmarks:*。如果我们现在对代码库运行静态分析,我们会看到一些错误,但这主要是因为我们的控制器中没有内容。

composer phpstan
Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon.
20/20 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
 
------ -------------------------------------------------------------------------------------------------
Line Http/Controllers/Bookmarks/IndexController.php
------ -------------------------------------------------------------------------------------------------
15 Method App\Http\Controllers\Bookmarks\IndexController::__invoke() has no return type specified.
------ -------------------------------------------------------------------------------------------------
 
------ -----------------------------------------------------------------------------------------------------------------------------
Line Models/Bookmark.php
------ -----------------------------------------------------------------------------------------------------------------------------
33 Method App\Models\Bookmark::user() return type with generic class Illuminate\Database\Eloquent\Relations\BelongsTo does not
specify its types: TRelatedModel, TChildModel
💡 You can turn this off by setting checkGenericClassInNonGenericObjectType: false in your
phpstan.neon.
------ -----------------------------------------------------------------------------------------------------------------------------
 
------ ----------------------------------------------------------------------------------------------------------------------------
Line Models/User.php
------ ----------------------------------------------------------------------------------------------------------------------------
49 Method App\Models\User::bookmarks() return type with generic class Illuminate\Database\Eloquent\Relations\HasMany does not
specify its types: TRelatedModel
💡 You can turn this off by setting checkGenericClassInNonGenericObjectType: false in your
phpstan.neon.
------ ----------------------------------------------------------------------------------------------------------------------------
 
[ERROR] Found 3 errors

最显眼的是 Method App\Models\User::bookmarks() return type with generic class 错误,现在我不想让这个应用程序过度依赖泛型。错误实际上告诉了我们可以做什么,所以让我们在 phpstan.neon 文件中添加 checkGenericClassInNonGenericObjectType: false

includes:
- ./vendor/nunomaduro/larastan/extension.neon
parameters:
paths:
- app
level: 9
ignoreErrors:
excludePaths:
checkGenericClassInNonGenericObjectType: false

现在,如果我们再次运行分析,只会出现 5 个错误,这些错误都与我们的控制器有关 - 让我们从 IndexController 开始,看看可以做些什么。将你的索引控制器重构为以下样子:

class IndexController extends Controller
{
public function __invoke(Request $request)
{
return View::make(
view: 'bookmarks.list',
data: [
'bookmarks' => Bookmark::query()
->where('user_id', $request->user()->id)
->paginate(),
]
);
}
}

如果我们现在对代码运行静态分析,并只关注我们正在处理的控制器,我们会看到以下问题:

------ -------------------------------------------------------------------------------------------------
Line Http/Controllers/Bookmarks/IndexController.php
------ -------------------------------------------------------------------------------------------------
15 Method App\Http\Controllers\Bookmarks\IndexController::__invoke() has no return type specified.
21 Cannot access property $id on App\Models\User|null.
------ -------------------------------------------------------------------------------------------------

那么我们如何解决这两个错误呢?第一个错误相对容易解决,我们可以添加一个返回类型。

public function __invoke(Request $request): \Illuminate\Contracts\View\View

我们可以为这个接口取一个别名,让它看起来更舒服。

public function __invoke(Request $request): ViewContract

但是下一个错误,Cannot access property $id on App\Models\User|null. 这与我们在默认 Laravel 应用程序中遇到的情况类似,其中来自请求用户的 ID 可能是 null。所以我喜欢用 Auth 辅助函数来直接从我们的 Auth 保护程序获取 ID。将查询重构为以下样子:

Bookmark::query()
->where('user_id', auth()->id())
->paginate()

使用 Auth ID 方法,我们直接从身份验证保护程序中获取 ID,而不是从请求中获取,因为请求中的 ID 有可能是 null。在这样做时需要注意的一点是,如果路由没有包含在身份验证中间件中,那么 id 方法会报错,说你正在尝试获取 null 的属性 ID。所以请确保你已经为这个路由设置了中间件。

现在,如果我们再次运行静态分析,应该会消除这些错误。

composer phpstan
Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon.
20/20 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
 
 
[OK] No errors

现在,我们的 IndexController 没有任何错误了。接下来,我们需要遍历应用程序,确保在重要的点运行静态分析检查。我们最不想做的事情就是在冲刺结束或添加新功能结束时才运行它,然后发现我们需要花费无数的时间来修复静态分析问题。然而,最终你会得到你信任的代码,这是我个人最喜欢的静态分析的好处之一。如果你能将静态分析与一个好的测试套件结合起来,那么就没有理由不信任你的代码了。

你在你的项目中使用 Larastan 吗?你敢不敢将严格程度设置为最大?欢迎在推特上告诉我们,或者分享你的恐怖故事!

Steve McDougall photo

《Laravel 新闻》的的技术作家,Treblle 的开发倡导者。API 专家,资深 PHP/Laravel 工程师。《Just Steve King》的 YouTube 直播主。

Cube

Laravel 新闻

加入 40,000 多名其他开发者,不要错过新的提示、教程等。

Laravel Forge logo

Laravel Forge

轻松创建和管理你的服务器,并在几秒钟内部署你的 Laravel 应用程序。

Laravel Forge
Tinkerwell logo

Tinkerwell

Laravel 开发者的必备代码运行器。使用 AI、自动完成和本地和生产环境的即时反馈进行调试。

Tinkerwell
No Compromises logo

无妥协

Joel 和 Aaron,两位来自无妥协播客的经验丰富的开发者,现在可以为你的 Laravel 项目雇佣。⬧ 固定价格为每月 7500 美元。⬧ 没有冗长的销售流程。⬧ 没有合同。⬧ 100% 退款保证。

无妥协
Kirschbaum logo

Kirschbaum

提供创新和稳定性,确保你的 Web 应用程序成功。

Kirschbaum
Shift logo

Shift

运行旧的 Laravel 版本吗?即时、自动化的 Laravel 升级和代码现代化,使你的应用程序保持新鲜感。

Shift
Bacancy logo

Bacancy

只需每月 2500 美元,即可为你的项目配备一位拥有 4-6 年经验的资深 Laravel 开发者。获得 160 小时的专业知识和 15 天的无风险试用。立即预约电话!

Bacancy
Lucky Media logo

Lucky Media

立即获得 Lucky - Laravel 开发的理想选择,拥有十多年的经验!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel 电子商务

Laravel 的电子商务。一个开源包,将现代无头电子商务功能的强大功能带到 Laravel。

Lunar: Laravel 电子商务
LaraJobs logo

LaraJobs

官方 Laravel 职位板

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS 启动工具包

SaaSykit 是一个 Laravel SaaS 启动工具包,它包含运行现代 SaaS 所需的所有功能。支付、漂亮的结账、管理面板、用户仪表板、身份验证、现成组件、统计信息、博客、文档等等。

SaaSykit: Laravel SaaS 启动工具包
Rector logo

Rector

您无缝 Laravel 升级、降低成本和加速创新以助力公司成功的合作伙伴

Rector
MongoDB logo

MongoDB

通过 MongoDB 和 Laravel 的强大集成来增强您的 PHP 应用程序,使开发人员能够轻松高效地构建应用程序。支持事务、搜索、分析和移动用例,同时使用熟悉的 Eloquent API。了解灵活的现代 MongoDB 数据库如何改变您的 Laravel 应用程序。

MongoDB
Maska is a Simple Zero-dependency Input Mask Library image

Maska 是一个简单的零依赖输入掩码库

阅读文章
Add Swagger UI to Your Laravel Application image

在您的 Laravel 应用程序中添加 Swagger UI

阅读文章
Assert the Exact JSON Structure of a Response in Laravel 11.19 image

在 Laravel 11.19 中断言响应的精确 JSON 结构

阅读文章
Build SSH Apps with PHP and Laravel Prompts image

使用 PHP 和 Laravel Prompts 构建 SSH 应用程序

阅读文章
Building fast, fuzzy site search with Laravel and Typesense image

使用 Laravel 和 Typesense 构建快速、模糊的站点搜索

阅读文章
Add Comments to your Laravel Application with the Commenter Package image

使用 Commenter 包为您的 Laravel 应用程序添加评论

阅读文章