คู่มือเปลี่ยนเว็บเป็น Real-Time UX ไม่ต้องง้อ WebSockets ด้วย Livewire Polling, Events และ Lazy Loading

โดย CyberMAN


สวัสดีครับเพื่อนๆ นักพัฒนาเว็บทุกคน! ยินดีต้อนรับสู่ยุคที่ผู้ใช้งานคาดหวังความลื่นไหลระดับสิบ แอปพลิเคชันยุคนี้ถ้าต้องคอยกด F5 เพื่อรีเฟรชหน้าจอเพื่อดูข้อมูลใหม่ๆ คงโดนผู้ใช้บ่นแน่นอน ไม่ว่าจะเป็นระบบแสดงผลยอดขาย Real-time, ระบบแจ้งเตือน (Notifications), หรือแดชบอร์ดสรุปผลวิเคราะห์ข้อมูล (Analytics Dashboard)

เมื่อพูดถึง "Real-Time Web App" ภาพในหัวของใครหลายคนคงนึกถึงระบบที่ขับเคลื่อนด้วย WebSockets หรือเครื่องมือยอดฮิตอย่าง Laravel Echo ร่วมกับ Pusher / Soketi แต่สำหรับเหล่านักพัฒนามือใหม่ หรือทีมที่กำลังเริ่มทำโปรเจกต์ด้วย Laravel และ CodeIgniter 4 (CI4) การตั้งค่าเซิร์ฟเวอร์สำหรับ WebSocket, การจัดการสิทธิ์, และการควบคุม Memory Leak อาจเป็นเรื่องที่ยุ่งยาก ซับซ้อน และเกินความจำเป็นสำหรับแอปพลิเคชันที่เพิ่งเริ่มต้น

วันนี้ผมจะมาแนะนำ "อาวุธลับ" ที่จะช่วยให้แอปพลิเคชันของคุณเปลี่ยนเป็นระบบ Real-time ได้ในพริบตาเดียวโดยไม่ต้องใช้ WebSockets แม้แต่บรรทัดเดียว! ผ่านฟีเจอร์เด่นของ Laravel Livewire v4 ได้แก่ Polling, Events และ Lazy Loading ที่ใช้งานง่ายจนคุณต้องร้องว้าว!


ทำความเข้าใจก่อนเริ่ม: Polling vs WebSockets

ก่อนที่เราจะดิ่งลึกไปที่โค้ด เรามาเข้าใจความแตกต่างระหว่างสองแนวคิดนี้ก่อน เพื่อให้คุณเลือกใช้งานได้อย่างเหมาะสมกับสเกลของระบบ

  • WebSockets: เป็นการเปิดท่อการเชื่อมต่อแบบถาวร (Persistent Connection) ระหว่างเบราว์เซอร์กับเซิร์ฟเวอร์ ข้อดีคือเซิร์ฟเวอร์สามารถดันข้อมูล (Push) หาคลายเอนต์ได้ทันทีเมื่อมีข้อมูลใหม่เกิดขึ้น แต่ข้อเสียคือต้องการแรมเซิร์ฟเวอร์สูง และสถาปัตยกรรมค่อนข้างซับซ้อน
  • HTTP Polling: คือเทคนิคการสั่งให้เบราว์เซอร์ส่งคำขอ (Request) ไปถามเซิร์ฟเวอร์เป็นระยะๆ (เช่น ทุกๆ 5 วินาที) ว่า "มีข้อมูลใหม่ไหม?" ถ้ามีก็นำมาแสดงผล แม้จะดูเป็นวิธีดั้งเดิม แต่ด้วยเทคโนโลยีของ Livewire v4 ทำให้การทำ Polling มีประสิทธิภาพสูงมาก เขียนง่าย และลดภาระของเซิร์ฟเวอร์ได้อย่างชาญฉลาด

ตารางเปรียบเทียบต่อไปนี้จะช่วยให้คุณเห็นภาพชัดเจนขึ้นว่าเมื่อไหร่ควรใช้เครื่องมือตัวไหนสำหรับโปรเจกต์ของคุณ:

คุณสมบัติ Livewire Polling (HTTP) WebSockets (Laravel Echo)
ความยากในการตั้งค่า ง่ายมาก (เขียนแค่คำสั่งบน Blade แท็กเดียว) ปานกลาง - สูง (ต้องตั้งค่า Server เพิ่มเติม)
การกินทรัพยากร ต่ำ (Livewire v4 มีระบบ Throttling อัจฉริยะ) ปานกลาง (ต้องคงสถานะการเชื่อมต่อตลอดเวลา)
ความเร็วในการอัปเดต ใกล้เคียง Real-time (ขึ้นอยู่กับ Interval) Real-time ทันทีในระดับมิลลิวินาที
เหมาะสำหรับระบบ Dashboard, สถิติยอดขาย, ดึงข้อมูลจาก APIs แชทสด (Chat App), ระบบประมูล, หุ้นคริปโต

