เวิร์กช็อป Laravel + Livewire แบบ Class-based Component พร้อม CRUD, ค้นหา, แบ่งหน้า และ Validation

#Livewire คืออะไร

Laravel Livewire เป็นเครื่องมือพัฒนา UI แบบโต้ตอบโดยใช้ PHP Component และ Blade เป็นหลัก เมื่อผู้ใช้กดปุ่มหรือส่งฟอร์ม Livewire จะประสานคำขอกับ Laravel แล้วปรับ DOM เฉพาะส่วนที่เปลี่ยน โดยไม่ต้องเขียน JavaScript สำหรับงานทั่วไปจำนวนมาก

เหมาะกับ: Admin Dashboard, ระบบหลังบ้าน, CRUD, ฟอร์ม และระบบข้อมูลภายในองค์กร

#สถาปัตยกรรม

flowchart LR
    U[Browser / Blade UI] -->|wire:click / wire:submit| L[Livewire PHP Component]
    L --> V[Validation / Authorization]
    V --> M[Eloquent Model]
    M <--> D[(Database)]
    L -->|HTML + Component State| U

#สิ่งที่ต้องมี

  • PHP และ Composer เวอร์ชันที่รองรับ Laravel/Livewire ที่ติดตั้ง
  • Laravel Project
  • SQLite (ตัวอย่างนี้ใช้ SQLite เพื่อเริ่มต้นง่าย)
  • Node.js ไม่จำเป็นสำหรับตัวอย่าง Blade พื้นฐานนี้ หากเพิ่ม Tailwind/Vite ให้ติดตั้ง Node.js

หมายเหตุ: คำสั่งและตำแหน่งไฟล์ของ Livewire อาจต่างกันตามเวอร์ชัน เวิร์กช็อปนี้ใช้ Class-based Component ที่ app/Livewire และ Blade ที่ resources/views/livewire ซึ่งควรตรวจสอบกับเวอร์ชันที่ใช้งาน

#1. สร้างโปรเจกต์

composer create-project laravel/laravel livewire-products
cd livewire-products
composer require livewire/livewire
php artisan make:model Product -m
php artisan make:livewire ProductManager

กำหนด SQLite ใน .env:

DB_CONNECTION=sqlite

ตรวจสอบว่ามีไฟล์ database/database.sqlite (สร้างหากยังไม่มี) และอย่าเก็บไฟล์ .env ที่มีความลับลง Git

#2. Migration

แก้ไขไฟล์ migration ของ products ใน database/migrations/:

Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->decimal('price', 10, 2);
    $table->unsignedInteger('stock')->default(0);
    $table->timestamps();
});
php artisan migrate

#3. Model

app/Models/Product.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    protected $fillable = ['name', 'price', 'stock'];
}

#4. Livewire Component: CRUD + Search + Pagination

แก้ไข app/Livewire/ProductManager.php:

<?php

namespace App\Livewire;

use App\Models\Product;
use Livewire\Component;
use Livewire\WithPagination;

class ProductManager extends Component
{
    use WithPagination;

    public string $name = '';
    public string $price = '';
    public string $stock = '0';
    public string $search = '';
    public ?int $editingId = null;

    protected function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
            'stock' => ['required', 'integer', 'min:0'],
        ];
    }

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function save(): void
    {
        // เพิ่ม authorization policy ก่อนใช้ในระบบจริง
        $data = $this->validate();

        if ($this->editingId !== null) {
            Product::findOrFail($this->editingId)->update($data);
        } else {
            Product::create($data);
        }

        $this->cancelEdit();
        session()->flash('success', 'บันทึกข้อมูลสำเร็จ');
    }

    public function edit(int $id): void
    {
        $product = Product::findOrFail($id);
        $this->editingId = $product->id;
        $this->name = $product->name;
        $this->price = (string) $product->price;
        $this->stock = (string) $product->stock;
    }

    public function cancelEdit(): void
    {
        $this->reset(['name', 'price', 'stock', 'editingId']);
        $this->stock = '0';
        $this->resetValidation();
    }

    public function delete(int $id): void
    {
        // เพิ่ม authorization policy ก่อนใช้ในระบบจริง
        Product::findOrFail($id)->delete();
        session()->flash('success', 'ลบข้อมูลสำเร็จ');
    }

    public function render()
    {
        return view('livewire.product-manager', [
            'products' => Product::query()
                ->when($this->search !== '', fn ($q) =>
                    $q->where('name', 'like', '%' . $this->search . '%'))
                ->latest('id')
                ->paginate(10),
        ]);
    }
}

ประเด็นสำคัญ: ข้อมูลใน public properties ที่ส่งจาก Browser ถือเป็น input ที่ไม่เชื่อถือ ต้อง validate และตรวจสอบสิทธิ์ในทุก action ที่เปลี่ยนข้อมูลเสมอ

#5. Blade UI

แก้ไข resources/views/livewire/product-manager.blade.php:

