บทความ

PHP 8.4 มีอะไรใหม่

ไล่ดูฟีเจอร์ของ PHP 8.4 ที่ได้ใช้จริง ตั้งแต่ property hooks, private(set), เรียกเมธอดต่อจาก new โดยไม่ต้องครอบวงเล็บ ไปจนถึง array_find() พร้อมผลทดลองว่าใช้ hooks ใน Eloquent model ได้แค่ไหน

เผยแพร่
เวลาอ่าน
6 นาที
แท็ก
#Laravel#PHP

ฟีเจอร์ของเวอร์ชันก่อนหน้าอ่านได้ที่ PHP 8.3 มีอะไรใหม่

Property hooks ใส่ logic ตอนอ่านหรือเขียน property ได้ตรง ๆ

ที่ผ่านมา ถ้าอยากแปลงค่าก่อนเก็บ เช่นเก็บอีเมลเป็นตัวพิมพ์เล็กเสมอ เราต้องทำ property เป็น private แล้วเขียน getter กับ setter คู่กัน PHP 8.4 ให้เราเขียน hook get หรือ set ไว้ที่ตัว property เลย คนที่ใช้คลาสก็อ่านและเขียน $customer->email ได้ตามปกติ

class Customer
{
    private string $email;

    public function getEmail(): string
    {
        return $this->email;
    }

    public function setEmail(string $email): void
    {
        $this->email = strtolower(trim($email));
    }
}

$customer = new Customer();
$customer->setEmail('  [email protected] ');
echo $customer->getEmail(); // [email protected]

set => … แบบสั้นจะเอาผลของ expression ไปเก็บใน property ให้ ส่วน $value คือค่าที่ส่งเข้ามา ถ้าต้องทำหลายบรรทัด เช่นเช็กค่าก่อน ให้เขียน set แบบมีวงเล็บปีกกา แล้วเก็บค่าเองด้วย $this->phone = …

class Customer
{
    public string $phone {
        set (string $value) {
            if (! preg_match('/^0\d{9}$/', $value)) {
                throw new InvalidArgumentException("เบอร์โทรไม่ถูกต้อง: {$value}");
            }

            $this->phone = $value;
        }
    }
}

$customer = new Customer();
$customer->phone = '0812345678'; // ผ่าน
$customer->phone = '1234';       // InvalidArgumentException: เบอร์โทรไม่ถูกต้อง: 1234

RFC · คู่มือ

Property ที่คำนวณจากค่าอื่น ไม่ต้องเก็บค่าของตัวเอง

ถ้า hook ไม่ได้อ้างถึง $this->ชื่อตัวเอง เลย PHP จะไม่จองที่เก็บค่าให้ property นั้น เรียกว่า virtual property เหมาะกับค่าที่คำนวณจาก property อื่น เช่นชื่อเต็ม

class Customer
{
    public function __construct(
        public string $firstName,
        public string $lastName,
    ) {}

    public string $fullName {
        get => "{$this->firstName} {$this->lastName}";
    }
}

$customer = new Customer('สมชาย', 'ใจดี');
echo $customer->fullName; // สมชาย ใจดี

$customer->lastName = 'รักดี';
echo $customer->fullName; // สมชาย รักดี

$customer->fullName = 'อื่น'; // Error: Property Customer::$fullName is read-only

$fullName มีแค่ get จึงเขียนค่าทับไม่ได้ และเพราะไม่มีที่เก็บค่า จะใส่ค่าเริ่มต้นแบบ public string $fullName = '' { … } ก็ไม่ได้ PHP หยุดด้วย error นี้

Fatal error: Cannot specify default value for virtual hooked property Customer::$fullName

Interface ประกาศ property ได้แล้ว

ก่อนหน้านี้ interface มีได้แค่เมธอด ถ้าอยากบังคับว่าทุกคลาสต้องมี label ก็ต้องประกาศเป็น getLabel() PHP 8.4 ให้ interface บอกได้ว่าต้องมี property ที่อ่านได้ (get) เขียนได้ (set) หรือทั้งสองอย่าง คลาสที่ implement จะใช้ hook หรือ property ธรรมดาก็ได้