ฟีเจอร์ที่ 1: ตื่นตาตื่นใจกับ wire:poll ของ Livewire

ใน Livewire เวอร์ชันล่าสุด การทำ Polling ทำได้ง่ายมาก เพียงแค่เพิ่มแอตทริบิวต์ wire:poll เข้าไปในโครงสร้าง HTML ของคุณ คอมโพเนนต์นั้นก็จะรีเฟรชตัวเองโดยอัตโนมัติ

ตัวอย่างโค้ด: ระบบนับยอดผู้ติดตาม (Subscriber Counter)

สมมติว่าคุณกำลังทำหน้าแดชบอร์ดส่วนตัว และต้องการให้ตัวเลขผู้ติดตามอัปเดตตลอดเวลาโดยไม่ต้องกดรีเฟรชหน้าเว็บ

SubscriberCounter.php (Backend) PHP
<?php

namespace App\Livewire;

use Livewire\Component;
use App\Models\User;

class SubscriberCounter extends Component
{
    public function render()
    {
        return view('livewire.subscriber-counter', [
            'count' => User::where('is_subscribed', true)->count(),
        ]);
    }
}
subscriber-counter.blade.php (Frontend) Blade
<div wire:poll>
    <div class="card">
        <h3>ยอดผู้ติดตามปัจจุบัน</h3>
        <p>{{ $count }} คน</p>
    </div>
</div>

เพียงแค่คุณใส่คำว่า wire:poll ลงไปในแท็กครอบด้านบนสุด ตัว Livewire จะแอบส่งสัญญาณ Request ไปอัปเดตข้อมูลให้คุณทุกๆ 2.5 วินาทีโดยอัตโนมัติ! สะดวกสุดๆ ไปเลยใช่ไหมครับ?

⚠️ ควบคุม Performance ด้วย Modifiers ของ Livewire v4:

- wire:poll.15s: ปรับเวลาเป็นทุกๆ 15 วินาที เพื่อไม่ให้เซิร์ฟเวอร์ทำงานหนักเกินไป
- wire:poll.visible: ระบบจะทำ Polling เฉพาะเมื่อผู้ใช้งานสกรอลล์หน้าจอมาเห็นคอมโพเนนต์นี้เท่านั้น!
- Background Tab Throttling: เมื่อผู้ใช้เปิดแท็บอื่นค้างไว้ Livewire จะลดการยิงคำขอลง 95% ทันทีโดยอัตโนมัติ


ฟีเจอร์ที่ 2: ผสานพลังไอเดียด้วย Livewire Events

บางครั้งเราไม่จำเป็นต้องตั้งเวลา Polling ตลอดเวลา แต่เราต้องการให้ Component หนึ่ง สั่งให้อีก Component หนึ่งอัปเดตตัวตามเมื่อเกิดการกระทำบางอย่าง (Event-driven Architecture)

ProductComponent.php (ฝั่งผู้ส่ง) PHP
public function addToCart($productId)
{
    // โค้ดเพิ่มสินค้าลงระบบตะกร้า
    
    // ส่งสัญญาณ Event ออกไปทั่วทั้งหน้าเว็บ
    $this->dispatch('cart-updated');
}
CartIcon.php (ฝั่งผู้รับ) PHP
use Livewire\Attributes\On;

class CartIcon extends Component
{
    #[On('cart-updated')]
    public function refreshCartCount()
    {
        // เมธอดนี้จะทำงานและดึงข้อมูลยอดใหม่ทันทีที่ได้รับสัญญาณ
    }
}

ฟีเจอร์ที่ 3: โหลดหน้าเว็บเร็วปานสายฟ้าด้วย Lazy Loading

เมื่อเรานำข้อมูลภายนอก (Third-party APIs) เช่น การดึงราคาน้ำมัน หรือวิเคราะห์ SQL ซับซ้อน หากเขียนแอปพลิเคชันแบบปกติ หน้าเว็บจะหมุนค้างและแสดงหน้ากระดาษสีขาวจนกว่าข้อมูลจะเสร็จ แต่ Livewire มอบฟีเจอร์ Lazy Loading ที่จะโหลดหน้าเว็บโครงสร้างหลักขึ้นมาก่อน แล้วค่อยโหลด Component ตามมาทีหลัง

WeatherWidget.php (Lazy Component) PHP
namespace App\Livewire;

// เปิดใช้งาน Lazy Loading ด้วย Attribute
#[\Livewire\Attributes\Lazy]
class WeatherWidget extends Component
{
    public function render()
    {
        $response = Http::get('https://api.weatherapi.com/v1/current.json');
        return view('livewire.weather-widget');
    }

    // แสดง Skeleton Screen ระหว่างรอข้อมูล
    public function placeholder()
    {
        return '<div class="loading">กำลังโหลดข้อมูลสภาพอากาศ...</div>';
    }
}

แล้วถ้าหากคุณเป็นนักพัฒนา CodeIgniter 4 ล่ะ?

