คุณสมบัติ $casts ของ Laravel เป็นหนึ่งในความสะดวกสบายที่ทำงานอยู่เบื้องหลังอย่างเงียบๆ ซึ่งช่วยกำหนดวิธีการจัดการข้อมูลของคุณโดยไม่เป็นที่สังเกตมากนัก เพียงแค่ใส่ type พื้นฐานอย่าง boolean หรือ datetime ลงใน array ตัว Eloquent ก็จะแปลงสตริงดิบจากฐานข้อมูลให้กลายเป็นรูปแบบที่ใช้งานง่ายขึ้นโดยอัตโนมัติ ก่อนที่ค่านั้นจะส่งไปถึง controller หรือ view ของคุณ ช่วยให้โค้ดของคุณดูสะอาดตา แต่ทันทีที่คุณพยายามเรียกใช้ helper function ของตัวเองโดยตรงภายใน array นั้น ตัว framework จะปฏิเสธทันที การเขียนอะไรอย่าง 'username' => 'encrypt_data()' จะใช้งานไม่ได้ เพราะ Laravel คาดหวังว่าจะได้รับ keyword สำหรับการ cast พื้นฐาน หรือคลาสที่ implement interface CastsAttributes เท่านั้น ข้อกำหนดนี้มีอยู่เพราะเลเยอร์ของการ casting ทำงานอยู่ลึกภายในวงจรการทำ hydration และ serialization ของ Eloquent ซึ่งมันต้องการ object ที่คาดเดาได้และมีสัญญา (contract) ที่ชัดเจน มากกว่าจะเป็นสตริงฟังก์ชันแบบสุ่มที่อาจจะไม่มีอยู่จริงในขณะ runtime

ทำไม Helper Function ถึงใช้งานไม่ได้ใน array $casts

เมื่อ Eloquent โหลด model มันจะสแกน array $casts เพื่อตัดสินใจว่าจะแปลงข้อมูลแต่ละคอลัมน์อย่างไร ตัว framework จะรู้จักค่าพื้นฐาน (primitives) เฉพาะเจาะจง เช่น string, integer, array, encrypted และอื่นๆ รวมถึงรู้จักชื่อคลาสแบบเต็ม (fully-qualified class names) ที่ implement CastsAttributes แต่มันจะไม่ประมวลผลสตริงในฐานะโค้ด ดังนั้น 'encrypt_data()' จึงถูกมองว่าเป็นประเภทการ cast ที่ไม่รู้จัก ซึ่งจะทำให้เกิด error แม้ว่า Laravel จะพยายาม parse มันก็ตาม ก็จะไม่มีวิธีที่เชื่อถือได้ในการส่ง model instance, attribute key, ค่าปัจจุบัน และ attributes อื่นๆ ที่เกี่ยวข้องเข้าไปในฟังก์ชันของคุณตามลำดับที่ถูกต้อง Interface นี้มีไว้เพื่อสร้างมาตรฐานในการประสานงาน (handshake) ระหว่าง logic ที่คุณเขียนขึ้นเองกับระบบภายในของ Eloquent โดยเฉพาะ

คุณมีสองวิธีหลักในการแก้ปัญหานี้ วิธีหนึ่งเน้นการนำกลับมาใช้ใหม่ในระยะยาว ส่วนอีกวิธีเน้นความรวดเร็วเมื่อคุณต้องการแก้ไขปัญหาเฉพาะหน้าภายใน model เดียวเท่านั้น

วิธีที่ 1: เขียน Custom Cast Class

หากการแปลงข้อมูลแบบเดียวกันต้องใช้กับหลายฟิลด์หรือกระจายอยู่ตามหลาย model การสร้าง cast class โดยเฉพาะจะเป็นทางเลือกที่สะอาดกว่า มันจะอยู่ในไฟล์ของตัวเอง สามารถทำ unit test แยกต่างหากได้ และช่วยให้ model ของคุณไม่ต้องมีโค้ดซ้ำซาก (boilerplate)

เริ่มจากการสร้าง app/Casts/CustomEncrypt.php โดย namespace ควรตรงกับการตั้งค่า autoloading ของคุณ ซึ่งโดยปกติจะเป็น App\Casts คลาสนี้ต้อง implement Illuminate\Contracts\Database\Eloquent\CastsAttributes ซึ่งจะบังคับให้คุณต้องกำหนดสอง method คือ get และ set

<?php

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

class CustomEncrypt implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): mixed
    {
        return decrypt_data($value);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): mixed
    {
        return encrypt_data($value);
    }
}