interface HasLabel
{
    public string $label { get; }
}

final class OrderStatus implements HasLabel
{
    public function __construct(private string $code) {}

    public string $label {
        get => match ($this->code) {
            'paid' => 'ชำระแล้ว',
            'shipped' => 'จัดส่งแล้ว',
            default => 'รอชำระ',
        };
    }
}

final class Category implements HasLabel
{
    public function __construct(public readonly string $label) {}
}

function printLabel(HasLabel $item): void
{
    echo $item->label, PHP_EOL;
}

printLabel(new OrderStatus('paid'));    // ชำระแล้ว
printLabel(new Category('เครื่องดื่ม')); // เครื่องดื่ม

ใส่ hook ให้ property ที่เป็น readonly ไม่ได้

property ที่มี hook ประกาศเป็น readonly ไม่ได้ PHP หยุดตั้งแต่ตอน compile

class Price
{
    public readonly int $amount {
        set => max(0, $value);
    }
}
Fatal error: Hooked properties cannot be readonly

ถ้าอยากได้ property ที่แปลงค่าตอนเขียน และให้คนนอกคลาสอ่านได้อย่างเดียว ให้ใช้ private(set) ในหัวข้อถัดไปแทน

private(set) ให้ทุกที่อ่านได้ แต่แก้ได้แค่ในคลาส

PHP 8.4 แยกสิทธิ์อ่านกับสิทธิ์เขียนของ property ออกจากกันได้ เรียกว่า asymmetric visibility เขียน public private(set) หมายถึงทุกที่อ่านได้ แต่เขียนได้แค่ในคลาสนี้ (ถ้าเขียน private(set) อย่างเดียว PHP ถือว่าอ่านได้แบบ public) ส่วน protected(set) ให้คลาสลูกเขียนได้ด้วย

ต่างจาก readonly ตรงที่ readonly เขียนได้ครั้งเดียว ส่วน private(set) ให้เมธอดในคลาสเปลี่ยนค่ากี่ครั้งก็ได้ เหมาะกับสถานะที่ต้องเปลี่ยนผ่านเมธอดเท่านั้น

final class Order
{
    private string $status = 'pending';

    public function getStatus(): string
    {
        return $this->status;
    }

    public function markPaid(): void
    {
        $this->status = 'paid';
    }
}

ถ้า property เป็น array กฎเดียวกันใช้ตอนเพิ่มสมาชิกด้วย ในคลาสเพิ่มได้ นอกคลาสเพิ่มไม่ได้

final class Order
{
    /** @var list<string> */
    public private(set) array $items = [];

    public function addItem(string $sku): void
    {
        $this->items[] = $sku; // ในคลาส เพิ่มได้
    }
}

$order = new Order();
$order->addItem('COFFEE-250');
echo count($order->items); // 1

$order->items[] = 'FREE-GIFT';
// Error: Cannot indirectly modify private(set) property Order::$items from global scope

ใช้กับ constructor property promotion ได้เหมือนกัน

final class Money
{
    public function __construct(
        public private(set) int $satang,
        public private(set) string $currency = 'THB',
    ) {}
}

$price = new Money(150000);
echo $price->satang; // 150000

$price->satang = 0;
// Error: Cannot modify private(set) property Money::$satang from global scope

RFC · คู่มือ

เรียกเมธอดต่อจาก new ได้เลย ไม่ต้องครอบวงเล็บ

เวลาสร้าง object แล้วเรียกเมธอดต่อทันที เราต้องครอบ new ด้วยวงเล็บอีกชั้น PHP 8.4 ตัดวงเล็บชั้นนั้นออกได้

class PriceFormatter
{
    public function format(int $satang): string
    {
        return number_format($satang / 100, 2) . ' บาท';
    }
}

// ก่อน PHP 8.4
echo (new PriceFormatter())->format(150000); // 1,500.00 บาท

// PHP 8.4
echo new PriceFormatter()->format(150000); // 1,500.00 บาท
echo new DateTimeImmutable('2026-10-11')->format('d/m/Y'); // 11/10/2026