ถึงแม้ว่า Livewire จะเป็นของ Laravel แต่สำหรับสาย CodeIgniter 4 (CI4) คุณก็สามารถสร้าง UX แบบ Real-time ที่ดีเยี่ยมนี้ได้เช่นเดียวกัน โดยผสมผสานสถาปัตยกรรม AJAX Polling ร่วมกับฟังก์ชันพื้นฐานของ CI4 ดังนี้ครับ:

app/Controllers/Dashboard.php PHP
public function getLatestSales()
{
    $model = new OrderModel();
    $totalSales = $model->sum('amount');

    return $this->response->setJSON([
        'status' => 'success',
        'total_sales' => number_format($totalSales, 2)
    ]);
}
dashboard_view.php (Frontend AJAX) JavaScript
function updateSales() {
    fetch('<?= base_url(\'dashboard/getLatestSales\') ?>')
        .then(response => response.json())
        .then(data => {
            if (data.status === 'success') {
                document.getElementById('sales-amount').innerText = '฿' + data.total_sales;
            }
        });
}
// สั่งให้ระบบส่งคำขอซ้ำทุกๆ 5 วินาที
setInterval(updateSales, 5000);

สรุป: เทคนิคไหนที่ใช่สำหรับคุณ?

การทำให้เว็บแอปพลิเคชันตอบสนองแบบเรียลไทม์ไม่จำเป็นต้องลงเอยด้วยสถาปัตยกรรม WebSockets ที่ซับซ้อนเสมอไป การเลือกใช้ HTTP Polling ที่มีการจัดการอัจฉริยะอย่าง Livewire v4 หรือโครงสร้าง AJAX บน CI4 ก็สามารถส่งมอบประสบการณ์ผู้ใช้ที่ดีเยี่ยม ลื่นไหล และประหยัดทรัพยากรฝั่งเซิร์ฟเวอร์ได้อย่างน่าอัศจรรย์ใจครับ

🚀 มาร่วมสนุกและแชร์ไอเดียกัน!

ตอนนี้แอปพลิเคชันของคุณใช้ระบบอัปเดตข้อมูลแบบไหนกันอยู่ครับ? ประสบปัญหาเว็บหน่วงหรือเซิร์ฟเวอร์ล่มกันบ้างไหม? คอมเมนต์พูดคุยแลกเปลี่ยนประสบการณ์ หรือสอบถามวิธีการเขียนโค้ดเพิ่มเติมด้านล่างนี้ได้เลยนะครับ! และอย่าลืมกดแชร์บทความนี้ให้กับเพื่อนๆ นักพัฒนาสาย PHP ด้วยล่ะ!




PHP CI MANIA - PHP Code Generator 

โปรแกรมช่วยสร้างโค้ด "ลดเวลาการเขียนโปรแกรม"
ราคาสุดคุ้ม  
http://www.phpcodemania.com

Scheduler List: Web Dashboard สำหรับจัดการ Laravel Scheduled Tasks

โดย CyberMAN



Laravel · Scheduled Tasks · Dashboard

Scheduler List: Web Dashboard
สำหรับจัดการ Laravel Scheduled Tasks

สร้าง Dashboard ดู Cron Jobs ทั้งหมดของโปรเจกต์ Laravel ผ่านหน้าเว็บ แบบไม่ต้อง SSH เข้า Server ทุกครั้ง

LaravelScheduled TasksCron JobPHPWeb Dashboard



ปัญหาคลาสสิกของ Cron Jobs

ใครที่เคยทำงาน Backend มาสักระยะ คงเคยเจอคำถามนี้จากทีมหรือลูกค้า: "Cron Job มันรันอยู่ไหม? ทำอะไรไปบ้าง?" — แล้วคุณก็ต้อง SSH เข้า Server ไปดู log หรือพิมพ์คำสั่ง crontab -l ซึ่งไม่ใช่วิธีที่สะดวกเลย โดยเฉพาะถ้าทีมมีคนที่ไม่ถนัด Command Line

บทความนี้จะพาคุณสร้าง Scheduler List Dashboard — หน้าเว็บที่แสดงรายการ Scheduled Tasks ทั้งหมดของ Laravel พร้อม Schedule Expression, สถานะ, และประวัติการรัน โดยใช้ฟีเจอร์ที่มีอยู่ใน Laravel อยู่แล้ว ไม่ต้องลง Package เพิ่ม

💡 เหมาะสำหรับ: ผู้ที่เริ่มต้นกับ Laravel และต้องการเข้าใจ Task Scheduling รวมถึงทีมที่ต้องการ Visibility ของ Background Jobs โดยไม่ต้องเปิด Terminal

Laravel Task Scheduling คืออะไร?

Laravel มี Built-in Task Scheduler ที่ให้คุณนิยาม Scheduled Jobs ทั้งหมดใน โค้ด PHP แทนการเขียน Cron หลาย ๆ บรรทัดใน Server — ทำให้ Version Control ได้, อ่านง่าย, และ Deploy พร้อมกับโค้ดเลย