<div>
    <h1>จัดการสินค้า</h1>

    @if (session()->has('success'))
        <p role="status">{{ session('success') }}</p>
    @endif

    <form wire:submit="save">
        <label>ชื่อสินค้า
            <input type="text" wire:model="name">
        </label>
        @error('name') <p>{{ $message }}</p> @enderror

        <label>ราคา
            <input type="number" step="0.01" min="0" wire:model="price">
        </label>
        @error('price') <p>{{ $message }}</p> @enderror

        <label>จำนวนคงเหลือ
            <input type="number" min="0" wire:model="stock">
        </label>
        @error('stock') <p>{{ $message }}</p> @enderror

        <button type="submit" wire:loading.attr="disabled" wire:target="save">
            {{ $editingId ? 'อัปเดตสินค้า' : 'เพิ่มสินค้า' }}
        </button>
        @if ($editingId)
            <button type="button" wire:click="cancelEdit">ยกเลิก</button>
        @endif
    </form>

    <hr>
    <label>ค้นหาสินค้า
        <input type="search" wire:model.live.debounce.300ms="search">
    </label>

    <table>
        <thead><tr><th>ชื่อ</th><th>ราคา</th><th>คงเหลือ</th><th>จัดการ</th></tr></thead>
        <tbody>
            @forelse ($products as $product)
                <tr wire:key="product-{{ $product->id }}">
                    <td>{{ $product->name }}</td>
                    <td>{{ number_format((float) $product->price, 2) }}</td>
                    <td>{{ $product->stock }}</td>
                    <td>
                        <button type="button" wire:click="edit({{ $product->id }})">แก้ไข</button>
                        <button type="button"
                            wire:click="delete({{ $product->id }})"
                            wire:confirm="ยืนยันการลบสินค้านี้?">ลบ</button>
                    </td>
                </tr>
            @empty
                <tr><td colspan="4">ไม่พบสินค้า</td></tr>
            @endforelse
        </tbody>
    </table>

    {{ $products->links() }}
</div>

ตัวอย่างนี้ใช้ HTML พื้นฐาน สามารถเพิ่ม Tailwind CSS และ Modal ได้ภายหลัง wire:confirm ช่วยลดการกดลบโดยไม่ตั้งใจ แต่ไม่ใช่มาตรการ Authorization

#6. Route และหน้าแสดงผล

routes/web.php:

<?php

use Illuminate\Support\Facades\Route;

Route::view('/products', 'products');

resources/views/products.blade.php:

<!doctype html>
<html lang="th">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Livewire Products</title>
    @livewireStyles
</head>
<body>
    <livewire:product-manager />
    @livewireScripts
</body>
</html>
php artisan serve

เข้า http://127.0.0.1:8000/products

#7. ทดลองระบบ

  1. เพิ่มสินค้า Keyboard ราคา 990.00 คงเหลือ 5
  2. เพิ่มสินค้า Mouse ราคา 450.00 คงเหลือ 10
  3. ค้นหา Key แล้วตรวจสอบผลลัพธ์
  4. แก้ไขราคา Keyboard เป็น 890.00
  5. ทดสอบกรอกราคาติดลบและตรวจสอบข้อความ Validation
  6. ลบสินค้าและตรวจสอบ Confirmation
  7. เพิ่มสินค้ามากกว่า 10 รายการเพื่อทดสอบ Pagination

#8. แนวทางทดสอบอัตโนมัติ

สร้าง Feature Test โดยใช้ Livewire testing utilities:

php artisan make:test ProductManagerTest

tests/Feature/ProductManagerTest.php:

<?php

namespace Tests\Feature;

use App\Livewire\ProductManager;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;

class ProductManagerTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_creates_a_product(): void
    {
        Livewire::test(ProductManager::class)
            ->set('name', 'Keyboard')
            ->set('price', '990.00')
            ->set('stock', '5')
            ->call('save')
            ->assertHasNoErrors();

        $this->assertDatabaseHas('products', [
            'name' => 'Keyboard',
            'stock' => 5,
        ]);
    }

    public function test_it_rejects_negative_price(): void
    {
        Livewire::test(ProductManager::class)
            ->set('name', 'Invalid Product')
            ->set('price', '-1')
            ->set('stock', '1')
            ->call('save')
            ->assertHasErrors(['price' => 'min']);
    }
}
php artisan test

#9. ข้อควรระวังสำหรับ Production

  • ป้องกัน Route ด้วย auth middleware และตรวจสอบสิทธิ์ด้วย Laravel Policies ภายใน save, edit, delete
  • จำกัดสิทธิ์การเข้าถึงข้อมูลตามผู้ใช้หรือองค์กร (tenant scoping) ไม่ใช่เพียงซ่อนปุ่ม
  • หลีกเลี่ยง query ข้อมูลจำนวนมากโดยไม่แบ่งหน้า
  • เพิ่ม index ในคอลัมน์ที่ใช้ค้นหาบ่อย และพิจารณา full-text search ตามฐานข้อมูล
  • หลีกเลี่ยงการส่งข้อมูลลับผ่าน public component properties
  • เพิ่ม Rate Limiting และ Logging ตามความเสี่ยงของระบบ
  • ทดสอบกรณีผู้ใช้แก้ไขหรือลบ record ที่ไม่มีสิทธิ์

#10. สรุป

Livewire ช่วยสร้าง Dynamic UI ด้วย Laravel และ Blade โดยไม่ต้องแยก Frontend SPA สำหรับงาน CRUD ทั่วไป เวิร์กช็อปนี้ครอบคลุมการเพิ่ม แสดง ค้นหา แก้ไข ลบ และแบ่งหน้าข้อมูล พร้อมแนวทาง Validation และ Testing เหมาะสำหรับต่อยอดเป็นระบบบริหารจัดการภายในองค์กร

#เอกสารอ้างอิง