PHPStan Trong Thực Tế: Static Analysis Cho Dự Án Laravel

· 11 min read

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ế:

  1. Dự án mới: Bắt đầu level 6, zero baseline
  2. Dự án hiện có: Bắt đầu level 6, tạo baseline
  3. Mỗi PR: PHPStan phải pass (GitHub Actions)
  4. Hàng tuần: Sửa 5-10 lỗi baseline
  5. Hàng tháng: Thử tăng level, tạo baseline mới
  6. 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.

Bình luận