สิ่งที่ต้องมีใน Server มีเพียงบรรทัดเดียวใน Crontab:

crontab
# Server Cron - รันทุกนาที แค่บรรทัดเดียว * * * * * cd /var/www/html && php artisan schedule:run >> /dev/null 2>&1

แล้ว Laravel จะจัดการ Tasks ที่ถึงเวลาแต่ละตัวให้เอง งานทั้งหมดนิยามใน app/Console/Kernel.php (Laravel 10 ลงไป) หรือใช้ Schedule Facade ใน routes/console.php (Laravel 11+)

app/Console/Kernel.php (Laravel 10)
protected function schedule(Schedule $schedule): void { // รายงานยอดขายทุกวันเที่ยงคืน $schedule->command('report:daily-sales') ->dailyAt('00:00') ->withoutOverlapping(); // ลบ Log เก่ากว่า 30 วัน ทุกอาทิตย์ $schedule->command('logs:cleanup') ->weekly() ->sundays() ->at('02:00'); // ส่ง Email สรุปทุก 6 ชั่วโมง $schedule->command('email:summary') ->everySixHours(); // Sync ข้อมูลกับ API ภายนอก ทุก 15 นาที $schedule->call(function() { ExternalApiSync::run(); })->everyFifteenMinutes(); }

Frequency Methods ที่ใช้บ่อย

Laravel มี Helper Methods สำหรับกำหนดความถี่ในการรัน Task ให้ใช้ได้เลยโดยไม่ต้องจำ Cron Syntax:

MethodCron ExpressionความหมายUse Case
->everyMinute()* * * * *ทุกนาทีHealth Check
->everyFifteenMinutes()*/15 * * * *ทุก 15 นาทีAPI Sync
->hourly()0 * * * *ทุกชั่วโมงCache Refresh
->dailyAt('08:00')0 8 * * *ทุกวัน เวลาที่กำหนดรายงานประจำวัน
->weekly()0 0 * * 0ทุกอาทิตย์Cleanup
->monthly()0 0 1 * *ทุกเดือนInvoice
->cron('30 9 * * 1-5')Customวันทำการ 09:30 น.Custom Schedule

สร้าง Scheduler List Dashboard

เราจะสร้าง Route + Controller + Blade View เพื่อดึง Scheduled Tasks ออกมาแสดงบนหน้าเว็บ Laravel มี app('Illuminate\Console\Scheduling\Schedule') ที่ให้ดึงรายการ Events ออกมาได้เลย

Step 1: สร้าง Controller

app/Http/Controllers/SchedulerController.php
<?php namespace App\Http\Controllers; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Http\Request; use Carbon\Carbon; class SchedulerController extends Controller { public function index(Schedule $schedule) { $events = collect($schedule->events()) ->map(function($event) { return [ 'command' => str_replace('\'artisan\' ', '', $event->command), 'expression' => $event->expression, 'description'=> $event->description ?? '-', 'timezone' => $event->timezone ?? config('app.timezone'), 'without_overlapping' => $event->withoutOverlapping, 'run_in_background' => $event->runInBackground, 'next_run' => $event->nextRunDate(Carbon::now(), 0, false, true), ]; }); return view('scheduler.index', compact('events')); } }

Step 2: เพิ่ม Route

routes/web.php
use App\Http\Controllers\SchedulerController; // ป้องกันด้วย Middleware auth ก่อน Deploy จริง Route::middleware(['auth'])->group(function() { Route::get('/admin/scheduler', [SchedulerController::class, 'index']) ->name('scheduler.index'); });

Step 3: สร้าง Blade View

resources/views/scheduler/index.blade.php
@extends('layouts.app') @section('content') <div class="container mx-auto py-8 px-4"> <div class="flex items-center justify-between mb-6"> <h1 class="text-2xl font-bold text-gray-800"> 📅 Scheduled Tasks </h1> <span class="text-sm text-gray-500"> ทั้งหมด {{ $events->count() }} Tasks </span> </div> <div class="overflow-x-auto rounded-xl shadow"> <table class="w-full bg-white text-sm"> <thead class="bg-indigo-50 text-indigo-900"> <tr> <th class="px-5 py-3 text-left">Command</th> <th class="px-5 py-3 text-left">Expression</th> <th class="px-5 py-3 text-left">Next Run</th> <th class="px-5 py-3 text-left">Options</th> </tr> </thead> <tbody class="divide-y divide-gray-100"> @forelse($events as $event) <tr class="hover:bg-gray-50"> <td class="px-5 py-4"> <code class="bg-indigo-50 text-indigo-700 px-2 py-1 rounded text-xs"> {{ $event['command'] }} </code> @if($event['description'] !== '-') <p class="text-gray-500 mt-1 text-xs">{{ $event['description'] }}</p> @endif </td> <td class="px-5 py-4 font-mono text-xs text-gray-600"> {{ $event['expression'] }} </td> <td class="px-5 py-4 text-gray-700"> {{ $event['next_run'] }} </td> <td class="px-5 py-4"> @if($event['without_overlapping']) <span class="bg-green-100 text-green-700 text-xs px-2 py-0.5 rounded-full mr-1"> no-overlap </span> @endif @if($event['run_in_background']) <span class="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded-full"> background </span> @endif </td> </tr> @empty <tr> <td colspan="4" class="px-5 py-8 text-center text-gray-400"> ยังไม่มี Scheduled Tasks </td> </tr> @endforelse </tbody> </table> </div> </div> @endsection

เพิ่ม Description และ Security

ก่อน Deploy จริง ต้องทำ 2 อย่างนี้เสมอ:

เพิ่ม Description ให้แต่ละ Task

app/Console/Kernel.php
$schedule->command('report:daily-sales') ->dailyAt('00:00') ->withoutOverlapping() ->description('สร้างรายงานยอดขายประจำวัน ส่ง Email Admin') // ✅ ->appendOutputTo(storage_path('logs/daily-sales.log')); $schedule->command('logs:cleanup') ->weekly()->sundays()->at('02:00') ->description('ลบ Log ไฟล์เก่ากว่า 30 วัน'); // ✅

ป้องกันด้วย Middleware

routes/web.php
// วิธีที่ 1: ใช้ Auth Middleware (แนะนำ) Route::middleware(['auth', 'can:view-admin']) ->get('/admin/scheduler', [SchedulerController::class, 'index']); // วิธีที่ 2: จำกัด IP (สำหรับ Internal Tools) Route::middleware(['auth']) ->get('/admin/scheduler', function() { if (!in_array(request()->ip(), explode(',', env('ADMIN_IPS')))) { abort(403); } return app(SchedulerController::class)->index(app(Schedule::class)); });
⚠️ สำคัญมาก: หน้า Scheduler Dashboard ควรอยู่หลัง Authentication เสมอ เพราะแสดงข้อมูล Internal Tasks ของระบบ อย่าเปิด Public โดยเด็ดขาด

คำสั่งที่ควรรู้จัก

Laravel มี Artisan Commands สำหรับจัดการ Scheduler โดยเฉพาะ ใช้ได้ตั้งแต่ Development จนถึง Production:

Terminal
# ดูรายการ Scheduled Tasks ทั้งหมด (แบบ CLI) php artisan schedule:list # รัน Scheduler ทันที (ทดสอบ) php artisan schedule:run # รัน Scheduler แบบ Loop ทุกนาที (สำหรับ Local Dev) php artisan schedule:work # รัน Task เฉพาะตัว โดยไม่รอ Schedule php artisan schedule:run --task="report:daily-sales" # ดู Log ของ Scheduler tail -f storage/logs/laravel.log

คำสั่ง php artisan schedule:list คือต้นแบบของ Dashboard ที่เราสร้าง แต่เราเปลี่ยนจาก Terminal Output มาเป็นหน้าเว็บที่ทีมทุกคนเข้าดูได้แทน

สรุป: ทำไม Scheduler Dashboard ถึงสำคัญ

การมี Web Dashboard สำหรับ Scheduled Tasks ช่วยให้ทีมพัฒนาและ Operations เห็นภาพรวมของ Background Jobs ทั้งหมดได้ทันที โดยไม่ต้องให้ทุกคนมีสิทธิ์ SSH เข้า Production Server

สิ่งที่ได้ประโยชน์
ดู Tasks ทั้งหมดบนเว็บไม่ต้อง SSH เข้า Server
แสดง Cron Expressionทีม Non-tech เข้าใจ Schedule ได้
แสดง Next Run Timeวางแผน Maintenance ได้ถูกต้อง
ป้องกันด้วย Authปลอดภัย เฉพาะ Admin เท่านั้น
Code อยู่ใน CodebaseVersion Control ได้, Deploy พร้อมกัน

ขั้นต่อไปที่ทำได้คือ เพิ่ม Audit Log บันทึกว่า Task รันสำเร็จหรือไม่, เพิ่มปุ่ม "Run Now" สำหรับ Admin, หรือแม้แต่ต่อ Webhook ส่ง Notification ไปที่ LINE/Slack เมื่อ Task ล้มเหลว

📖 อ่านบทความ PHP / Laravel เพิ่มเติม

บทความใหม่ทุกสัปดาห์เกี่ยวกับ PHP, Laravel, CodeIgniter 4 และ Web Development สำหรับนักพัฒนาไทย

ไปที่ PHP Code Mania →





PHP CI MANIA - PHP Code Generator 

โปรแกรมช่วยสร้างโค้ด "ลดเวลาการเขียนโปรแกรม"
ราคาสุดคุ้ม  
http://www.phpcodemania.com

จัดการ "เงิน" ใน PHP ให้ถูกต้อง: Money Value Object vs brick/money (สำหรับ Laravel/CI4)

โดย CyberMAN

เคยเจอบั๊กแบบนี้ไหมครับ... ลูกค้าโทรมาบอกว่าโดนเก็บเงินเกิน 1 สตางค์ แล้วทีมบัญชีก็ส่งมาถามว่า "ทำไมยอดมันไม่ตรง?" พอไปดูโค้ดก็เจอว่าเราเก็บราคาเป็น float หรือ decimal แล้วบวกลบกันไปมาจนค่ามันคลาดเคลื่อน นี่ไม่ใช่บั๊กเล็ก ๆ เลยครับ เพราะมันเกี่ยวกับ "เงิน" ของลูกค้าโดยตรง

ปัญหานี้เกิดจากแนวคิดพื้นฐานที่หลายคนมองข้าม คือ "เงิน" ไม่ใช่แค่ตัวเลข มันคือจำนวนเงินที่ผูกอยู่กับสกุลเงิน และมีกฎเกณฑ์ทางธุรกิจที่ตัวเลขล้วน ๆ ไม่สามารถบอกเราได้ เช่น เราไม่สามารถเอา 10 USD ไปบวกกับ 10 EUR ได้ตรง ๆ และเมื่อต้องหารเงินแบบไม่ลงตัว ใครจะเป็นคนรับเศษสตางค์ที่เหลือ?

ในบทความนี้ เราจะมาดูกันว่าทำไมการเก็บเงินด้วยตัวเลขธรรมดาถึงอันตราย แล้วจะแก้ปัญหาด้วยการสร้าง Value Object ของตัวเอง หรือจะใช้ไลบรารีสำเร็จรูปอย่าง brick/money ดีกว่ากัน รวมถึงแนวทางการออกแบบให้ทั้งสองแบบสามารถสลับใช้งานกันได้ในอนาคต เหมาะสำหรับคนที่กำลังพัฒนาเว็บแอปด้วย CodeIgniter 4 หรือ Laravel ครับ

🔍 ปัญหาที่ซ่อนอยู่ 3 อย่างของการเก็บเงินแบบ "ตัวเลขดิบ"

ก่อนจะไปถึงทางแก้ เรามาดูกันก่อนว่าทำไมการเก็บราคาเป็น float หรือแม้แต่ int/decimal แบบไม่มีการจัดการที่ดี ถึงนำไปสู่ปัญหาในระยะยาว

1. ปัญหา Floating Point (จุดทศนิยมไม่เที่ยง)

คอมพิวเตอร์เก็บเลขทศนิยมในรูปแบบ binary fraction ซึ่งตัวเลขอย่าง 0.10 ไม่สามารถแทนค่าได้แม่นยำ 100% ในระบบไบนารี เมื่อเราบวกราคาสินค้าหลายพันรายการเข้าด้วยกัน ความผิดพลาดเล็ก ๆ นี้จะสะสมจนกลายเป็นยอดเงินที่ผิดจริง ๆ

2. ปัญหาสกุลเงินที่หายไป

ตัวเลขเปล่า ๆ อย่าง 10 ไม่สามารถบอกได้ว่าหมายถึง 10 ดอลลาร์ หรือ 10 เยน ถ้าระบบของเรามีหลายสกุลเงิน แล้วดันเผลอเอายอด USD ไปบวกกับ JPY ตรง ๆ โดยไม่มีการตรวจสอบ ผลลัพธ์ที่ได้ก็จะเป็นตัวเลขที่ "รันได้" แต่ไม่มีความหมายทางธุรกิจเลย

3. ปัญหากฎการปัดเศษ (Rounding)

ลองสมมติว่าต้องหารส่วนลด 10.00 บาท ออกเป็น 3 รายการเท่า ๆ กัน จะได้ 3.333... บาทต่อรายการ คำถามคือใครจะรับเศษสตางค์ที่เหลือไป? ถ้าเราปล่อยให้แต่ละจุดในโค้ดปัดเศษกันเอง โดยไม่มีกฎกลางที่ชัดเจน ผลรวมที่ได้อาจไม่เท่ากับยอดตั้งต้น และนี่คือต้นเหตุของรายงานบัญชีที่ไม่ลงตัว

ปัญหาสาเหตุทางแก้
Floating point ไม่เที่ยงเก็บเงินเป็น float/decimal โดยตรงเก็บเป็นจำนวนเต็มในหน่วยย่อยที่สุด (สตางค์/เซนต์)
สกุลเงินหายไปใช้ตัวเลขเปล่า ๆ แทนเงินผูกสกุลเงินไว้กับจำนวนเงินเสมอ
ปัดเศษไม่สม่ำเสมอปัดเศษกระจายอยู่ทั่วโค้ดกำหนดกฎปัดเศษเป็น method กลางในโดเมน

🛠️ แนวทางที่ 1: สร้าง Money Value Object เอง

แนวคิดหลักของ Value Object คือการสร้างคลาสที่ immutable (เปลี่ยนค่าไม่ได้หลังสร้าง) ปลอดภัยเรื่องสกุลเงิน และเก็บจำนวนเงินเป็นจำนวนเต็มในหน่วยย่อยที่สุด เช่น เก็บ 199.99 บาท เป็น 19999 สตางค์

เริ่มจากกำหนดสกุลเงินเป็น enum เพื่อให้ PHP ช่วยตรวจสอบความถูกต้องให้เราตั้งแต่ตอน compile:

Currency.php
enum Currency: string
{
    case USD = 'USD';
    case EUR = 'EUR';
    case THB = 'THB';
    case JPY = 'JPY';

    public function minorUnitDecimals(): int
    {
        return match ($this) {
            Currency::JPY => 0,
            default => 2,
        };
    }
}

สังเกตว่า minorUnitDecimals() สำคัญมาก เพราะ JPY ไม่มีทศนิยม ในขณะที่ USD, EUR, THB มี 2 ตำแหน่ง ถ้าเราใช้สูตรปัดเศษเดียวกันกับทุกสกุลเงิน ผลลัพธ์ของเยนจะผิดทันที

ต่อมาคือคลาส Money ที่เป็น Value Object หลัก:

Money.php
final class Money
{
    private function __construct(
        private readonly int $amountInMinorUnits,
        private readonly Currency $currency,
    ) {}

    public static function fromMinorUnits(int $amount, Currency $currency): self
    {
        return new self($amount, $currency);
    }

    public function add(Money $other): self
    {
        $this->assertSameCurrency($other);

        return new self(
            $this->amountInMinorUnits + $other->amountInMinorUnits,
            $this->currency,
        );
    }

    private function assertSameCurrency(Money $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException(
                'Cannot operate on different currencies.'
            );
        }
    }

    public function getAmount(): int
    {
        return $this->amountInMinorUnits;
    }

    public function getCurrency(): Currency
    {
        return $this->currency;
    }
}

