อัปเกรด Laravel แต่ละครั้ง ที่ผ่านมาเราก็มักเปิด upgrade guide ไว้ข้าง ๆ แล้วไล่แก้ทีละไฟล์ เปลี่ยนชื่อเมธอดที่เลิกใช้ ย้าย config ไปที่ใหม่ แก้ทีละจุดจนครบ งานพวกนี้ส่วนใหญ่เป็นการเปลี่ยนแบบเดิมซ้ำ ๆ หลายสิบที่ ซึ่งเป็นงานที่ Rector ทำแทนเราได้
Rector เป็นเครื่องมือ automated refactoring หนึ่งในสี่แบบของเครื่องมือตรวจโค้ด ถ้ายังไม่แน่ใจว่าต่างจาก Pint หรือ PHPStan ยังไง ลองอ่านโพสต์ที่แยกเครื่องมือแต่ละแบบก่อนได้ครับ
Rector อ่านโค้ดเป็นโครงสร้าง แล้วเขียนใหม่ตามกฎ
ถ้าใช้ find and replace เปลี่ยนโค้ด เราเห็นโค้ดเป็นแค่ข้อความ แยกไม่ออกว่า where ตรงนี้เป็นเมธอดของ query builder หรือเป็นคำในคอมเมนต์ Rector อ่านโค้ดเป็นโครงสร้าง (AST) รู้ว่าตรงไหนเป็นคลาส เมธอด argument หรือเงื่อนไข และใช้ PHPStan ช่วยดูว่าตัวแปรแต่ละตัวเป็นชนิดอะไร จึงเปลี่ยนเฉพาะจุดที่ตรงกับกฎจริง ๆ
กฎ (rule) ข้อหนึ่งดูแลการเปลี่ยนแบบเดียว เช่น เปลี่ยน $casts จาก property เป็นเมธอด casts() กฎหลายข้อรวมกันเป็นชุด (set) เช่น ชุดของ PHP แต่ละเวอร์ชัน หรือชุด dead code ที่ลบโค้ดที่ไม่มีวันทำงาน
ติดตั้ง Rector คู่กับ rector-laravel ที่เพิ่มกฎของ Laravel
composer require --dev rector/rector driftingly/rector-laravel
รัน vendor/bin/rector ครั้งแรก ถ้ายังไม่มี rector.php Rector จะถามว่าจะให้สร้างไฟล์ตั้งต้นให้ไหม จากนั้นสั่ง --dry-run เพื่อดูว่าจะเปลี่ยนอะไรบ้าง โดยยังไม่แตะไฟล์ พอดู diff แล้วโอเค ค่อยรันโดยไม่ใส่ --dry-run
vendor/bin/rector process --dry-run
vendor/bin/rector process
เอกสารของ Rector แนะนำให้โปรเจกต์ที่มีโค้ดอยู่แล้วเริ่มจากชุดเล็ก ๆ เช่น withTypeCoverageLevel(0) แล้วขยับทีละระดับ ทุกระดับเปิดกฎเพิ่มทีละข้อ diff จึงเล็กพอให้อ่านจบใน PR เดียว
rector.php ของเว็บนี้
return RectorConfig::configure()
->withPaths([__DIR__.'/app', __DIR__.'/bootstrap', __DIR__.'/config', __DIR__.'/database', __DIR__.'/routes', __DIR__.'/tests'])
->withPhpSets(php84: true)
->withPreparedSets(deadCode: true, codeQuality: true, earlyReturn: true, namedArgs: true, carbon: true)
->withSets([
LaravelSetList::LARAVEL_130_WITHOUT_ATTRIBUTES,
LaravelSetList::LARAVEL_120,
LaravelSetList::LARAVEL_CODE_QUALITY,
LaravelSetList::LARAVEL_COLLECTION,
LaravelSetList::LARAVEL_TYPE_DECLARATIONS,
LaravelSetList::LARAVEL_ELOQUENT_MAGIC_METHOD_TO_QUERY_BUILDER,
])
->withRules([
MigrateToSimplifiedAttributeRector::class,
ModelCastsPropertyToCastsMethodRector::class,
])
->withTypeCoverageLevel(0)
->withSkip([
__DIR__.'/bootstrap/cache',
__DIR__.'/bootstrap/ssr',
AppToResolveRector::class,
ApplyDefaultInsteadOfNullCoalesceRector::class,
AddNameToBooleanArgumentRector::class,
RemoveDefaultValueFromAssignedPropertyRector::class,
ServerVariableToRequestFacadeRector::class => [__DIR__.'/bootstrap'],
]);
ไล่ทีละส่วน
withPaths()บอกว่าให้ Rector ดูโฟลเดอร์ไหนwithPhpSets(php84: true)เปลี่ยนโค้ดมาใช้ syntax ที่ PHP มีจนถึง 8.4 เช่น constructor property promotion หรือmatchแทนswitchที่คืนค่าอย่างเดียว (ดูของใหม่ใน PHP 8.4 ได้ที่โพสต์นี้)withPreparedSets()เปิดชุดที่ Rector เตรียมไว้deadCodeลบโค้ดที่ไม่มีวันทำงานcodeQualityทำโค้ดให้สั้นและตรงขึ้นearlyReturnเปลี่ยนifซ้อนกันเป็น return ก่อนnamedArgsใส่ชื่อให้ argument บางแบบ และcarbonปรับวิธีใช้ CarbonwithSets()เปิดชุดของ rector-laravel ซึ่งอธิบายในหัวข้อถัดไปwithRules()เปิดกฎทีละข้อที่ไม่ได้อยู่ในชุดไหน เช่น ย้าย$castsไปเป็นเมธอดcasts()withSkip()ข้ามโฟลเดอร์ หรือข้ามกฎที่ไม่อยากได้ จะข้ามทั้งโปรเจกต์ หรือข้ามแค่บางโฟลเดอร์ก็ได้ อย่างบรรทัดสุดท้ายที่ข้ามServerVariableToRequestFacadeRectorเฉพาะในbootstrap
อัปเกรด PHP ด้วย withPhpSets()
Rector มีชุดกฎของ PHP ทุกเวอร์ชัน ตั้งแต่ 5.3 จนถึง 8.6 ถ้าเรียก withPhpSets() โดยไม่ใส่ argument Rector จะอ่านเวอร์ชันขั้นต่ำจาก require.php ใน composer.json แล้วเปิดกฎทุกชุดจนถึงเวอร์ชันนั้นให้ Rector เองก็แนะนำให้เรียกแบบนี้ จะได้ไม่ต้องกลับมาแก้ rector.php ทุกครั้งที่ขยับเวอร์ชัน
return RectorConfig::configure()
->withPhpSets();
ขั้นตอนอัปเกรดจึงเหลือแค่แก้ "php": "^8.4" ใน composer.json แล้วรัน rector process --dry-run ดู diff งานที่ Rector ทำแบ่งได้สองแบบ
- เปลี่ยนมาใช้ syntax ใหม่ เช่น constructor property promotion หรือ
matchแทนswitch - แก้สิ่งที่ PHP เวอร์ชันใหม่ประกาศว่าเลิกใช้ (deprecated) อย่าง PHP 8.4 ที่เลิกให้เขียนพารามิเตอร์ที่มีค่าเริ่มต้นเป็น
nullโดยไม่ใส่?ไว้หน้า type
// เดิม: PHP 8.4 ขึ้นคำเตือน deprecated
public function publish(Post $post, CarbonImmutable $at = null): void
// หลังรัน Rector
public function publish(Post $post, ?CarbonImmutable $at = null): void
ถ้าโปรเจกต์ค้างอยู่ที่เวอร์ชันเก่ามาก แล้วเปิดทีเดียวได้ diff เป็นพันไฟล์ ให้ใช้ withPhpLevel() แทน ซึ่งเปิดกฎทีละข้อแบบเดียวกับ withTypeCoverageLevel()
Rector แก้ได้เฉพาะสิ่งที่เห็นจากตัวโค้ด ส่วนพฤติกรรมที่เปลี่ยนตอนรัน เช่น ฟังก์ชันที่คืนค่าต่างจากเดิม หรือ extension ที่ต้องติดตั้งเพิ่มบนเซิร์ฟเวอร์ ยังต้องอ่านจาก migration guide ของ PHP และรันเทสต์บน PHP เวอร์ชันใหม่อยู่ดี
rector-laravel มีชุดอัปเกรดของ Laravel แต่ละเวอร์ชัน
rector-laravel มีชุดกฎสองกลุ่ม
กลุ่มแรกเป็นชุดอัปเกรด ชุดละหนึ่งขั้น LARAVEL_120 คือจาก 11 ไป 12 และ LARAVEL_130 คือจาก 12 ไป 13 ตอนอัปเกรด Laravel เราเปิดชุดของเวอร์ชันที่จะไป รัน --dry-run อ่าน diff แล้วค่อยรันจริง งานที่เหลือให้ทำเองคือเรื่องที่ Rector ตัดสินแทนไม่ได้ เช่น พฤติกรรมที่เปลี่ยนไป ซึ่งยังต้องอ่านจาก upgrade guide
ชุด Laravel 13 แบ่งเป็นสองแบบ แบบเต็มจะเปลี่ยน property ของคลาสอย่าง $table, $fillable หรือ $tries ให้เป็น PHP attribute ตามที่ Laravel 13 รองรับ เว็บนี้ยังอยากเก็บไว้เป็น property จึงเลือก LARAVEL_130_WITHOUT_ATTRIBUTES ที่ข้ามกฎกลุ่มนั้น
README ของ rector-laravel ตอนนี้แนะนำให้ใช้ withComposerBased() แทนการไล่ใส่ชุดอัปเกรดเอง Rector จะอ่านเวอร์ชัน Laravel จาก composer.json แล้วเลือกชุดอัปเกรดจนถึงเวอร์ชันที่ติดตั้งอยู่ให้ ส่วนชุดอัปเกรดแบบใส่เองอย่าง LaravelSetList::LARAVEL_130 ตอนนี้ขึ้นว่า deprecated แล้ว และจะหายไปในเวอร์ชันหลักถัดไป
return RectorConfig::configure()
->withComposerBased(laravel: true);
กลุ่มที่สองเป็นชุดที่ไม่ผูกกับเวอร์ชัน ตัวที่เว็บนี้เปิดไว้คือ
LARAVEL_CODE_QUALITYปรับโค้ดให้ใช้ของที่ Laravel มีให้แล้วLARAVEL_COLLECTIONปรับการใช้ collectionLARAVEL_TYPE_DECLARATIONSเติม type ให้โค้ด Laravel ที่ Rector รู้ชนิดได้แน่นอนLARAVEL_ELOQUENT_MAGIC_METHOD_TO_QUERY_BUILDERเปลี่ยนการเรียกแบบPost::find(1)เป็นPost::query()->find(1)ให้ editor กับ PHPStan รู้ว่ากำลังทำงานกับ query builder
เช็ก dd() ที่หลงเหลือด้วย RemoveDumpDataDeadCodeRector
dd() กับ dump() ที่ใส่ไว้ตอนไล่บั๊กแล้วลืมลบ ถ้าหลุดขึ้น production หน้านั้นจะหยุดทำงาน แล้วแสดงข้อมูลข้างในให้ผู้ใช้เห็นทันที rector-laravel มีกฎ RemoveDumpDataDeadCodeRector ที่ลบการเรียกฟังก์ชันพวกนี้ออก กฎนี้ไม่ได้อยู่ในชุดไหน ต้องเปิดเอง และเลือกได้ว่าจะให้หาฟังก์ชันอะไรบ้าง
use RectorLaravel\Rector\FuncCall\RemoveDumpDataDeadCodeRector;
return RectorConfig::configure()
->withConfiguredRule(RemoveDumpDataDeadCodeRector::class, ['dd', 'dump', 'ray']);
// เดิม
public function publish(Post $post): void
{
dd($post);
$post->update(['published_at' => now()]);
}
// หลังรัน Rector
public function publish(Post $post): void
{
$post->update(['published_at' => now()]);
}
ในเครื่อง กฎนี้ลบ dd() ให้เลย ส่วนใน pull request ที่รัน rector process --dry-run ไฟล์ที่ยังมี dd() ค้างอยู่จะทำให้ workflow ไม่ผ่าน กฎข้อเดียวจึงเป็นได้ทั้งตัวลบและตัวตรวจ
กฎนี้ดูเฉพาะการเรียกแบบฟังก์ชัน ส่วน ->dd() หรือ ->dump() ที่ต่อท้าย query builder หรือ collection กฎนี้ไม่เห็น ถ้าอยากได้อีกชั้น Pest มี arch test ที่เขียนว่า expect(['dd', 'dump'])->not->toBeUsed() ได้ เทสต์จะไม่ผ่านถ้ามีไฟล์ไหนเรียกใช้
กฎที่เราเลือกข้าม
กฎทุกข้อของ Rector เขียนไว้ให้ใช้กับโค้ด PHP ทั่วไป บางข้อจึงเข้ากับโค้ด Laravel ได้ไม่ดี ตัวอย่างที่เห็นชัดคือ AddNameToBooleanArgumentRector ในชุด namedArgs กฎนี้ใส่ชื่อพารามิเตอร์ให้ argument ที่เป็น true หรือ false เพราะ true เปล่า ๆ ไม่บอกว่าหมายถึงอะไร
กับฟังก์ชันของ PHP กฎนี้ช่วยได้จริง
// เดิม
in_array($tag, $tags, true);
// หลังรัน Rector
in_array($tag, $tags, strict: true);
แต่พอเจอ where() ของ Eloquent ผลออกมาแบบนี้ (diff จริงจาก --dry-run)
// เดิม
Post::query()->where('is_active', true)->get();
// หลังรัน Rector
Post::query()->where('is_active', operator: true)->get();
where() ประกาศพารามิเตอร์ไว้เป็น where($column, $operator = null, $value = null, $boolean = 'and') argument ตัวที่สองจึงชื่อ $operator ตามหลักแล้ว Rector ใส่ชื่อถูก แต่ตอนเรียก where() แบบสอง argument Laravel เอาตัวที่สองไปใช้เป็นค่าที่จะเทียบ และใช้ = เป็น operator ให้เอง operator: true จึงอ่านแล้วเข้าใจผิด คนที่มาอ่านทีหลังอาจคิดว่า true เป็นเครื่องหมายเปรียบเทียบอะไรสักอย่าง
กฎแบบนี้ข้ามได้ด้วยการใส่ชื่อคลาสไว้ใน withSkip() โดยยังเปิดชุด namedArgs ไว้ได้ เว็บนี้ข้ามไว้อีกสามข้อด้วยเหตุผลคล้ายกัน คือเปลี่ยนแล้วโค้ดไม่ได้อ่านง่ายขึ้นสำหรับทีมเรา ทุกครั้งที่เปิดชุดใหม่ ให้อ่าน diff จาก --dry-run ทั้งหมดก่อน เจอกฎที่ไม่ชอบก็ข้ามทีละข้อ
รัน Rector ก่อน Pint
โค้ดที่ Rector เขียนให้อาจยังไม่ตรงกับรูปแบบที่ทีมใช้ เช่น ช่องว่างหรือการขึ้นบรรทัด เราจึงรัน Laravel Pint ตามหลังทุกครั้ง
vendor/bin/rector process && vendor/bin/pint --dirty
ใน pull request เว็บนี้รัน rector process --dry-run เฉพาะไฟล์ที่ PR แก้ ถ้ายังมีจุดที่ Rector จะเปลี่ยน workflow จะไม่ผ่าน คนเปิด PR ก็รัน Rector ในเครื่องแล้ว push ใหม่






