ลง tool ด้วย composer global require มีปัญหาสองเรื่อง
ที่ผ่านมา tool ที่อยากใช้ได้จากทุกโปรเจกต์ เราก็มักลงด้วย composer global require อย่าง Laravel installer ที่ทำให้พิมพ์ laravel new ได้จากทุกที่ ก็ใช้ได้นะ แต่พอลงไว้หลายตัวจะเริ่มเจอสองเรื่อง
- dependency ชนกัน global ของ Composer จริง ๆ คือโปรเจกต์เดียวที่มี
composer.jsonกับvendorชุดเดียว ทุก tool ที่ลงไว้จึงต้องใช้ library เวอร์ชันเดียวกัน ถ้าตัวหนึ่งต้องการsymfony/console6 อีกตัวต้องการ 7 Composer จะลงไม่ได้และขึ้นว่าYour requirements could not be resolved to an installable set of packages. - ไม่อัปเดตเอง tool จะค้างอยู่ที่เวอร์ชันที่ลงไว้ จนกว่าเราจะนึกได้แล้วรัน
composer global update
ถ้ายังไม่คุ้นกับ composer.json, vendor และ version constraint ลองอ่านวิธีใช้ Composer จัดการ package ก่อนได้ครับ
CPX คือ npx ของ Composer
CPX ย่อมาจาก Composer Package Executor ทำหน้าที่เดียวกับ npx ของ npm คือรันคำสั่งของ package โดยไม่ต้องเพิ่ม package นั้นเข้าโปรเจกต์หรือเข้า global Liam Hammett เริ่มทำไว้ตั้งแต่ปี 2024 แล้วเดือนกรกฎาคม 2026 ทีม Laravel เขียนใหม่ทั้งหมดเป็น CPX 2.0 ย้ายมาอยู่ที่ laravel/cpx และ Taylor Otwell เปิดตัวใน keynote ของ Laracon US 2026 เวอร์ชันล่าสุดตอนนี้คือ 2.1.0 ใช้กับ PHP 8.3 ขึ้นไป
ลงครั้งเดียวด้วย Composer
composer global require cpx/cpx
ตัว CPX เองลงแบบ global ได้โดยไม่ชนใคร เพราะเป็นไฟล์ PHAR ที่รวม dependency ไว้ข้างในแล้ว แค่ต้องให้ global bin ของ Composer อยู่ใน PATH (ดู path ได้จาก composer global config bin-dir --absolute) จากนั้นก็รันคำสั่งของ package ไหนก็ได้
# สร้างโปรเจกต์ใหม่ โดยไม่ต้องลง laravel/installer ไว้ก่อน
cpx laravel/installer new shop
# ระบุเวอร์ชันด้วย constraint แบบเดียวกับใน composer.json
cpx nunomaduro/phpinsights:^2.0 analyse ./src
ครั้งแรกจะช้าสักไม่กี่วินาที เพราะ CPX ต้องโหลด package และ dependency ของมันไปลงแยกไว้ที่ ~/.cpx ครั้งต่อไปใช้ของเดิม และทุกครั้งที่รัน CPX จะเช็คเวอร์ชันใหม่ไม่เกินชั่วโมงละครั้ง ถ้ามีเวอร์ชันที่ตรง constraint ก็อัปเดตก่อนรัน ส่วน composer.json ของโปรเจกต์ไม่เปลี่ยนเลย
ถ้าโปรเจกต์ลง tool ตัวนั้นไว้ใน vendor/bin อยู่แล้ว CPX จะใช้ตัวในโปรเจกต์ก่อน เช่น cpx pint ก็คือ Pint เวอร์ชันที่โปรเจกต์ล็อกไว้ ถ้าอยากใช้ตัวที่ลงแยกจริง ๆ ใส่ --skip-local
| คำสั่ง | ทำอะไร |
|---|---|
cpx alias laravel/installer laravel |
ตั้งชื่อสั้นให้ package จะได้ไม่ต้องพิมพ์ชื่อเต็มทุกครั้ง |
cpx installed |
ดู package ที่เคยรันผ่าน CPX และใช้ล่าสุดเมื่อไหร่ |
cpx update |
อัปเดตทุกตัวตาม constraint ปกติไม่ต้องสั่ง เพราะเช็คให้เองอยู่แล้ว |
cpx clean |
ลบ package ที่ไม่ได้ใช้มานาน ใส่ --all เพื่อลบทั้งหมด |
cpx tinker |
เปิด REPL ถ้าอยู่ในโปรเจกต์ Laravel จะเรียก php artisan tinker ให้ |
CLI ที่ agent ใช้ง่ายตอบเป็น JSON และบอกผลด้วย exit code
AI agent อย่าง Claude Code หรือ Codex เรียก CLI ผ่าน shell แล้วอ่าน output กลับมา tool ที่พิมพ์ตารางสี ๆ หรือถามต่อว่า "ต้องการทำต่อไหม" คนอ่านง่าย แต่ agent ต้องเดาเอาเอง ต้นทางสรุป CLI ที่ agent ใช้ง่ายไว้สี่ข้อ
| ข้อ | เหตุผล |
|---|---|
| ผลลัพธ์เป็น JSON รูปเดิมทุกครั้ง | agent กับ script อ่าน field ได้เลย ไม่ต้องตัดข้อความ |
| error ก็เป็น JSON | ไม่มีกรอบ error สี ๆ ของ framework ปนมา |
| สำเร็จจบด้วย exit code 0 พลาดจบด้วย 1 | ผู้เรียกรู้ผลได้โดยไม่ต้องอ่าน output |
| ไม่ถามอะไรกลับ | agent ตอบคำถามแบบ interactive ไม่ได้ คำสั่งจะค้างรอ |
CPX เองก็ทำตามนี้ ถ้ารู้ว่าไม่ได้รันใน terminal ของคน (agent เรียก ส่ง input ผ่าน pipe หรือใส่ --no-interaction) จะปิด progress bar กับคำถามทั้งหมด เหลือแค่ output ของ tool และ error ของ CPX เองก็เป็น JSON เช่นตอนพิมพ์ชื่อ package ผิด
{"success": false, "errors": ["The package \"acme/thai-idd\" could not be found."], "summary": {}}
สร้าง CLI ตรวจเลขบัตรประชาชนด้วย Symfony Console
ต้นทางทำ CLI ดูสภาพอากาศเป็นตัวอย่าง เราลองทำตัวที่ใกล้งานเรากว่า คือส่งเลขบัตรประชาชน 13 หลักเข้าไป แล้วได้ JSON กลับมาว่าหลักตรวจสอบตรงไหม (สูตรคำนวณอยู่ในโพสต์ ThaiIdCardRule) package มีแค่สามไฟล์ ใช้ Symfony Console ตัวเดียวกับที่ Artisan ใช้อยู่ข้างใต้
<?php
namespace Acme\ThaiId;
use InvalidArgumentException;
final class ThaiId
{
/** @return array{id: string, valid: bool} */
public static function check(string $id): array
{
$digits = preg_replace('/\D/', '', $id);
if (strlen($digits) !== 13) {
throw new InvalidArgumentException('ID must be 13 digits');
}
$sum = 0;
for ($i = 0; $i < 12; $i++) {
$sum += (int) $digits[$i] * (13 - $i);
}
return [
'id' => $digits,
'valid' => (11 - $sum % 11) % 10 === (int) $digits[12],
];
}
}
#!/usr/bin/env php
<?php
use Acme\ThaiId\ThaiId;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
require file_exists(__DIR__.'/../vendor/autoload.php')
? __DIR__.'/../vendor/autoload.php'
: __DIR__.'/../../../autoload.php';
$command = (new Command('thai-id'))
->addArgument('id', InputArgument::REQUIRED, 'Thai national ID, 13 digits')
->setCode(function (InputInterface $input, OutputInterface $output): int {
$output->writeln(json_encode(ThaiId::check($input->getArgument('id'))));
return Command::SUCCESS;
});
$app = new Application('thai-id', '0.1.0');
$app->addCommand($command);
$app->setDefaultCommand('thai-id', true);
$app->setCatchExceptions(false);
try {
exit($app->run());
} catch (Throwable $e) {
echo json_encode(['error' => $e->getMessage()]).PHP_EOL;
exit(1);
}
{
"name": "acme/thai-id",
"description": "Check a Thai national ID number, as JSON.",
"license": "MIT",
"bin": ["bin/thai-id"],
"require": {
"php": "^8.4",
"symfony/console": "^8.0"
},
"autoload": {
"psr-4": { "Acme\\ThaiId\\": "src/" }
}
}
ตรรกะอยู่ในคลาส ThaiId แยกจากไฟล์ใน bin ไฟล์ CLI จึงเหลือแค่รับ argument กับพิมพ์ผล และทดสอบตรรกะได้โดยไม่ต้องผ่าน CLI จุดที่ควรดูในไฟล์ bin/thai-id มีดังนี้
- บรรทัด
#!/usr/bin/env phpทำให้รันไฟล์ได้ตรง ๆ หลังchmod +x bin/thai-id requireautoloader สองที่ ตอนพัฒนา autoloader อยู่ในvendor/ของ package เอง แต่พอ CPX หรือ Composer ลง package เป็น dependency ไฟล์นี้จะไปอยู่ในvendor/acme/thai-id/binautoloader จึงอยู่ถัดขึ้นไปสามชั้น ถ้าลืมข้อนี้ ทดสอบในเครื่องจะผ่าน แต่พังทันทีหลัง publishsetDefaultCommand('thai-id', true)ทำให้พิมพ์thai-id <เลข>ได้โดยไม่ต้องใส่ชื่อ commandsetCatchExceptions(false)คู่กับtry/catchปกติ Symfony จะจับ exception แล้วพิมพ์กรอบแดงให้คนอ่าน เราปิดไว้แล้วจับเองที่เดียว ทั้ง exception ของเราและของ Symfony อย่างลืมใส่ argument หรือใส่ option ที่ไม่มี ทุกอย่างจึงออกมาเป็น{"error": …}และจบด้วย exit code 1
ส่วน bin ใน composer.json บอกทั้ง Composer และ CPX ว่าไฟล์ไหนคือคำสั่ง ชื่อไฟล์ตรงกับชื่อ package พอดี จึงพิมพ์แค่ cpx acme/thai-id <เลข> ได้โดยไม่ต้องบอกชื่อ binary ต้นทางเขียนด้วย pipe operator (|>) ของ PHP 8.5 ส่วนตัวอย่างนี้เขียนแบบธรรมดา จึงใช้กับ PHP 8.4 ที่ Symfony Console 8 ต้องการได้
ลองรันดูหลัง composer install
bin/thai-id 1234567890121
{"id":"1234567890121","valid":true}bin/thai-id 12345
{"error":"ID must be 13 digits"}bin/thai-id
{"error":"Not enough arguments (missing: \"id\")."}สังเกตว่าเลขที่หลักตรวจสอบไม่ตรงไม่นับเป็น error คำสั่งทำงานสำเร็จ แค่ผลออกมาเป็น "valid": false และจบด้วย exit code 0 แยกผลลัพธ์ออกจาก error ให้ชัด agent จะรู้ว่าควรแจ้งผู้ใช้ หรือควรแก้คำสั่งแล้วลองใหม่
ลองรันผ่าน CPX ก่อนขึ้น Packagist
ก่อนออกเวอร์ชันแรก ลองรัน cpx . 1234567890121 ในโฟลเดอร์ของ package ดูก่อน CPX จะอ่าน bin จาก composer.json แล้วรันเหมือนตอน publish แล้ว ถ้า metadata ผิดจะเห็นตั้งแต่ตอนนี้ (ต้องรัน composer install ไว้ก่อน เพราะ CPX ไม่ลง dependency ให้ package ในเครื่อง)
ทีนี้ก็ publish ได้ push ขึ้น GitHub ติด tag v0.1.0 แล้วส่ง repo ที่ packagist.org/packages/submit ถ้าเปิด GitHub integration ของ Packagist ไว้ tag ใหม่จะขึ้นเองทุกครั้ง ต้นทางทำ GitHub Action ที่เพิ่มเลข patch แล้วติด tag ให้ทุกครั้งที่ push เข้า main ด้วย เพราะ Packagist ดูที่ tag การติด tag จึงเท่ากับออกเวอร์ชันใหม่
หลังจากนั้นใครก็รัน cpx acme/thai-id 1234567890121 ได้จากทุกเครื่องที่มี PHP กับ CPX
บอก agent ว่ามีคำสั่งนี้ให้ใช้
agent ไม่รู้เองว่ามีคำสั่งนี้ เขียนไว้ใน AGENTS.md, CLAUDE.md หรือ skill ว่าเรียกยังไง ผลเป็นแบบไหน และพลาดแล้วควรทำอะไรต่อ
ตรวจเลขบัตรประชาชนด้วย `cpx acme/thai-id <เลข 13 หลัก>`
ผลเป็น JSON ที่มี `valid` บอกว่าหลักตรวจสอบตรงหรือไม่
ถ้าได้ `error` ให้เช็คว่าส่งเลขครบ 13 หลักหรือยัง อย่าเดาเลขใหม่เอง
ฝั่ง agent มีแค่ PHP กับ CPX ก็พอ ไม่ต้องมี SDK หรือ server ถ้าโปรเจกต์ใช้ Laravel Boost อยู่แล้ว ลองดูวิธีจัดไฟล์ให้ agent อ่านในโพสต์ Laravel Boost กับ AGENTS.md ได้ครับ
CPX เหมาะกับงานแบบไหน
- installer และ tool ที่ใช้นาน ๆ ครั้ง อย่าง
laravel/installerหรือ PHP Insights ใช้แทนcomposer global requireได้ตรง ๆ - tool ของทีมเอง เขียนครั้งเดียว ทุกคนและ agent ในทุกเครื่องรันได้ด้วยชื่อ package แก้แล้วติด tag ใหม่ ทุกเครื่องก็ได้เวอร์ชันใหม่ภายในชั่วโมง
- tool ที่ผูกกับโปรเจกต์ อย่าง Pint หรือ PHPUnit ยังลงไว้ใน
require-devเหมือนเดิม เวอร์ชันจะล็อกอยู่ในcomposer.lockและ CPX ก็ใช้ตัวในโปรเจกต์อยู่แล้ว
อัปเดตเองก็มีอีกด้าน package ที่อัปเดตเองทุกชั่วโมงจะได้เวอร์ชันใหม่ทันทีที่ออก รวมถึงเวอร์ชันปลอมจากบัญชีที่โดนยึดด้วย (ความเสี่ยงเดียวกับที่ทำให้เราตั้ง cooldown ใน Composer) tool ที่มีสิทธิ์เยอะ ระบุเวอร์ชันให้แคบ เช่น cpx vendor/package:2.1.0 จะอุ่นใจกว่า
ต้นทางเองก็บอกว่าตัวอย่างดูสภาพอากาศไม่ได้มีประโยชน์ในตัวเอง เพราะ agent เรียก API ตรง ๆ ก็ได้ สิ่งที่ได้คือโครง package ที่เอาไปใช้ซ้ำกับ tool อื่นได้ ถ้าอยากเห็นโค้ดเต็มรวมถึงไฟล์ GitHub Action ลองอ่านต่อที่บทความของ Tighten และคำสั่งทั้งหมดของ CPX อยู่ใน README ของ laravel/cpx