แต่วงเล็บหลังชื่อคลาสยังต้องมี ถึงจะไม่ส่ง argument ก็ตาม ถ้าเขียน new DateTimeImmutable->format('Y') PHP จะไม่รู้ว่า -> เป็นของอะไร

Parse error: syntax error, unexpected token "->", expecting "," or ";"

RFC

array_find(), array_any() และ array_all() แทน foreach หาค่า

PHP 8.4 เพิ่มฟังก์ชัน array สี่ตัว ทุกตัวรับ array กับ callback ที่ได้ทั้งค่าและ key และหยุดทันทีที่ได้คำตอบ

  • array_find() คืนค่าตัวแรกที่ callback ตอบ true ถ้าไม่เจอคืน null
  • array_find_key() เหมือนกัน แต่คืน key
  • array_any() มีอย่างน้อยหนึ่งตัวที่ตรงไหม
  • array_all() ทุกตัวตรงไหม
$orders = [
    'A001' => ['total' => 450, 'paid' => true],
    'A002' => ['total' => 1290, 'paid' => false],
    'A003' => ['total' => 3200, 'paid' => true],
];

array_find($orders, fn (array $order) => $order['total'] > 1000);
// ['total' => 1290, 'paid' => false]

array_find_key($orders, fn (array $order) => $order['total'] > 1000);   // 'A002'
array_any($orders, fn (array $order) => ! $order['paid']);              // true
array_all($orders, fn (array $order) => $order['paid']);                // false
array_find($orders, fn (array $order) => $order['total'] > 9999);       // null

// callback รับ key เป็น argument ตัวที่สอง
array_find_key($orders, fn (array $order, string $id) => str_ends_with($id, '3')); // 'A003'

ถ้าเขียน Laravel อยู่แล้ว Collection มีของคู่กันมานานแล้ว คือ first(), search(), contains() และ every() ที่รับ callback ได้ ฟังก์ชันชุดนี้จึงเหมาะกับตอนที่มี array ธรรมดาอยู่ในมือ และไม่อยากห่อเป็น collect() แค่เพื่อหาค่าตัวเดียว

RFC

#[\Deprecated] ให้ PHP เตือนตอนมีโค้ดเรียกเมธอดที่เลิกใช้แล้ว

เดิมเราบอกว่าเมธอดไหนเลิกใช้ได้แค่ใน docblock @deprecated ซึ่ง IDE เห็น แต่ PHP ไม่เห็น ตอนนี้ใส่ attribute #[\Deprecated] ได้ PHP จะแจ้ง E_USER_DEPRECATED ทุกครั้งที่มีโค้ดเรียก แต่เมธอดยังทำงานต่อตามปกติ

class ThaiBaht
{
    #[\Deprecated(message: 'use ThaiBaht::toText() instead', since: '2.0')]
    public static function convert(float $amount): string
    {
        return self::toText($amount);
    }

    public static function toText(float $amount): string
    {
        return 'หนึ่งร้อยบาทถ้วน';
    }
}

echo ThaiBaht::convert(100);
Deprecated: Method ThaiBaht::convert() is deprecated since 2.0, use ThaiBaht::toText() instead
หนึ่งร้อยบาทถ้วน

ใช้กับฟังก์ชัน เมธอด และ class constant ได้ เหมาะกับคนดูแล package หรือโค้ดกลางที่ใช้กันหลายโปรเจกต์ ใน Laravel ข้อความ deprecated จะไปที่ log channel ตาม LOG_DEPRECATIONS_CHANNEL ซึ่งค่าเริ่มต้นใน config/logging.php คือ null (ทิ้งไป) ถ้าอยากเห็นให้ตั้งเป็น channel ที่มีอยู่ เช่น single บนเครื่อง dev

RFC

พารามิเตอร์ที่ default เป็น null ต้องเขียน ? ให้ชัด

