在 Laravel 上使用 PHPStan 的最高级别
发布于 作者: Steve McDougall
在过去的几年里,PHP 中的静态分析,更具体地说是在 Laravel 中,变得越来越流行。随着越来越多的人将其采用到他们的软件交付生命周期中,我认为现在是时候写一篇关于如何在 Laravel 项目中添加它的教程了。
早在 2019 年,Nuno Maduro 发布了一个名为 Larastan 的包,它是一组适用于 Laravel 应用程序的 PHPStan 规则,我对此非常兴奋。在此之前,我一直难以在 Laravel 中使用 PHPStan 或 Psalm 来获得良好的静态分析覆盖率。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.neonparameters: paths: - app level: 9 ignoreErrors: excludePaths:
我们从 includes
开始,这些通常是我们想要包含在我们的基本 phpstan 规则集中来自包的规则。然后我们转向参数,第一个选项 paths
允许我们定义我们想要 phpstan 检查的范围 - 在我们的例子中,我们只对 app
目录感兴趣,因为我们的应用程序代码就位于那里。如果你愿意,可以将它扩展到涵盖其他区域,但要小心你包含的内容,因为事情将变得严格起来!接下来是我们的 level
PHPStan 可以检查不同的级别,0 是最低级别,而 9 是目前最高级别。正如您所看到的,我们已将我们的级别设置为 9,我建议您在现有应用程序上这样做,因为理想情况下您应该逐步提高到这个级别 - 但由于这是一个全新的项目,我们可以很舒服地将它设置为 9。接下来我们有 ignoreErrors
和 excludePaths
,这两个选项允许我们基本上告诉 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.neonparameters: 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 吗?你敢不敢将严格程度设置为最大?欢迎在推特上告诉我们,或者分享你的恐怖故事!