PHPStan Trong Thực Tế: Static Analysis Cho Dự Án Laravel
PHPStan bắt bug trước khi chúng lên production — mà không cần chạy bất kỳ test nào. Với dự án Laravel, Larastan mở rộng PHPStan để hiểu được Eloquent magic, facades, và container bindings. Bài viết này hướng dẫn setup thực tế, không chỉ "cài và chạy level 5."
Tại Sao Static Analysis Quan Trọng
Các loại bug PHPStan bắt được mà test thường bỏ sót:
// 1. Gọi method trên possibly null
$user = User::find($id);
$user->notify(new WelcomeEmail()); // PHPStan: Method called on null
// 2. Sai kiểu argument
Cache::put('key', $value, 'not-a-number'); // PHPStan: expects int|DateTimeInterface
// 3. Dead code
if ($status === 'active' && $status === 'inactive') { // Luôn false
// Code này không bao giờ chạy
}
// 4. Thiếu return type gây bug cho caller
function getPrice() { // Trả về int|float — caller nghĩ là int
return $item->price * 1.1;
}
Cài Đặt
composer require --dev larastan/larastan
Larastan đã bao gồm PHPStan — không cần cài riêng cả hai.
Cấu Hình
phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
parameters:
paths:
- app/
level: 6
excludePaths:
- app/Console/Commands/
ignoreErrors: []
checkMissingIterableValueType: false
checkGenericClassInNonGenericObjectType: false
Hiểu Các Level
| Level | Kiểm tra gì | Phù hợp cho |
|---|---|---|
| 0 | Kiểm tra cơ bản, class không tồn tại | Codebase cũ |
| 1 | Biến có thể undefined | Bắt đầu |
| 2 | Method không tồn tại trên $this |
Dự án mới |
| 3 | Return types | Đang phát triển |
| 4 | Dead code, unreachable | Dự án đang lớn |
| 5 | Kiểu argument | Mặc định tốt |
| 6 | Thiếu typehints | Khuyến nghị |
| 7 | Union types nghiêm ngặt | Dự án strict |
| 8 | Nullable operations | Dự án strict |
| 9 | Mixed type strictness | Nghiêm ngặt tối đa |
Bắt đầu level 5-6 cho dự án mới. Dự án hiện có, dùng baselines.
Chạy PHPStan
# Phân tích toàn bộ project
vendor/bin/phpstan analyse
# Phân tích đường dẫn cụ thể
vendor/bin/phpstan analyse app/Services/ app/Http/Controllers/
# Với giới hạn memory cho project lớn
vendor/bin/phpstan analyse --memory-limit=512M
# Tạo baseline (cho dự án hiện có)
vendor/bin/phpstan analyse --generate-baseline
# Xóa cache sau khi đổi config
vendor/bin/phpstan clear-result-cache
Chiến Lược Baseline
Với dự án hiện có, bạn không thể sửa 500 lỗi cùng lúc. Baseline cho phép bạn bắt đầu enforce rules cho code mới ngay lập tức:
# Tạo file baseline
vendor/bin/phpstan analyse --generate-baseline
File phpstan-baseline.neon được tạo:
# phpstan.neon — include baseline
includes:
- vendor/larastan/larastan/extension.neon
- phpstan-baseline.neon
parameters:
level: 6
paths:
- app/
Giờ PHPStan bỏ qua lỗi cũ nhưng bắt mọi lỗi mới. Giảm dần baseline theo thời gian:
# Xem còn bao nhiêu lỗi baseline
grep -c "message:" phpstan-baseline.neon
# Sau khi sửa một số lỗi, tạo lại baseline
vendor/bin/phpstan analyse --generate-baseline
Các Lỗi Laravel Phổ Biến Và Cách Sửa
1. Thuộc Tính Eloquent Model
PHPStan không biết về dynamic Eloquent attributes:
// Error: Access to an undefined property App\Models\Post::$title
$post = Post::first();
echo $post->title;
Sửa: Thêm @property PHPDoc cho model:
/**
* @property int $id
* @property string $title
* @property string $slug
* @property string $content
* @property bool $is_published
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, Tag> $tags
*/
class Post extends Model
{
// ...
}
Hoặc dùng IDE helper generator:
composer require --dev barryvdh/laravel-ide-helper
php artisan ide-helper:models --write
2. Kết Quả find() Có Thể Null
Lỗi PHPStan phổ biến nhất trong Laravel:
// Error: Called method on null
$user = User::find($id);
$user->name; // $user có thể null!
Các cách sửa:
// Cách 1: findOrFail — throw 404 nếu không tìm thấy
$user = User::findOrFail($id);
$user->name; // An toàn — PHPStan biết đây là User
// Cách 2: Kiểm tra null
$user = User::find($id);
if ($user === null) {
abort(404);
}
$user->name; // An toàn sau null check
// Cách 3: Type assertion (khi bạn chắc chắn)
/** @var User $user */
$user = User::find($id);
3. Collection Generic Types
// Error: Method get() returns mixed
$posts = Post::where('published', true)->get();
// PHPStan biết đây trả về Collection<int, Post>
// nhưng chained operations có thể mất type info
$titles = $posts->map(fn ($post) => $post->title);
// $titles là Collection<int, mixed> nếu không có return type hint
Sửa bằng PHPDoc:
/** @return Collection<int, string> */
public function getPublishedTitles(): Collection
{
return Post::where('published', true)
->get()
->map(fn (Post $post): string => $post->title);
}
4. Facade Magic Methods
Larastan xử lý hầu hết facades, nhưng custom macros cần hỗ trợ:
// Nếu bạn thêm macro:
Str::customMethod('test'); // Error: Call to undefined method
// Sửa: Thêm stub file
// stubs/macros.stub
namespace Illuminate\Support;
/**
* @method static string customMethod(string $value)
*/
class Str {}
# phpstan.neon
parameters:
stubFiles:
- stubs/macros.stub
5. Request Validation Data
// Error: $validated là array<string, mixed>
$validated = $request->validated();
$name = $validated['name']; // Type là mixed
// Sửa 1: Cast sau validation
$name = (string) $validated['name'];
// Sửa 2: Dùng FormRequest với @return PHPDoc
/**
* @return array{name: string, email: string, age: int}
*/
public function validated($key = null, $default = null): array
{
return parent::validated($key, $default);
}
// Sửa 3: Dùng DTO
$dto = PostData::from($request->validated());
$dto->name; // PHPStan biết đây là string
6. Config và Environment Values
// Error: config() trả về mixed
$appName = config('app.name');
strlen($appName); // Mixed truyền vào strlen
// Sửa: Cast rõ ràng
$appName = (string) config('app.name');
// Hoặc helper an toàn kiểu
function configString(string $key, string $default = ''): string
{
$value = config($key);
return is_string($value) ? $value : $default;
}
Custom PHPStan Rules
Cấm dd() và dump() Trong Production Code
// app/PHPStan/Rules/NoDebugStatementsRule.php
namespace App\PHPStan\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<FuncCall>
*/
class NoDebugStatementsRule implements Rule
{
private const FORBIDDEN_FUNCTIONS = ['dd', 'dump', 'ray', 'var_dump', 'print_r'];
public function getNodeType(): string
{
return FuncCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof \PhpParser\Node\Name) {
return [];
}
$functionName = $node->name->toString();
if (in_array($functionName, self::FORBIDDEN_FUNCTIONS, true)) {
return [
RuleErrorBuilder::message(
sprintf('Không được dùng %s() trong production code.', $functionName)
)->build(),
];
}
return [];
}
}
Đăng ký:
# phpstan.neon
services:
- class: App\PHPStan\Rules\NoDebugStatementsRule
tags:
- phpstan.rules.rule
Bắt Buộc Return Type Cho Services
// app/PHPStan/Rules/ServiceMethodReturnTypeRule.php
namespace App\PHPStan\Rules;
use PhpParser\Node;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<ClassMethod>
*/
class ServiceMethodReturnTypeRule implements Rule
{
public function getNodeType(): string
{
return ClassMethod::class;
}
public function processNode(Node $node, Scope $scope): array
{
$className = $scope->getClassReflection()?->getName() ?? '';
if (!str_contains($className, '\\Services\\')) {
return [];
}
if ($node->name->toString() === '__construct') {
return [];
}
if ($node->returnType === null) {
return [
RuleErrorBuilder::message(
sprintf(
'Public method %s::%s() phải có return type declaration.',
$scope->getClassReflection()->getName(),
$node->name->toString()
)
)->build(),
];
}
return [];
}
}
Tích Hợp CI/CD
GitHub Actions
# .github/workflows/static-analysis.yml
name: Static Analysis
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
phpstan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- name: Install dependencies
run: composer install --no-progress --prefer-dist
- name: Run PHPStan
run: vendor/bin/phpstan analyse --error-format=github
Flag --error-format=github thêm inline annotations vào PRs.
Pre-commit Hook
#!/bin/sh
# .git/hooks/pre-commit
CHANGED_PHP_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
if [ -n "$CHANGED_PHP_FILES" ]; then
echo "Đang chạy PHPStan trên các file thay đổi..."
vendor/bin/phpstan analyse $CHANGED_PHP_FILES
if [ $? -ne 0 ]; then
echo "PHPStan tìm thấy lỗi. Hãy sửa trước khi commit."
exit 1
fi
fi
Kết Hợp Với Pint
Chạy cả hai tool để kiểm tra chất lượng code hoàn chỉnh:
// composer.json
{
"scripts": {
"lint": "vendor/bin/pint --test",
"analyse": "vendor/bin/phpstan analyse",
"check": [
"@lint",
"@analyse"
]
}
}
composer check
Cấu Hình Nâng Cao
Override Level Theo Đường Dẫn
Rules nghiêm ngặt hơn cho code mới, nới lỏng cho legacy:
parameters:
level: 6
paths:
- app/
# Nghiêm ngặt hơn cho services
# Nới lỏng cho legacy controllers
ignoreErrors:
-
message: '#Parameter .* has no type declaration#'
paths:
- app/Http/Controllers/Legacy/
Theo Dõi Type Coverage
Đo xem bao nhiêu phần codebase đã typed:
composer require --dev tomasvotruba/type-coverage
vendor/bin/phpstan analyse --error-format=json | \
php vendor/bin/type-coverage
PHPStan Extensions Nên Thêm
# Strict rules — bắt nhiều lỗi hơn
composer require --dev phpstan/phpstan-strict-rules
# Cảnh báo deprecation
composer require --dev phpstan/phpstan-deprecation-rules
# PHPUnit assertions
composer require --dev phpstan/phpstan-phpunit
includes:
- vendor/larastan/larastan/extension.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
- vendor/phpstan/phpstan-phpunit/extension.neon
Quy Trình Thực Tế
Đây là workflow hoạt động tốt trong thực tế:
- Dự án mới: Bắt đầu level 6, zero baseline
- Dự án hiện có: Bắt đầu level 6, tạo baseline
- Mỗi PR: PHPStan phải pass (GitHub Actions)
- Hàng tuần: Sửa 5-10 lỗi baseline
- Hàng tháng: Thử tăng level, tạo baseline mới
- Mục tiêu: Level 8+ với zero baseline errors
# Kiểm tra nhanh khi phát triển
vendor/bin/phpstan analyse app/Services/MarkdownPostService.php
# Kiểm tra đầy đủ trước khi push
composer check
# Giảm baseline theo thời gian
vendor/bin/phpstan analyse --generate-baseline
git diff phpstan-baseline.neon # Xem tiến độ
Đo Lường Tiến Độ
Theo dõi hành trình PHPStan:
# Đếm lỗi baseline hiện tại
grep -c "message:" phpstan-baseline.neon
# Theo dõi qua thời gian trong README
# Tuần 1: Level 5, 342 lỗi baseline
# Tuần 4: Level 6, 189 lỗi baseline
# Tuần 8: Level 6, 47 lỗi baseline
# Tuần 12: Level 7, 12 lỗi baseline
Tổng Kết
| Khía cạnh | Khuyến nghị |
|---|---|
| Level khởi đầu | 5-6 cho mới, baseline cho hiện có |
| Larastan | Luôn dùng — hiểu Laravel magic |
| Baseline | Thiết yếu cho áp dụng dần dần |
| CI | Block PR đưa lỗi mới vào |
| Custom rules | Cấm dd/dump, bắt buộc return types |
| IDE helper | Tự động tạo PHPDoc cho model |
| Mục tiêu | Level 8+ với baseline sạch |
PHPStan không phải về việc đạt level 9 ngày đầu tiên. Nó về việc ngăn regression trong khi dần cải thiện type safety. Mỗi lỗi PHPStan bắt được là một bug ít hơn trên production.