ข้อนี้เราน่าจะเจอก่อนเรื่องอื่นตอนอัปเกรด ที่ผ่านมา PHP ยอมให้เขียน Closure $fallback = null แล้วถือว่าพารามิเตอร์นั้นรับ null ได้เอง ทั้งที่ type บอกแค่ Closure PHP 8.4 เตือนเป็น deprecated แล้ว และ RFC วางแผนจะเลิกรองรับใน PHP 9

function findCustomer(string $email, Closure $fallback = null): ?string
{
    // …
}
Deprecated: findCustomer(): Implicitly marking parameter $fallback as nullable is deprecated, the explicit nullable type must be used instead

แก้โดยใส่ ? หน้า type หรือเขียนเป็น union type Closure|null (union type มาตั้งแต่ PHP 8.0)

function findCustomer(string $email, ?Closure $fallback = null): ?string

โปรเจกต์ Laravel ไม่ต้องไล่แก้เอง Pint ที่ใช้ preset laravel มีกฎ nullable_type_declaration_for_default_null_value เปิดอยู่แล้ว รัน vendor/bin/pint ครั้งเดียวก็แก้ให้ทั้งโปรเจกต์ ผมลองกับไฟล์ตัวอย่างข้างบนแล้ว Pint เติม ? ให้ตามนั้น ถ้าใช้ Rector ก็มีกฎ ExplicitNullableParamTypeRector ทำแบบเดียวกัน

mb_trim() ตัดช่องว่างที่ trim() มองไม่เห็น

trim() ตัดได้แค่ช่องว่างแบบ ASCII ถ้าข้อความ copy มาจากเว็บหรือ Excel มักติด non-breaking space (U+00A0) มาด้วย ซึ่ง trim() ไม่ตัด PHP 8.4 เพิ่ม mb_trim(), mb_ltrim() และ mb_rtrim() ที่รู้จักช่องว่างแบบ Unicode

$name = "\u{00A0}สมชาย ใจดี\u{00A0}";

var_dump(trim($name));    // string(32) " สมชาย ใจดี "
var_dump(mb_trim($name)); // string(28) "สมชาย ใจดี"

มาพร้อม mb_ucfirst() กับ mb_lcfirst() ด้วย เช่น mb_ucfirst('élan') ได้ Élan ขณะที่ ucfirst() คืนค่าเดิม

RFC mb_trim · RFC mb_ucfirst

อ่าน HTML ด้วย Dom\HTMLDocument แล้วหาด้วย CSS selector

DOMDocument ตัวเดิมอ่าน HTML ตามกฎของ HTML4 เจอ tag ของ HTML5 ก็ฟ้อง warning และหา element ได้แค่ด้วย XPath PHP 8.4 เพิ่มคลาสใหม่ใน namespace Dom ที่อ่าน HTML5 ตามมาตรฐาน และมี querySelector() ให้ใช้แบบเดียวกับ JavaScript ใครที่เคยดึงข้อมูลจากหน้าเว็บด้วย XPath ลองเปลี่ยนมาใช้ดู

$html = <<<'HTML'
<main>
    <article>สินค้าทั่วไป</article>
    <article class="product featured">กาแฟคั่วกลาง <span class="price">฿320</span></article>
</main>
HTML;

$dom = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);
$product = $dom->querySelector('article.featured');

$product->classList->contains('featured');       // true
$product->querySelector('.price')->textContent;  // '฿320'
count($dom->querySelectorAll('main > article')); // 2

DOMDocument ตัวเดิมยังอยู่ โค้ดเก่าไม่พัง

RFC

ใน Laravel ใช้ hooks กับ DTO ได้ แต่อย่าประกาศชื่อซ้ำกับคอลัมน์ของ model

พอเห็น property hooks ก็อยากเอาไปใช้กับ Eloquent model แทน accessor และ mutator แต่สองอย่างนี้ไปด้วยกันไม่ได้ Eloquent อ่านและเขียนคอลัมน์ผ่าน __get() กับ __set() ใน Illuminate\Database\Eloquent\Model ซึ่ง PHP จะเรียกก็ต่อเมื่อคลาสไม่มี property ชื่อนั้นจริง ๆ พอเราประกาศ public string $email ไว้ใน model ทั้งอ่านและเขียน $customer->email ก็ไปที่ property ตัวนั้นตรง ๆ ไม่ผ่าน $attributes ของ Eloquent อีกเลย