จุดสำคัญของโค้ดนี้คือ assertSameCurrency() ถ้ามีการเอา Money สองสกุลที่ไม่ตรงกันมาบวกกัน ระบบจะ throw exception ทันที แทนที่จะให้ผลลัพธ์เป็นตัวเลขผิด ๆ ที่กลายเป็นปัญหาในอีก 3 สัปดาห์ข้างหน้า

การจัดการเรื่องปัดเศษด้วย Allocation

ส่วนที่ยากที่สุดของการจัดการเงินไม่ใช่การบวกลบ แต่คือการ "หาร" ลองดูตัวอย่างการแบ่งส่วนลด 1000 สตางค์ออกเป็น 3 ส่วน:

Money.php — allocate()
final class Money
{
    // ... โค้ดด้านบน

    /**
     * แบ่งจำนวนเงินออกเป็น N ส่วน โดยผลรวมต้องเท่ากับ
     * จำนวนเงินตั้งต้นเสมอ เศษสตางค์ที่เหลือจะถูกมอบให้
     * กับส่วนแรก ๆ เพื่อไม่ให้มีเงินงอกหรือหายไป
     */
    public function allocate(int $parts): array
    {
        $base = intdiv($this->amountInMinorUnits, $parts);
        $remainder = $this->amountInMinorUnits % $parts;

        $result = [];
        for ($i = 0; $i < $parts; $i++) {
            $amount = $base + ($i < $remainder ? 1 : 0);
            $result[] = new self($amount, $this->currency);
        }

        return $result;
    }
}
💡 ตัวอย่าง: ถ้าเราแบ่ง 1000 สตางค์ออกเป็น 3 ส่วน จะได้ 334, 333, 333 ซึ่งรวมกันได้ 1000 พอดี ไม่มีเงินงอกขึ้นมาจากไหน และไม่มีสตางค์หายไปไหน กฎการปัดเศษนี้ถูกเขียนไว้เป็น method ที่อ่านง่าย ทดสอบได้ และเป็นเจ้าของโดยโดเมนของเราเอง