Signature ของ method นั้นสำคัญมาก Eloquent จะส่ง argument สี่ตัวเข้าไปในแต่ละ method: $model คือ instance ที่กำลังถูกเติมข้อมูลหรือบันทึก ซึ่งช่วยให้คุณตรวจสอบฟิลด์อื่นๆ ได้หาก logic ของคุณต้องพึ่งพาฟิลด์เหล่านั้น, $key คือชื่อคอลัมน์ที่กำลังถูก cast, $value คือสตริงดิบหรือ null ที่มาจากฐานข้อมูลในกรณีของ get หรือค่าที่ผู้ใช้ส่งมาในกรณีของ set, และ $attributes คือ array ของคอลัมน์ทั้งหมดในแถวนั้น คุณไม่จำเป็นต้องใช้ทั้งสี่ตัวในทุกครั้ง แต่ interface กำหนดให้ต้องมีไว้

ใน method get ด้านบน decrypt_data($value) จะทำงานหลังจาก Eloquent ดึงข้อมูลแถวนั้นจากฐานข้อมูล และก่อนที่ค่านั้นจะไปถึง model ของคุณ ส่วนใน method set ตัว encrypt_data($value) จะทำงานก่อนการ INSERT หรือ UPDATE เพื่อให้แน่ใจว่าฐานข้อมูลจะไม่เห็นข้อความธรรมดา (plaintext)

ในการเชื่อมต่อ ให้เรียกใช้คลาสภายใน array $casts ของ model:

protected $casts = [
    'username' => CustomEncrypt::class,
    'password' => CustomEncrypt::class,
];

เนื่องจากคุณใช้ class constant ตัว autoloader จะจัดการส่วนที่เหลือให้ หากในอนาคตแอปพลิเคชันของคุณต้องการเปลี่ยนรูปแบบการเข้ารหัส คุณเพียงแค่แก้ไขไฟล์เดียว และทุกฟิลด์ที่ถูกแมปไว้จะเปลี่ยนพฤติกรรมทันที การรวมศูนย์แบบนี้เป็นสิ่งที่ยอดเยี่ยมมากเมื่อคุณต้องจัดการข้อมูลที่ละเอียดอ่อนในหลายๆ ตาราง

วิธีที่ 2: ใช้ Accessor และ Mutator

ในบางครั้ง คุณอาจไม่อยากสร้างไฟล์ใหม่สำหรับการแปลงข้อมูลที่สำคัญแค่ในที่เดียว คลาส Attribute ของ Laravel ช่วยให้คุณกำหนด logic ของ get และ set ได้โดยตรงบน model โดยใช้ syntax แบบ closure ของ PHP 8+

use Illuminate\Database\Eloquent\Casts\Attribute;

protected function username(): Attribute
{
    return Attribute::make(
        get: fn ($value) => decrypt_data($value),
        set: fn ($value) => encrypt_data($value),
    );
}

ในกรณีนี้ ชื่อ method จะต้องตรงกับคอลัมน์หรือ attribute ที่คุณต้องการ เมื่อ Eloquent อ่าน username จากฐานข้อมูล มันจะส่งค่าดิบผ่าน closure ของ get และเมื่อคุณกำหนดค่าใหม่ให้กับ property username ของ model ตัว closure ของ set จะทำการเข้ารหัสข้อมูลก่อนที่ Eloquent จะสร้าง query

วิธีนี้เหมาะอย่างยิ่งสำหรับงาน prototype หรือ legacy models ที่การสร้าง directory สำหรับ cast classes ทั้งหมดดูจะเกินความจำเป็น ข้อเสียคือความซ้ำซ้อน หากในภายหลังคุณตัดสินใจว่า email, phone และ backup_code ต้องได้รับการจัดการแบบเดียวกัน คุณจะต้องคัดลอก closure เหล่านั้นไปยังหลาย method หรือหลาย model ซึ่งจะทำให้โค้ดดูรกและจัดการยาก นอกจากนี้ยังทำให้คุณไม่สามารถนำ logic นั้นไปใช้ซ้ำใน unit test ได้โดยไม่ต้องโหลด model ทั้งหมดขึ้นมาทำงาน

การเลือกระหว่างทั้งสองแบบ

ใช้ custom cast class เมื่อคุณให้ความสำคัญกับการนำกลับมาใช้ใหม่ (reuse), การทดสอบ (testing) และการทำให้ model มีขนาดกะทัดรัด (slim) สิ่งนี้เป็นการส่งสัญญาณให้ผู้พัฒนาคนอื่นทราบว่าการแปลงข้อมูลนี้เป็นแนวคิดหลัก (first-class concept) ในแอปพลิเคชันของคุณ ไม่ใช่แค่การเขียนโค้ดแบบแก้ขัด (one-off hack)