ผมลองกับ Laravel 13 บน PHP 8.4 ได้ผลแบบนี้

class Customer extends Model
{
    protected $guarded = [];

    public string $email {
        set => strtolower($value);
    }
}

$customer = new Customer(['email' => '[email protected]', 'name' => 'สมชาย']);
$customer->email = '[email protected]'; // ไปเข้า property ไม่เข้า $attributes
$customer->save();                  // บันทึก [email protected] ลงฐานข้อมูล

$customer = Customer::first();
$customer->getAttribute('email');   // '[email protected]'
$customer->email;
// Error: Typed property Customer::$email must not be accessed before initialization

ค่าเดียวกันกลายเป็นสองชุด fill() กับ create() ยังเขียนลง $attributes โดยไม่ผ่าน hook ส่วน $customer->email = … เขียนลง property ที่ Eloquent ไม่เคยบันทึก และตอนโหลดจากฐานข้อมูล property ก็ว่างเปล่า

ถ้า hook ใช้ชื่อที่ไม่ซ้ำคอลัมน์ เช่น public string $label { get => "{$this->name} <{$this->email}>"; } ก็อ่านค่าได้ แต่ไม่ออกมาใน toArray() หรือ JSON ของ model เพราะ Eloquent ไม่รู้จัก property นั้น สรุปว่ากับ model ให้ใช้ accessor และ mutator ของ Laravel ต่อไป ซึ่งทำงานผ่าน $attributes และใส่ใน $appends ได้

use Illuminate\Database\Eloquent\Casts\Attribute;

protected function email(): Attribute
{
    return Attribute::make(
        set: fn (string $value) => strtolower($value),
    );
}

ที่ hooks กับ private(set) เข้ากันดีคือคลาส PHP ธรรมดาในแอป เช่น DTO ที่รับค่าจาก form request ไปส่งต่อให้ action, value object อย่างเงินหรือเบอร์โทร หรือคลาส config ของ package

final class OrderData
{
    public function __construct(
        public private(set) string $email {
            set => strtolower(trim($value));
        },
        public private(set) int $totalSatang,
    ) {}

    public string $totalBaht {
        get => number_format($this->totalSatang / 100, 2);
    }
}

$data = new OrderData(email: ' [email protected]', totalSatang: 129050);

echo $data->email;     // [email protected]
echo $data->totalBaht; // 1,290.50

$data->email = '[email protected]';
// Error: Cannot modify private(set) property OrderData::$email from global scope

ของอื่นที่มากับ PHP 8.4

  • Lazy objects สร้าง object ที่ยังไม่รัน constructor จนกว่าจะมีคนใช้ ผ่าน ReflectionClass::newLazyGhost() และ newLazyProxy() RFC บอกตรง ๆ ว่าทำมาให้คนเขียน framework กับ ORM เป็นหลัก โค้ดแอปทั่วไปแทบไม่ต้องแตะ
  • BcMath\Number คำนวณเลขทศนิยมแม่นยำแบบ object ใช้ +, -, > ได้ตรง ๆ แทน bcadd() และ bccomp()
  • PDO::connect() คืน subclass ตาม driver เช่น Pdo\MySql หรือ Pdo\Pgsql พร้อมเมธอดเฉพาะของ driver นั้น
  • round() รับ enum RoundingMode และมีโหมดปัดใหม่สี่แบบ
  • ส่วนเสริม IMAP ย้ายออกจาก core ไปอยู่ PECL ถ้าระบบอ่านอีเมลด้วย imap_open() ต้องติดตั้งแยกก่อนอัปเกรด

รายการเต็มอยู่ที่หน้า release ของ php.net ส่วนเรื่องที่อาจทำให้โค้ดเก่าพังอ่านได้ที่ Migrating from PHP 8.3.x to PHP 8.4.x

อ่านต่อ

ล่าสุด

ดูทั้งหมด →