📦 แนวทางที่ 2: ใช้ brick/money ไลบรารีสำเร็จรูป

เมื่อระบบของเราซับซ้อนขึ้น เช่น ต้องรองรับหลายสกุลเงินจริงจัง ต้องแปลงสกุลเงินตามอัตราแลกเปลี่ยน หรือต้องใช้กฎการปัดเศษหลายรูปแบบ (round half up, round half even ฯลฯ) การเขียน Value Object เองอาจไม่คุ้มค่าอีกต่อไป ตรงนี้แหละที่ไลบรารี brick/money เข้ามาช่วยได้

ติดตั้งผ่าน Composer:

terminal
composer require brick/money

ตัวอย่างการใช้งานเบื้องต้น:

example.php
use Brick\Money\Money;
use Brick\Math\RoundingMode;

$price = Money::of('199.99', 'THB');
$discount = $price->multipliedBy('0.1', RoundingMode::HALF_UP);

$finalPrice = $price->minus($discount);

echo $finalPrice->getAmount(); // 179.99
echo $finalPrice->getCurrency()->getCurrencyCode(); // THB

brick/money จัดการเรื่อง currency, rounding mode, และการแบ่งส่วน (allocation) ให้ครบแล้ว ทำให้เราไม่ต้องเขียนโค้ดเหล่านี้ซ้ำเอง