ใช้ accessor เมื่อตรรกะ (logic) นั้นจำกัดอยู่เฉพาะจุด, เป็นการทดลอง หรือไม่น่าจะถูกใช้งานนานเกินกว่า sprint ปัจจุบัน วิธีนี้ช่วยให้คุณทำงานได้รวดเร็วโดยไม่ต้องกระจายไฟล์เล็กๆ ไปทั่ว codebase เพียงแค่เตรียมพร้อมที่จะ refactor ไปเป็น cast class เมื่อพบรูปแบบเดิมซ้ำเป็นครั้งที่สองหรือสาม

รายละเอียดเชิงปฏิบัติที่ควรคำนึงถึง

Custom casts นั้นทรงพลัง แต่ก็อาจนำมาซึ่งพฤติกรรมที่ทำให้คุณประหลาดใจได้หากไม่ระวัง ประการแรก จำไว้ว่า get จะได้รับค่าใดก็ตามที่ฐานข้อมูลส่งกลับมา ซึ่งรวมถึง null ด้วย หาก decrypt_data ไม่รองรับค่า input ที่เป็น null คุณควรเขียนโค้ดเพื่อป้องกันไว้:

public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
    return is_null($value) ? null : decrypt_data($value);
}

ประการที่สอง casts จะทำงานระหว่างการทำ array และ JSON serialization เมื่อคุณส่งคืน model ในรูปแบบ API resource หรือเรียกใช้ toArray() ตรรกะ get ของ cast จะยังคงทำงานอยู่ ซึ่งโดยปกติแล้วนั่นคือสิ่งที่คุณต้องการ แต่ควรจำไว้หากคุณกำลังเปิดเผยค่าที่ถอดรหัสแล้ว และจำเป็นต้องมีการควบคุมการมองเห็น (visibility controls) เพิ่มเติมในภายหลัง

ประการที่สาม และสำคัญที่สุดคือ การ casting จะเกิดขึ้น หลังจาก การดึงข้อมูล (retrieval) คุณไม่สามารถ query โดยใช้ค่าที่ถูกแปลงแล้วได้ ตัวอย่างเช่น query อย่าง User::where('username', 'john_doe')->first() จะส่งสตริง john_doe ไปยังฐานข้อมูลโดยตรง โดยไม่ผ่านตรรกะการถอดรหัสของคุณเลย หากคอลัมน์นั้นถูกเข้ารหัสไว้ (encrypted at rest) query ดังกล่าวจะไม่พบข้อมูลใดๆ เว้นแต่คุณจะค้นหาจาก ciphertext ที่ถูกเข้ารหัสโดยตรง ดังนั้นควรวางแผนรูปแบบการเข้าถึงฐานข้อมูล (database access patterns) ให้เหมาะสม เพราะ casts ไม่สามารถใช้แทนฟังก์ชันพื้นฐานของฐานข้อมูล (native database functions) หรือคอลัมน์ที่เป็น plaintext และมีการทำ index ได้

Custom cast classes ยังเป็นที่ที่เหมาะสำหรับเก็บการตั้งค่า (configuration) เล็กๆ น้อยๆ หากคุณต้องการ constructor arguments เช่น การส่งค่า cipher mode หรือ format string เข้าไป Laravel รองรับสิ่งนี้ผ่าน $casts array โดยใช้ expression อย่าง 'field' => CustomEncrypt::class . ':arg' แม้ว่านั่นจะเป็นขั้นตอนที่เหนือกว่าการตั้งค่าพื้นฐานที่อธิบายไว้ในที่นี้ก็ตาม

บทสรุปที่สำคัญ

ข้อจำกัดในการไม่ใส่ helper functions ลงใน $casts โดยตรงนั้นไม่ใช่กฎระเบียบที่ไร้เหตุผล แต่มันช่วยผลักดันให้คุณเขียนโค้ดที่ชัดเจน (explicit), ทดสอบได้ (testable) และนำกลับมาใช้ใหม่ได้ (reusable) Custom cast classes จะเปลี่ยนตรรกะที่กระจัดกระจายอยู่ตามจุดต่างๆ ให้กลายเป็นคอมโพเนนต์ที่เชื่อถือได้ ซึ่งคุณสามารถแชร์ข้าม model ได้โดยไม่ต้องเขียนซ้ำ ส่วน Accessors จะช่วยให้คุณสามารถแก้ไขปัญหาเฉพาะจุดได้อย่างรวดเร็วในกรณีที่การสร้างไฟล์แยกดูจะเป็นเรื่องยุ่งยากเกินความจำเป็น หากคุณเชี่ยวชาญทั้งสองแบบและเลือกใช้ตามขอบเขตของปัญหา เลเยอร์ Eloquent ของคุณจะยังคงอ่านง่ายแม้แอปพลิเคชันจะเติบโตขึ้นก็ตาม