เทสต์บอกได้ว่าโค้ดที่เทสต์วิ่งผ่านทำงานถูกไหม แต่บรรทัดที่ไม่มีเทสต์ไหนวิ่งผ่าน เราจะรู้ว่าพังก็ตอนที่ผู้ใช้เปิดหน้านั้นจริง PHPStan เข้ามาช่วยตรงนี้ โดยอ่านโค้ดทุกไฟล์ ไล่ว่าตัวแปรแต่ละตัวเป็นชนิดอะไร แล้วเทียบกับสิ่งที่โค้ดเอาไปใช้ ทั้งหมดนี้ไม่ต้องรันโค้ดเลยสักบรรทัด
โพสต์นี้เป็นส่วนหนึ่งของชุดเครื่องมือตรวจโค้ด ถ้ายังสับสนว่า static analysis ต่างจาก lint หรือ formatter ยังไง ลองอ่านโพสต์ที่แยกเครื่องมือแต่ละแบบก่อนได้ครับ
PHPStan อ่านชนิดข้อมูลจากโค้ด
PHPStan รู้ชนิดข้อมูลจาก type ที่เราประกาศไว้ใน PHP จาก PHPDoc และจากชนิดที่เมธอดต่าง ๆ คืนค่า แล้วไล่ต่อไปทีละบรรทัด ลองดูคลาสเล็ก ๆ ที่มีบั๊กสองจุดนี้
<?php
namespace App\Services;
use App\Models\Post;
use Illuminate\Support\Str;
class PostTitle
{
public function short(Post $post): string
{
$title = $post->titel;
return Str::limit($title, '60');
}
}
vendor/bin/phpstan analyse
------ -----------------------------------------------------------------------
Line app/Services/PostTitle.php
------ -----------------------------------------------------------------------
12 Access to an undefined property App\Models\Post::$titel.
🪪 property.notFound
14 Parameter #2 $limit of static method Illuminate\Support\Str::limit()
expects int, string given.
🪪 argument.type
------ -----------------------------------------------------------------------
[ERROR] Found 2 errorsบรรทัด 12 พิมพ์ title ผิดเป็น titel ตอนรันจริง Eloquent จะคืน null ให้เฉย ๆ โดยไม่มี error สักบรรทัด หน้าเว็บก็แค่แสดงชื่อว่าง ส่วนบรรทัด 14 ส่ง '60' เป็น string ให้ Str::limit() ที่ประกาศพารามิเตอร์ $limit เป็น int
ใต้ error แต่ละข้อมีป้าย 🪪 บอกชื่อชนิดของ error เช่น argument.type เอาไปเปิดหน้า https://phpstan.org/error-identifiers/argument.type ก็จะเห็นคำอธิบายพร้อมตัวอย่างวิธีแก้
Larastan สอน PHPStan ให้รู้จัก Laravel
Laravel ทำงานผ่าน magic method เยอะ $post->title มาจาก column ในตาราง ไม่ได้ประกาศเป็น property ไว้ในคลาส Post::where() ไม่มีอยู่จริงใน Post และ facade อย่าง Cache::get() ส่งต่อไปที่ object ตัวอื่นตอนรัน PHPStan เปล่า ๆ อ่านแค่โค้ด จึงไม่รู้เรื่องพวกนี้ แล้วรายงาน error ปลอมออกมาเต็มไปหมด
Larastan เป็น extension ของ PHPStan ที่เติมความรู้ส่วนนี้ให้ Larastan boot container ของแอปขึ้นมาเพื่อหาชนิดที่รู้ได้ตอนรันเท่านั้น อ่าน migration เพื่อรู้ว่า model มี column อะไร และรู้ว่า query builder หรือ collection คืนค่าชนิดไหน error ที่เหลือจึงเป็นของจริง
ติดตั้งเป็น dev dependency ตัวเดียว PHPStan ติดมาด้วย
composer require --dev "larastan/larastan:^3.0"
แล้วสร้าง phpstan.neon ที่ราก repo
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 5
paths:
- app/
ถ้าติดตั้ง phpstan/extension-installer ไว้ด้วย บรรทัด includes ของ Larastan ก็ไม่ต้องเขียนเอง เพราะแพ็กเกจนี้โหลด extension ที่ติดตั้งไว้ให้อัตโนมัติ
เว็บนี้ตั้งไว้แบบนี้ (ตัดบางส่วนออก) ให้ตรวจทุกโฟลเดอร์ที่มีโค้ดของเรา รวมถึง tests และให้ PHPStan อ่าน _ide_helper_models.php ที่ laravel-ide-helper สร้างจากฐานข้อมูลเพิ่มด้วย
includes:
- phpstan-baseline.neon
parameters:
level: 5
paths:
- app
- bootstrap
- config
- database
- routes
- tests
scanFiles:
- _ide_helper_models.php
ความเข้มงวดมี 11 ระดับ ตั้งแต่ 0 ถึง 10
แต่ละระดับตรวจทุกอย่างของระดับก่อนหน้า แล้วเพิ่มเรื่องใหม่เข้าไป ตามเอกสาร rule levels
| ระดับ | เพิ่มการตรวจ |
|---|---|
| 0 | คลาสและฟังก์ชันที่ไม่มีอยู่ เรียกเมธอดผิดจำนวน argument ตัวแปรที่ไม่เคยประกาศ |
| 1 | ตัวแปรที่อาจยังไม่ได้ประกาศ magic method และ property ที่ไม่รู้จัก |
| 2 | เมธอดและ property ที่ไม่มีอยู่ บน object ทุกตัว ไม่ใช่แค่ $this และตรวจ PHPDoc |
| 3 | ชนิดที่เมธอดคืนค่า และชนิดที่กำหนดให้ property |
| 4 | โค้ดที่ไม่มีวันทำงาน เช่น else ที่เข้าไม่ถึง หรือโค้ดหลัง return |
| 5 | ชนิดของ argument ที่ส่งให้เมธอดและฟังก์ชัน |
| 6 | type hint ที่ขาดหายไป |
| 7 | union type ที่ถูกแค่บางส่วน |
| 8 | เรียกเมธอดหรือ property บนค่าที่อาจเป็น null |
| 9 | เข้มงวดกับ mixed ที่ประกาศไว้ |
| 10 | เข้มงวดกับ mixed ทั้งที่ประกาศไว้และที่ไม่ได้ใส่ type (เพิ่มมาใน PHPStan 2.0) |
ตั้ง level: max ได้ด้วย PHPStan จะใช้ระดับสูงสุดของเวอร์ชันที่ติดตั้งอยู่ แต่เอกสารเตือนไว้ว่า พออัปเกรด PHPStan แล้วมีระดับใหม่เพิ่มมา error ก็อาจโผล่ขึ้นมาอีกเป็นกอง
ระดับ 5 เป็นจุดเริ่มที่ดี
ตัวอย่างข้างบนแสดงให้เห็นชัด ถ้าตั้งไว้ระดับ 0 หรือ 1 PHPStan จะไม่รายงานอะไรเลย ระดับ 2 เริ่มจับ $post->titel ได้ และต้องถึงระดับ 5 ถึงจะจับ Str::limit($title, '60') ได้
ระดับ 5 ครอบคลุมบั๊กที่ทำให้โค้ดพังจริงเกือบครบ ทั้งเรียกของที่ไม่มีอยู่ คืนค่าผิดชนิด และส่ง argument ผิดชนิด พอขึ้นระดับ 6 PHPStan เริ่มขอให้ใส่ type ให้ครบทุกพารามิเตอร์และทุกค่าที่คืน ซึ่งเป็นงานเติม type มากกว่างานหาบั๊ก ส่วนระดับ 8 ขึ้นไปจะเตือนทุกจุดที่ค่าอาจเป็น null อย่าง auth()->user() ที่คืน null ได้ถ้ายังไม่ล็อกอิน โค้ด Laravel ทั่วไปจะเจอคำเตือนแบบนี้เยอะมาก
ตัวอย่าง config ใน README ของ Larastan เองก็ตั้งไว้ที่ระดับ 5 เว็บนี้ก็ใช้ระดับ 5 พอโค้ดผ่านระดับ 5 ได้สะอาดแล้ว ค่อยขยับขึ้นทีละระดับ
Baseline เก็บ error เดิมไว้ ให้โค้ดใหม่ต้องผ่าน
รัน PHPStan ครั้งแรกกับโปรเจกต์ที่เขียนมาหลายปี อาจเจอ error หลายร้อยข้อ ถ้าต้องแก้ให้หมดก่อนถึงจะเริ่มใช้ได้ ก็คงไม่ได้เริ่มสักที baseline แก้ปัญหานี้ด้วยการจด error ที่มีอยู่ตอนนี้ลงไฟล์ แล้วให้ PHPStan ข้าม error เหล่านั้นในการรันครั้งต่อไป โค้ดที่เขียนใหม่หลังจากนี้ต้องผ่านตามปกติ
สร้าง baseline ด้วย --generate-baseline เว็บนี้ตั้งเป็น composer script ไว้คู่กับคำสั่งรันปกติ
"analyse": [
"@php vendor/bin/phpstan analyse --memory-limit=4G"
],
"analyse:baseline": [
"@php vendor/bin/phpstan analyse --generate-baseline --memory-limit=4G --debug"
]
ได้ไฟล์ phpstan-baseline.neon แล้ว include ไว้ใน phpstan.neon อย่างที่เห็นด้านบน error แต่ละข้อในไฟล์มีข้อความ ชนิด จำนวนครั้ง และไฟล์ที่เจอ
-
message: '#^Call to an undefined method DOMNameSpaceNode\|DOMNode\:\:getAttribute\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Services/LegacyImport/V3Importer.php
พอเราแก้ error ข้อไหนไปแล้ว PHPStan จะรายงานว่ามีรายการใน baseline ที่ไม่ตรงกับ error ไหนแล้ว ให้ลบออก baseline จึงมีแต่หดลง เว็บนี้เหลืออยู่ 21 รายการ ส่วนใหญ่อยู่ในโค้ดที่ใช้ย้ายข้อมูลจากเว็บเวอร์ชันเก่า
เอกสารของ PHPStan แนะนำให้ใช้ baseline กับ error ราว "a few dozen to a few hundred" ข้อ และอย่าสร้าง baseline ใหม่เพื่อซ่อน error ที่เพิ่งเกิด ถ้ามี error ที่ตั้งใจปล่อยไว้จริง ๆ ใส่ @phpstan-ignore พร้อมเหตุผลไว้ที่บรรทัดนั้นดีกว่า คนที่มาอ่านทีหลังจะได้รู้ว่าทำไม
รันในเครื่องก่อน push และรันทั้งโปรเจกต์ใน CI
ในเครื่องรัน composer analyse ก่อน push ส่วนใน CI เว็บนี้รัน composer analyse ทุกครั้งที่มี push เข้า main ทั้งโปรเจกต์ ไม่ได้เลือกเฉพาะไฟล์ที่แก้แบบ Pint หรือ Rector เพราะการแก้ไฟล์หนึ่งทำให้ไฟล์อื่นที่เรียกใช้พังได้
ถ้ารันแล้วเจอ Allowed memory size exhausted ให้เพิ่ม --memory-limit อย่างที่ใส่ไว้ใน script ข้างบน โปรเจกต์ที่ใหญ่ขึ้นก็ต้องใช้หน่วยความจำมากขึ้นตาม