🚪 ทางออกที่ยั่งยืน: ซ่อน brick/money ไว้หลัง Port ของโดเมนตัวเอง

คำถามคือ ถ้าใช้ brick/money โดยตรงทั่วทั้งโปรเจกต์ แล้ววันหนึ่งอยากเปลี่ยนไลบรารี หรืออยากเพิ่มกฎทางธุรกิจเฉพาะของบริษัทเราเองล่ะ? คำตอบคือการสร้าง Port หรือ interface ของโดเมนเราเองขึ้นมาครอบไว้อีกชั้น

MoneyServiceInterface.php
interface MoneyServiceInterface
{
    public function create(string $amount, string $currency): MoneyValue;

    public function add(MoneyValue $a, MoneyValue $b): MoneyValue;

    public function allocate(MoneyValue $money, int $parts): array;
}

จากนั้นสร้าง implementation ที่ใช้ brick/money อยู่ภายใน:

BrickMoneyService.php
use Brick\Money\Money as BrickMoney;
use Brick\Math\RoundingMode;

class BrickMoneyService implements MoneyServiceInterface
{
    public function create(string $amount, string $currency): MoneyValue
    {
        $money = BrickMoney::of($amount, $currency);

        return new MoneyValue(
            $money->getMinorAmount()->toInt(),
            $currency
        );
    }

    public function add(MoneyValue $a, MoneyValue $b): MoneyValue
    {
        $brickA = BrickMoney::ofMinor($a->amount, $a->currency);
        $brickB = BrickMoney::ofMinor($b->amount, $b->currency);

        $result = $brickA->plus($brickB);

        return new MoneyValue(
            $result->getMinorAmount()->toInt(),
            $a->currency
        );
    }

    public function allocate(MoneyValue $money, int $parts): array
    {
        $brick = BrickMoney::ofMinor($money->amount, $money->currency);
        $ratios = array_fill(0, $parts, 1);

        $allocated = $brick->allocate(...$ratios);

        return array_map(
            fn ($m) => new MoneyValue($m->getMinorAmount()->toInt(), $money->currency),
            $allocated
        );
    }
}

ในโค้ด Controller หรือ Service ของ Laravel/CodeIgniter 4 เราจะเรียกผ่าน MoneyServiceInterface เสมอ ไม่เรียก BrickMoney ตรง ๆ:

OrderService.php
class OrderService
{
    public function __construct(
        private MoneyServiceInterface $moneyService,
    ) {}

    public function splitDiscount(string $amount, string $currency, int $items): array
    {
        $money = $this->moneyService->create($amount, $currency);

        return $this->moneyService->allocate($money, $items);
    }
}
💡 ข้อดีของแนวทางนี้: ถ้าวันหนึ่งทีมตัดสินใจเปลี่ยนจาก brick/money ไปใช้ไลบรารีอื่น หรือกลับมาเขียน Value Object เองทั้งหมด เราแค่เขียน implementation ใหม่ของ MoneyServiceInterface โดยไม่ต้องแก้โค้ดส่วน business logic เลยแม้แต่บรรทัดเดียว นี่คือหลักการเดียวกับ Hexagonal Architecture ที่ว่า "กฎการปัดเศษอยู่ในโดเมนของเรา ส่วนไลบรารีเป็นแค่ตัวที่สลับเปลี่ยนได้"

📌 สรุป

การจัดการ "เงิน" ในแอปพลิเคชัน PHP ไม่ใช่เรื่องของการเลือก data type ให้ถูกต้องเพียงอย่างเดียว แต่เป็นเรื่องของการออกแบบให้กฎทางธุรกิจ เช่น การปัดเศษ การแบ่งส่วน และการตรวจสอบสกุลเงิน อยู่ในที่เดียวที่ชัดเจนและทดสอบได้ ไม่ว่าจะเลือกเขียน Value Object เอง หรือใช้ brick/money สิ่งสำคัญคือต้องห่อหุ้มมันไว้หลัง interface ของโดเมนตัวเอง เพื่อให้ระบบยืดหยุ่นและรองรับการเปลี่ยนแปลงในอนาคตได้โดยไม่ต้องรื้อทั้งโปรเจกต์

สำหรับโปรเจกต์เล็ก ๆ ที่มีสกุลเงินเดียวและกฎไม่ซับซ้อน การเขียน Value Object เองก็เพียงพอและช่วยให้โค้ดเบาขึ้น แต่ถ้าโปรเจกต์เริ่มโตขึ้นและต้องรองรับหลายสกุลเงินหรือกฎการปัดเศษที่ซับซ้อน brick/money พร้อม Port ของตัวเองคือคำตอบที่คุ้มค่ากว่าในระยะยาว




PHP CI MANIA - PHP Code Generator 

โปรแกรมช่วยสร้างโค้ด "ลดเวลาการเขียนโปรแกรม"
ราคาสุดคุ้ม  
http://www.phpcodemania.com