XMAN Code Academy
ศูนย์เรียนรู้โค้ดมืออาชีพ
Laravel
PHP Framework ยอดนิยมอันดับ 1 / #1 PHP Framework
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Product extends Model
{
use SoftDeletes;
protected $fillable = [
'name', 'slug', 'description',
'price', 'category_id', 'is_active',
];
protected $casts = [
'price' => 'decimal:2',
'is_active' => 'boolean',
];
// ความสัมพันธ์: สินค้า → หมวดหมู่
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
// ความสัมพันธ์: สินค้า → รีวิว (หลายรายการ)
public function reviews(): HasMany
{
return $this->hasMany(Review::class);
}
// Scope: เฉพาะที่เปิดใช้งาน
public function scopeActive($query)
{
return $query->where('is_active', true);
}
// Accessor: ราคาพร้อมฟอร์แมต
public function getFormattedPriceAttribute(): string
{
return number_format($this->price, 2) . ' ฿';
}
}
class ProductController extends Controller
{
public function index(Request $request)
{
$products = Product::active()
->with('category')
->when($request->search, fn($q, $s) =>
$q->where('name', 'like', "%{$s}%")
)
->latest()
->paginate(12);
return view('products.index', compact('products'));
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|unique:products',
'price' => 'required|numeric|min:0',
'category_id' => 'required|exists:categories,id',
'image' => 'nullable|image|max:2048',
]);
if ($request->hasFile('image')) {
$validated['image'] = $request
->file('image')
->store('products', 'public');
}
$product = Product::create($validated);
return redirect()
->route('products.show', $product)
->with('success', 'สร้างสินค้าเรียบร้อย!');
}
}
return new class extends Migration
{
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->decimal('price', 10, 2);
$table->foreignId('category_id')
->constrained()
->cascadeOnDelete();
$table->string('image')->nullable();
$table->boolean('is_active')->default(true);
$table->softDeletes();
$table->timestamps();
// Index สำหรับเพิ่มความเร็ว
$table->index(['is_active', 'created_at']);
});
}
};
class EnsureIsAdmin
{
public function handle(Request $request, Closure $next)
{
if (! $request->user()?->is_admin) {
abort(403, 'ไม่มีสิทธิ์เข้าถึง');
}
return $next($request);
}
}
// การใช้งานใน routes/web.php
Route::middleware(['auth', EnsureIsAdmin::class])
->prefix('admin')
->group(function () {
Route::resource('products', AdminProductController::class);
});
@extends('layouts.app')
@section('title', 'สินค้าทั้งหมด')
@section('content')
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">
สินค้าทั้งหมด
</h1>
<div class="grid md:grid-cols-3 gap-6">
@forelse($products as $product)
<x-product-card
:product="$product"
:show-price="true"
/>
@empty
<p class="text-gray-500 col-span-3">
ยังไม่มีสินค้า
</p>
@endforelse
</div>
<!-- Pagination -->
<div class="mt-8">
{{ $products->links() }}
</div>
</div>
@endsection
class ProductResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'price' => $this->price,
'formatted' => $this->formatted_price,
'category' => new CategoryResource(
$this->whenLoaded('category')
),
'reviews_count' => $this->whenCounted('reviews'),
'created_at' => $this->created_at->toISOString(),
];
}
}
PHP
ภาษาพื้นฐานสำหรับ Web Development / Server-side scripting language
interface PaymentGateway
{
public function charge(float $amount): PaymentResult;
public function refund(string $transactionId): bool;
}
readonly class PaymentResult
{
public function __construct(
public bool $success,
public string $transactionId,
public float $amount,
public string $message = '',
) {}
}
class StripeGateway implements PaymentGateway
{
public function __construct(
private readonly string $apiKey,
) {}
public function charge(float $amount): PaymentResult
{
// เรียก Stripe API
return new PaymentResult(
success: true,
transactionId: uniqid('txn_'),
amount: $amount,
);
}
}
$users = [
['name' => 'สมชาย', 'age' => 28, 'role' => 'dev'],
['name' => 'สมหญิง', 'age' => 34, 'role' => 'lead'],
['name' => 'สมศักดิ์', 'age' => 22, 'role' => 'dev'],
];
// กรองเฉพาะ developer อายุ > 25
$seniorDevs = array_filter($users, fn($u) =>
$u['role'] === 'dev' && $u['age'] > 25
);
// ดึงเฉพาะชื่อ
$names = array_map(fn($u) => $u['name'], $users);
// ['สมชาย', 'สมหญิง', 'สมศักดิ์']
// รวมอายุทั้งหมด
$totalAge = array_reduce($users,
fn($sum, $u) => $sum + $u['age'], 0
); // 84
// จัดเรียงตามอายุ
usort($users, fn($a, $b) =>
$a['age'] <=> $b['age']
);
// Spread operator (PHP 8+)
$merged = [...$names, 'สมปอง', 'สมใจ'];
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Shipped = 'shipped';
case Completed = 'completed';
case Cancelled = 'cancelled';
// ชื่อแสดงเป็นภาษาไทย
public function label(): string
{
return match($this) {
self::Pending => 'รอดำเนินการ',
self::Processing => 'กำลังดำเนินการ',
self::Shipped => 'จัดส่งแล้ว',
self::Completed => 'สำเร็จ',
self::Cancelled => 'ยกเลิก',
};
}
// สีสำหรับแสดงใน Badge
public function color(): string
{
return match($this) {
self::Pending => 'yellow',
self::Processing => 'blue',
self::Shipped => 'purple',
self::Completed => 'green',
self::Cancelled => 'red',
};
}
}
class InsufficientBalanceException extends \RuntimeException
{
public function __construct(
public readonly float $required,
public readonly float $available,
) {
parent::__construct(
"ยอดเงินไม่เพียงพอ: ต้องการ {$required} มี {$available}"
);
}
}
function processPayment(float $amount): void
{
try {
$balance = getWalletBalance();
if ($balance < $amount) {
throw new InsufficientBalanceException(
$amount, $balance
);
}
deductBalance($amount);
} catch (InsufficientBalanceException $e) {
logger()->warning('ยอดไม่พอ', [
'required' => $e->required,
'available' => $e->available,
]);
throw $e;
} finally {
// ทำงานเสมอ ไม่ว่าจะสำเร็จหรือล้มเหลว
logTransaction($amount);
}
}
JavaScript
ภาษาสำหรับ Web ทั้ง Frontend & Backend / The language of the web
// Destructuring — แกะค่าจาก Object / Array
const { name, age, role = 'user' } = userData;
const [first, ...rest] = items;
// Template Literals — สตริงแบบมีตัวแปร
const greeting = `สวัสดี ${name}, คุณอายุ ${age} ปี`;
// Optional Chaining & Nullish Coalescing
const city = user?.address?.city ?? 'ไม่ระบุ';
// Arrow Functions
const double = (n) => n * 2;
const greet = (name) => {
const msg = `สวัสดี ${name}!`;
return msg;
};
// Spread & Rest
const merged = { ...defaults, ...overrides };
const allItems = [...oldItems, newItem];
// Object shorthand
const product = { name, price, getTotal() {
return this.price * 1.07; // รวม VAT 7%
}};
// Fetch API with async/await
async function fetchProducts(category) {
try {
const response = await fetch(
`/api/products?category=${category}`
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const { data } = await response.json();
return data;
} catch (error) {
console.error('โหลดข้อมูลไม่สำเร็จ:', error);
return [];
}
}
// Promise.all — ทำงานหลายอย่างพร้อมกัน
const [products, categories, reviews] =
await Promise.all([
fetchProducts('software'),
fetchCategories(),
fetchReviews(),
]);
// Promise.allSettled — รอทุกตัวจบ (ไม่สนใจ error)
const results = await Promise.allSettled([
apiCall1(), apiCall2(), apiCall3()
]);
results.forEach((r) => {
if (r.status === 'fulfilled') console.log(r.value);
else console.warn(r.reason);
});
// เลือก Element
const btn = document.querySelector('#submit-btn');
const cards = document.querySelectorAll('.product-card');
// สร้าง Element ใหม่
const card = document.createElement('div');
card.className = 'p-4 bg-white rounded-lg shadow';
card.innerHTML = `
<h3>${product.name}</h3>
<p>${product.price} ฿</p>
`;
document.querySelector('#grid').appendChild(card);
// Event Listener พร้อม Debounce
function debounce(fn, ms = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(
() => fn(...args), ms
);
};
}
searchInput.addEventListener('input',
debounce((e) => searchProducts(e.target.value))
);
<!-- Dropdown Menu -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open">เมนู</button>
<div
x-show="open"
x-transition.opacity
@click.outside="open = false"
class="absolute mt-2 bg-white rounded-lg shadow-xl"
>
<a href="/profile">โปรไฟล์</a>
<a href="/settings">ตั้งค่า</a>
</div>
</div>
<!-- Counter with Animation -->
<div x-data="{ count: 0 }">
<span x-text="count" class="text-4xl font-bold"></span>
<button @click="count++">+1</button>
</div>
<!-- Fetch + Loading State -->
<div x-data="{ items: [], loading: true }"
x-init="
items = await (await fetch('/api/items')).json();
loading = false;
">
<div x-show="loading">กำลังโหลด...</div>
<template x-for="item in items">
<div x-text="item.name"></div>
</template>
</div>
Tailwind CSS
Utility-First CSS Framework / เขียน CSS ด้วย Class
<div class="group relative bg-white rounded-2xl
overflow-hidden shadow-lg hover:shadow-2xl
transition-all duration-500 hover:-translate-y-2">
<!-- รูปภาพ + Overlay -->
<div class="aspect-video overflow-hidden">
<img src="/images/product.jpg"
alt="สินค้า"
class="w-full h-full object-cover
transition-transform duration-700
group-hover:scale-110"/>
<div class="absolute inset-0 bg-gradient-to-t
from-black/50 to-transparent
opacity-0 group-hover:opacity-100
transition-opacity"></div>
</div>
<!-- Badge -->
<span class="absolute top-3 right-3
px-3 py-1 bg-sky-500 text-white
text-xs font-bold rounded-full">
ใหม่
</span>
<!-- เนื้อหา -->
<div class="p-5 sm:p-6">
<h3 class="text-lg font-bold text-slate-800
line-clamp-1">ชื่อสินค้า</h3>
<p class="mt-2 text-sm text-slate-500
line-clamp-2">
รายละเอียดสินค้าที่น่าสนใจ...
</p>
<div class="mt-4 flex items-center
justify-between">
<span class="text-xl font-black
text-sky-600">฿599</span>
<button class="px-4 py-2 bg-sky-500
text-white text-sm font-semibold
rounded-lg hover:bg-sky-600
transition-colors">
ซื้อเลย
</button>
</div>
</div>
</div>
<!-- Responsive Grid: 1 → 2 → 3 → 4 คอลัมน์ -->
<div class="grid grid-cols-1 sm:grid-cols-2
lg:grid-cols-3 xl:grid-cols-4 gap-6">
<!-- Card items go here -->
</div>
<!-- Centered Flex Container -->
<div class="flex items-center justify-center
min-h-screen">
<div class="w-full max-w-md">Login Form</div>
</div>
<!-- Sidebar Layout -->
<div class="flex flex-col lg:flex-row gap-8">
<aside class="lg:w-64 shrink-0">
Sidebar
</aside>
<main class="flex-1 min-w-0">
Main Content
</main>
</div>
<!-- Sticky Header + Scrollable Content -->
<div class="h-screen flex flex-col">
<header class="sticky top-0 z-50 bg-white/80
backdrop-blur-md border-b
px-6 py-3">
Navigation
</header>
<main class="flex-1 overflow-y-auto p-6">
Scrollable Content
</main>
</div>
Python
ภาษายอดนิยมสำหรับ AI, Data Science & Automation
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
class Priority(Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
@dataclass
class Task:
title: str
description: str
priority: Priority = Priority.MEDIUM
completed: bool = False
tags: list[str] = field(default_factory=list)
created_at: datetime = field(
default_factory=datetime.now
)
def mark_done(self) -> None:
self.completed = True
def is_urgent(self) -> bool:
return self.priority in (
Priority.HIGH, Priority.CRITICAL
)
# การใช้งาน
task = Task(
title="Deploy v2.0",
description="อัพเดทเวอร์ชันใหม่",
priority=Priority.HIGH,
tags=["deploy", "production"],
)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="XMAN API")
class ProductCreate(BaseModel):
name: str
price: float
category: str = "general"
class ProductResponse(ProductCreate):
id: int
@app.get("/products")
async def list_products(
category: str | None = None,
limit: int = 20,
) -> list[ProductResponse]:
query = select(Product)
if category:
query = query.where(
Product.category == category
)
return await db.execute(
query.limit(limit)
)
@app.post("/products", status_code=201)
async def create_product(
data: ProductCreate,
) -> ProductResponse:
product = Product(**data.model_dump())
db.add(product)
await db.commit()
return product
Flutter / Dart
สร้างแอป Cross-Platform จากโค้ดเดียว / One codebase, all platforms
class ProductListScreen extends StatefulWidget {
const ProductListScreen({super.key});
@override
State<ProductListScreen> createState() =>
_ProductListScreenState();
}
class _ProductListScreenState
extends State<ProductListScreen> {
List<Product> _products = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadProducts();
}
Future<void> _loadProducts() async {
final data = await ApiService.getProducts();
if (!mounted) return; // สำคัญมาก!
setState(() {
_products = data;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Center(
child: CircularProgressIndicator(),
);
}
return ListView.builder(
itemCount: _products.length,
itemBuilder: (ctx, i) => ProductCard(
product: _products[i],
onTap: () => _navigateToDetail(_products[i]),
),
);
}
}
import 'package:http/http.dart' as http;
import 'dart:convert';
class ApiService {
static const _baseUrl = 'https://api.xman.studio';
static Future<List<Product>> getProducts({
String? category,
int page = 1,
}) async {
final uri = Uri.parse('$_baseUrl/products')
.replace(queryParameters: {
if (category != null) 'category': category,
'page': page.toString(),
});
final response = await http.get(
uri,
headers: {'Accept': 'application/json'},
);
if (response.statusCode != 200) {
throw ApiException(
'โหลดสินค้าไม่สำเร็จ',
response.statusCode,
);
}
final List data = jsonDecode(
response.body
)['data'];
return data
.map((json) => Product.fromJson(json))
.toList();
}
}
SQL / Database
ภาษาจัดการฐานข้อมูล / Database management & optimization
-- ดึงข้อมูลสินค้าพร้อมหมวดหมู่ (JOIN)
SELECT
p.id,
p.name,
p.price,
c.name AS category_name,
COUNT(r.id) AS review_count,
AVG(r.rating) AS avg_rating
FROM products p
LEFT JOIN categories c
ON p.category_id = c.id
LEFT JOIN reviews r
ON r.product_id = p.id
WHERE p.is_active = 1
GROUP BY p.id, p.name, p.price, c.name
HAVING AVG(r.rating) >= 4.0
ORDER BY avg_rating DESC
LIMIT 20;
-- INSERT พร้อมป้องกัน duplicate
INSERT INTO products (name, slug, price)
VALUES ('สินค้าใหม่', 'new-product', 599.00)
ON DUPLICATE KEY UPDATE
price = VALUES(price);
-- UPDATE พร้อมเงื่อนไขหลายตัว
UPDATE products
SET price = price * 0.9 -- ลด 10%
WHERE category_id = 5
AND is_active = 1;
-- สร้าง Index ที่เหมาะสม
CREATE INDEX idx_products_active_created
ON products (is_active, created_at DESC);
-- CTE (Common Table Expression) — อ่านง่าย
WITH monthly_sales AS (
SELECT
product_id,
SUM(amount) AS total,
COUNT(*) AS orders
FROM order_items
WHERE created_at >= DATE_SUB(
NOW(), INTERVAL 30 DAY
)
GROUP BY product_id
)
SELECT
p.name,
ms.total,
ms.orders,
RANK() OVER(
ORDER BY ms.total DESC
) AS sales_rank
FROM monthly_sales ms
JOIN products p
ON p.id = ms.product_id
ORDER BY sales_rank
LIMIT 10;
Git
ระบบจัดการเวอร์ชัน / Version control system
# สร้าง branch ใหม่จาก main
git checkout -b feature/payment-gateway
# ดูสถานะไฟล์ที่เปลี่ยนแปลง
git status
git diff --staged # ดูเฉพาะที่ staged
# เพิ่มไฟล์ทีละไฟล์ (ปลอดภัยกว่า git add .)
git add app/Services/PaymentService.php
git add tests/PaymentTest.php
# Commit พร้อมข้อความที่ชัดเจน
git commit -m "feat: add Stripe payment gateway
- Add PaymentService with charge/refund
- Add webhook handler for payment events
- Add unit tests for payment flows"
# Push และสร้าง Pull Request
git push -u origin feature/payment-gateway
# อัพเดท branch กับ main (rebase)
git fetch origin
git rebase origin/main
# Stash — เก็บงานชั่วคราว
git stash push -m "WIP: payment form"
git stash pop # เอากลับมา
# ดูประวัติแบบสวยงาม
git log --oneline --graph --all -20
# Cherry-pick — เลือก commit เฉพาะ
git cherry-pick abc1234
# Bisect — หา commit ที่ทำให้พัง
git bisect start
git bisect bad # ตอนนี้พัง
git bisect good v1.0.0 # ตรงนี้ยังดี
# Git จะหา commit ต้นเหตุให้อัตโนมัติ
git bisect reset # เสร็จแล้วรีเซ็ต
# Reflog — กู้ commit ที่หายไป
git reflog
git checkout HEAD@{3} # กลับไปจุดนั้น
# Squash commits (รวม commit ก่อน merge)
git merge --squash feature/xyz
git commit -m "feat: add XYZ feature"
# Blame — ดูว่าใครแก้บรรทัดไหน
git blame app/Models/User.php
# Clean — ลบไฟล์ที่ไม่ได้ track
git clean -fd --dry-run # ดูก่อนลบ
git clean -fd # ลบจริง
REST API Design
ออกแบบ API ที่ดี / Professional API design patterns
# ===== RESTful API Convention =====
GET /api/v1/products # รายการสินค้า
GET /api/v1/products/42 # สินค้า ID 42
POST /api/v1/products # สร้างสินค้าใหม่
PUT /api/v1/products/42 # แก้ไขทั้งหมด
PATCH /api/v1/products/42 # แก้ไขบางส่วน
DELETE /api/v1/products/42 # ลบสินค้า
# Nested Resources
GET /api/v1/products/42/reviews
POST /api/v1/products/42/reviews
# Filtering, Sorting, Pagination
GET /api/v1/products?category=software
&sort=-price
&page=2
&per_page=20
# Response Format (JSON)
{
"data": [...],
"meta": {
"current_page": 2,
"total": 150,
"per_page": 20
},
"links": {
"next": "/api/v1/products?page=3",
"prev": "/api/v1/products?page=1"
}
}
// 200 OK — สำเร็จ
{ "data": { "id": 42, "name": "Product" } }
// 201 Created — สร้างสำเร็จ
{ "data": { ... }, "message": "สร้างเรียบร้อย" }
// 400 Bad Request — ข้อมูลไม่ถูกต้อง
{
"error": {
"code": "VALIDATION_ERROR",
"message": "ข้อมูลไม่ถูกต้อง",
"details": {
"name": ["กรุณากรอกชื่อสินค้า"],
"price": ["ราคาต้องมากกว่า 0"]
}
}
}
// 401 Unauthorized — ไม่ได้ล็อกอิน
{
"error": {
"code": "UNAUTHENTICATED",
"message": "กรุณาเข้าสู่ระบบ"
}
}
// 404 Not Found
{
"error": {
"code": "NOT_FOUND",
"message": "ไม่พบสินค้าที่ต้องการ"
}
}
// 429 Too Many Requests
{
"error": {
"code": "RATE_LIMITED",
"message": "คำขอมากเกินไป กรุณารอสักครู่",
"retry_after": 60
}
}
Docker
Containerization & Deployment / คอนเทนเนอร์สำหรับ Deploy
# Stage 1: Build assets
FROM node:20-alpine AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY vite.config.js tailwind.config.js ./
COPY resources/ resources/
RUN npm run build
# Stage 2: PHP production
FROM php:8.3-fpm-alpine
WORKDIR /var/www
# Install extensions
RUN docker-php-ext-install \
pdo_mysql bcmath opcache
# Install Composer
COPY --from=composer:latest \
/usr/bin/composer /usr/bin/composer
# Install dependencies (no dev)
COPY composer.* ./
RUN composer install --no-dev \
--optimize-autoloader --no-scripts
# Copy application code
COPY . .
COPY --from=assets /app/public_html/build \
public_html/build
# Optimize Laravel
RUN php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache
EXPOSE 9000
CMD ["php-fpm"]
services:
app:
build: .
volumes:
- ./storage:/var/www/storage
depends_on:
- mysql
- redis
environment:
DB_HOST: mysql
REDIS_HOST: redis
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- app
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: xmanstudio
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
mysql_data:
LINE OA — Messaging API
เชื่อมต่อ LINE Official Account กับ Laravel / LINE Notification & Chatbot Integration
ขั้นตอนภาพรวม — Overview Steps
ต้องมี: LINE Official Account (ฟรี), LINE Developers Console, Laravel 10+, PHP 8.1+, HTTPS domain (สำหรับ Webhook)
# =============================================
# LINE OA — Messaging API Configuration
# =============================================
# ได้จาก https://developers.line.biz/console/
# → เลือก Provider → เลือก Channel
# =============================================
# Channel Secret (Basic settings tab)
LINE_CHANNEL_SECRET=your_channel_secret_here
# Channel Access Token (Messaging API tab)
# กด "Issue" เพื่อสร้าง Long-lived token
LINE_CHANNEL_ACCESS_TOKEN=your_channel_access_token_here
# (Optional) สำหรับ LINE Login
LINE_LOGIN_CHANNEL_ID=your_login_channel_id
LINE_LOGIN_CHANNEL_SECRET=your_login_channel_secret
LINE_LOGIN_CALLBACK_URL=https://yoursite.com/auth/line/callback
// เพิ่มใน config/services.php
'line' => [
'channel_secret' => env('LINE_CHANNEL_SECRET'),
'channel_access_token' => env('LINE_CHANNEL_ACCESS_TOKEN'),
'api_endpoint' => 'https://api.line.me/v2/bot',
],
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LineService
{
private string $accessToken;
private string $channelSecret;
private string $apiBase;
public function __construct()
{
$this->accessToken = config('services.line.channel_access_token');
$this->channelSecret = config('services.line.channel_secret');
$this->apiBase = config('services.line.api_endpoint');
}
// ======================================
// Push Message — ส่งข้อความหาคนเฉพาะเจาะจง
// ======================================
public function pushMessage(
string $userId,
array $messages,
): bool {
return $this->sendRequest('/message/push', [
'to' => $userId,
'messages' => $messages,
]);
}
// ======================================
// Reply Message — ตอบกลับจาก Webhook
// ======================================
public function replyMessage(
string $replyToken,
array $messages,
): bool {
return $this->sendRequest('/message/reply', [
'replyToken' => $replyToken,
'messages' => $messages,
]);
}
// ======================================
// Multicast — ส่งหาหลายคนพร้อมกัน
// (สูงสุด 500 userId ต่อครั้ง)
// ======================================
public function multicast(
array $userIds,
array $messages,
): bool {
return $this->sendRequest('/message/multicast', [
'to' => $userIds,
'messages' => $messages,
]);
}
// ======================================
// Broadcast — ส่งหาทุกคนที่ add เพื่อน
// ======================================
public function broadcast(array $messages): bool
{
return $this->sendRequest('/message/broadcast', [
'messages' => $messages,
]);
}
// ======================================
// ดึงข้อมูลโปรไฟล์ผู้ใช้
// ======================================
public function getProfile(string $userId): ?array
{
$response = Http::withToken($this->accessToken)
->get("{$this->apiBase}/profile/{$userId}");
return $response->successful()
? $response->json()
: null;
}
// ======================================
// ตรวจสอบ Signature จาก Webhook
// (สำคัญมาก! ป้องกัน Spoofing)
// ======================================
public function validateSignature(
string $body,
string $signature,
): bool {
$hash = hash_hmac(
'sha256',
$body,
$this->channelSecret,
true
);
return hash_equals(
$signature,
base64_encode($hash)
);
}
// ======================================
// Private: ส่ง HTTP Request ไป LINE API
// ======================================
private function sendRequest(
string $endpoint,
array $data,
): bool {
$response = Http::withToken($this->accessToken)
->post($this->apiBase . $endpoint, $data);
if ($response->failed()) {
Log::error('LINE API Error', [
'endpoint' => $endpoint,
'status' => $response->status(),
'body' => $response->json(),
]);
}
return $response->successful();
}
}
<?php
namespace App\Http\Controllers;
use App\Services\LineService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class LineWebhookController extends Controller
{
public function __construct(
private readonly LineService $line,
) {}
public function handle(Request $request): JsonResponse
{
// 1) ตรวจสอบ Signature (ป้องกัน Spoofing)
$signature = $request->header('X-Line-Signature', '');
$body = $request->getContent();
if (!$this->line->validateSignature($body, $signature)) {
Log::warning('LINE Webhook: Invalid signature');
return response()->json(['error' => 'Invalid'], 403);
}
// 2) วนลูปรับ Event ทั้งหมด
$events = $request->input('events', []);
foreach ($events as $event) {
match ($event['type']) {
'message' => $this->handleMessage($event),
'follow' => $this->handleFollow($event),
'unfollow' => $this->handleUnfollow($event),
'postback' => $this->handlePostback($event),
default => Log::info('LINE event: ' . $event['type']),
};
}
return response()->json(['status' => 'ok']);
}
// =============================
// จัดการข้อความที่ส่งเข้ามา
// =============================
private function handleMessage(array $event): void
{
$replyToken = $event['replyToken'];
$userId = $event['source']['userId'];
$msgType = $event['message']['type'];
$text = $event['message']['text'] ?? '';
// ตอบตามคำสั่ง
$reply = match (mb_strtolower(trim($text))) {
'สวัสดี', 'hello' => $this->textMsg(
'สวัสดีครับ! 👋 ยินดีต้อนรับสู่ XMAN Studio'
),
'เมนู', 'menu' => $this->menuMessage(),
'ราคา', 'price' => $this->textMsg(
"💰 ราคาบริการ\n\n"
. "🔹 เว็บไซต์ เริ่มต้น 15,000฿\n"
. "🔹 แอปมือถือ เริ่มต้น 30,000฿\n"
. "🔹 ระบบ AI เริ่มต้น 50,000฿\n\n"
. "📞 ติดต่อเพิ่มเติม: พิมพ์ 'ติดต่อ'"
),
'ติดต่อ', 'contact' => $this->textMsg(
"📞 ช่องทางติดต่อ\n\n"
. "🌐 เว็บ: xman.studio\n"
. "📧 อีเมล: [email protected]"
),
default => $this->textMsg(
"ขอบคุณสำหรับข้อความครับ 🙏\n"
. "พิมพ์ 'เมนู' เพื่อดูรายการคำสั่ง"
),
};
$this->line->replyMessage($replyToken, [$reply]);
}
// มีคนเพิ่มเพื่อน (follow)
private function handleFollow(array $event): void
{
$userId = $event['source']['userId'];
$profile = $this->line->getProfile($userId);
// บันทึกลง Database
LineFollower::updateOrCreate(
['line_user_id' => $userId],
[
'display_name' => $profile['displayName'] ?? null,
'picture_url' => $profile['pictureUrl'] ?? null,
'status' => 'active',
],
);
// ส่งข้อความต้อนรับ
$this->line->replyMessage($event['replyToken'], [
$this->textMsg(
"🎉 ยินดีต้อนรับสู่ XMAN Studio!\n\n"
. "พิมพ์ 'เมนู' เพื่อดูสิ่งที่เราทำได้"
),
]);
}
// มีคน unfollow
private function handleUnfollow(array $event): void
{
LineFollower::where('line_user_id', $event['source']['userId'])
->update(['status' => 'inactive']);
}
// Postback จาก Flex Message / Rich Menu
private function handlePostback(array $event): void
{
parse_str($event['postback']['data'], $params);
match ($params['action'] ?? '') {
'view_order' => $this->sendOrderDetail($event, $params),
'track' => $this->sendTrackingInfo($event, $params),
default => null,
};
}
// Helper: สร้าง Text Message Object
private function textMsg(string $text): array
{
return ['type' => 'text', 'text' => $text];
}
}
// routes/api.php
// API routes จะไม่มี CSRF token อยู่แล้ว
use App\Http\Controllers\LineWebhookController;
Route::post(
'/webhook/line',
[LineWebhookController::class, 'handle']
)->name('webhook.line');
// ถ้าใช้ routes/web.php แทน api.php
// ต้องยกเว้น CSRF สำหรับ Webhook URL
// ======================================
// Laravel 11 — bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(
except: [
'webhook/line',
'webhook/*', // หรือยกเว้นทั้งหมด
]
);
})
// ======================================
// Laravel 10 — app/Http/Middleware/
// VerifyCsrfToken.php
// ======================================
protected $except = [
'webhook/line',
];
ตัวอย่าง:
https://yoursite.com/api/webhook/lineสำหรับทดสอบในเครื่อง ใช้ ngrok:
ngrok http 8000
// ======================================
// 1) Text Message — ข้อความธรรมดา
// ======================================
$textMsg = [
'type' => 'text',
'text' => 'สวัสดีครับ! 🎉',
];
// ======================================
// 2) Text + Emoji
// ======================================
$emojiMsg = [
'type' => 'text',
'text' => '$ สั่งซื้อสำเร็จ $ ขอบคุณครับ',
'emojis' => [
['index' => 0, 'productId' => '5ac1bfd5040ab15980c9b435',
'emojiId' => '001'],
['index' => 12, 'productId' => '5ac1bfd5040ab15980c9b435',
'emojiId' => '002'],
],
];
// ======================================
// 3) Image Message — ส่งรูปภาพ
// ======================================
$imageMsg = [
'type' => 'image',
'originalContentUrl' => 'https://yoursite.com/img/promo.jpg',
'previewImageUrl' => 'https://yoursite.com/img/promo-sm.jpg',
];
// ======================================
// 4) Sticker Message — สติกเกอร์
// ======================================
$stickerMsg = [
'type' => 'sticker',
'packageId' => '446',
'stickerId' => '1988',
];
// ======================================
// 5) Quick Reply — ปุ่มตอบด่วน
// ======================================
$quickReply = [
'type' => 'text',
'text' => 'เลือกสิ่งที่ต้องการ:',
'quickReply' => [
'items' => [
[
'type' => 'action',
'action' => [
'type' => 'message',
'label' => '💰 ดูราคา',
'text' => 'ราคา',
],
],
[
'type' => 'action',
'action' => [
'type' => 'message',
'label' => '📞 ติดต่อ',
'text' => 'ติดต่อ',
],
],
[
'type' => 'action',
'action' => [
'type' => 'uri',
'label' => '🌐 เว็บไซต์',
'uri' => 'https://xman.studio',
],
],
],
],
];
// สร้าง Flex Message ใบเสร็จคำสั่งซื้อ
public function orderReceiptFlex(Order $order): array
{
// รายการสินค้า
$items = $order->items->map(fn ($item) => [
'type' => 'box',
'layout' => 'horizontal',
'contents' => [
[
'type' => 'text',
'text' => $item->name,
'size' => 'sm',
'color' => '#555555',
'flex' => 0,
],
[
'type' => 'text',
'text' => "฿" . number_format($item->price, 2),
'size' => 'sm',
'color' => '#111111',
'align' => 'end',
],
],
])->toArray();
return [
'type' => 'flex',
'altText' => "ใบเสร็จ #{$order->order_number}",
'contents' => [
'type' => 'bubble',
'size' => 'mega',
// === Header ===
'header' => [
'type' => 'box',
'layout' => 'vertical',
'backgroundColor' => '#0ea5e9',
'paddingAll' => '20px',
'contents' => [
[
'type' => 'text',
'text' => 'XMAN STUDIO',
'color' => '#ffffff',
'size' => 'lg',
'weight' => 'bold',
],
[
'type' => 'text',
'text' => 'ใบเสร็จรับเงิน',
'color' => '#ffffffcc',
'size' => 'xs',
],
],
],
// === Body (รายการสินค้า) ===
'body' => [
'type' => 'box',
'layout' => 'vertical',
'contents' => [
[
'type' => 'text',
'text' => "Order #{$order->order_number}",
'weight' => 'bold',
'size' => 'xl',
],
['type' => 'separator', 'margin' => 'lg'],
// รายการสินค้า
[
'type' => 'box',
'layout' => 'vertical',
'margin' => 'lg',
'spacing' => 'sm',
'contents' => $items,
],
['type' => 'separator', 'margin' => 'lg'],
// ยอมรวม
[
'type' => 'box',
'layout' => 'horizontal',
'margin' => 'lg',
'contents' => [
[
'type' => 'text',
'text' => 'รวมทั้งหมด',
'size' => 'md',
'weight' => 'bold',
],
[
'type' => 'text',
'text' => "฿" . number_format(
$order->total, 2
),
'size' => 'lg',
'weight' => 'bold',
'color' => '#0ea5e9',
'align' => 'end',
],
],
],
],
],
// === Footer (ปุ่มกด) ===
'footer' => [
'type' => 'box',
'layout' => 'vertical',
'spacing' => 'sm',
'contents' => [
[
'type' => 'button',
'style' => 'primary',
'color' => '#0ea5e9',
'action' => [
'type' => 'uri',
'label' => 'ดูรายละเอียดคำสั่งซื้อ',
'uri' => route(
'orders.show',
$order
),
],
],
],
],
],
];
}
<?php
namespace App\Notifications;
use App\Channels\LineChannel;
use Illuminate\Notifications\Notification;
class OrderPaidNotification extends Notification
{
public function __construct(
public readonly Order $order,
) {}
// กำหนดช่องทางส่ง
public function via($notifiable): array
{
return ['mail', LineChannel::class];
}
// ข้อความสำหรับ LINE
public function toLine($notifiable): array
{
return [
[
'type' => 'text',
'text' => "✅ ชำระเงินสำเร็จ!\n\n"
. "📦 คำสั่งซื้อ: #{$this->order->order_number}\n"
. "💰 ยอดชำระ: ฿" . number_format(
$this->order->total, 2
) . "\n"
. "📅 วันที่: " . now()->format('d/m/Y H:i')
. "\n\nขอบคุณที่ใช้บริการครับ 🙏",
],
];
}
}
<?php
namespace App\Channels;
use App\Services\LineService;
use Illuminate\Notifications\Notification;
class LineChannel
{
public function __construct(
private readonly LineService $line,
) {}
public function send(
$notifiable,
Notification $notification,
): void {
// ดึง LINE User ID จาก Model
$lineUserId = $notifiable->routeNotificationFor(
'line'
);
if (!$lineUserId) {
return;
}
$messages = $notification->toLine($notifiable);
$this->line->pushMessage($lineUserId, $messages);
}
}
// เพิ่มใน User Model
public function routeNotificationForLine(): ?string
{
return $this->line_user_id; // คอลัมน์ในตาราง users
}
// การใช้งาน
$user->notify(new OrderPaidNotification($order));
// ======================================
// สร้าง Rich Menu (ทำครั้งเดียว)
// ======================================
public function createRichMenu(): ?string
{
$response = Http::withToken($this->accessToken)
->post('{$this->apiBase}/richmenu', [
'size' => [
'width' => 2500,
'height' => 1686, // หรือ 843
],
'selected' => true,
'name' => 'Main Menu',
'chatBarText' => 'เมนูหลัก',
'areas' => [
// ปุ่มซ้ายบน — ดูสินค้า
[
'bounds' => [
'x' => 0, 'y' => 0,
'width' => 833, 'height' => 843,
],
'action' => [
'type' => 'uri',
'uri' => 'https://yoursite.com/products',
'label' => 'ดูสินค้า',
],
],
// ปุ่มกลางบน — ดูราคา
[
'bounds' => [
'x' => 833, 'y' => 0,
'width' => 834, 'height' => 843,
],
'action' => [
'type' => 'message',
'text' => 'ราคา',
'label' => 'ดูราคา',
],
],
// ปุ่มขวาบน — ติดต่อเรา
[
'bounds' => [
'x' => 1667, 'y' => 0,
'width' => 833, 'height' => 843,
],
'action' => [
'type' => 'message',
'text' => 'ติดต่อ',
'label' => 'ติดต่อเรา',
],
],
],
]);
return $response->json('richMenuId');
}
// อัพโหลดรูป Rich Menu (2500x1686 px)
public function uploadRichMenuImage(
string $richMenuId,
string $imagePath,
): bool {
$response = Http::withToken($this->accessToken)
->withHeaders([
'Content-Type' => 'image/png',
])
->withBody(
file_get_contents($imagePath), 'image/png'
)
->post(
"https://api-data.line.me/v2/bot/richmenu"
. "/{$richMenuId}/content"
);
return $response->successful();
}
// ตั้งเป็น Default สำหรับทุกคน
public function setDefaultRichMenu(
string $richMenuId,
): bool {
return Http::withToken($this->accessToken)
->post(
"{$this->apiBase}/user/all/richmenu/{$richMenuId}"
)->successful();
}
<?php
namespace App\Listeners;
use App\Events\OrderPaid;
use App\Services\LineService;
use Illuminate\Contracts\Queue\ShouldQueue;
// ใช้ Queue เพื่อไม่ให้ block ผู้ใช้
class SendLineOrderNotification
implements ShouldQueue
{
public int $tries = 3;
public int $backoff = 10; // วินาที
public function __construct(
private readonly LineService $line,
) {}
public function handle(OrderPaid $event): void
{
$order = $event->order;
$user = $order->user;
// ส่งแจ้งลูกค้า
if ($user->line_user_id) {
$this->line->pushMessage(
$user->line_user_id,
[$this->orderReceiptFlex($order)]
);
}
// ส่งแจ้ง Admin (Broadcast กลุ่ม admin)
$adminLineId = config('services.line.admin_user_id');
if ($adminLineId) {
$this->line->pushMessage($adminLineId, [
[
'type' => 'text',
'text' => "🔔 คำสั่งซื้อใหม่!\n\n"
. "📦 #{$order->order_number}\n"
. "👤 {$user->name}\n"
. "💰 ฿" . number_format(
$order->total, 2
),
],
]);
}
}
}
// ลงทะเบียน Event → Listener
protected $listen = [
OrderPaid::class => [
SendLineOrderNotification::class,
SendEmailReceipt::class,
UpdateInventory::class,
],
OrderShipped::class => [
SendLineShippingNotification::class,
],
LicenseActivated::class => [
SendLineLicenseInfo::class,
],
];
// ======================================
// LINE Notify — ส่งแจ้งเตือนแบบง่ายที่สุด
// ขอ Token: https://notify-bot.line.me/
// ======================================
// .env
// LINE_NOTIFY_TOKEN=your_token_here
class LineNotifyService
{
private const API_URL =
'https://notify-api.line.me/api/notify';
public function send(
string $message,
?string $imageUrl = null,
?string $stickerPackageId = null,
?string $stickerId = null,
): bool {
$data = ['message' => $message];
if ($imageUrl) {
$data['imageThumbnail'] = $imageUrl;
$data['imageFullsize'] = $imageUrl;
}
if ($stickerPackageId && $stickerId) {
$data['stickerPackageId'] = $stickerPackageId;
$data['stickerId'] = $stickerId;
}
return Http::asForm()
->withToken(config('services.line.notify_token'))
->post(self::API_URL, $data)
->successful();
}
}
// ======================================
// ตัวอย่างการใช้งาน
// ======================================
$notify = app(LineNotifyService::class);
// แจ้งข้อความธรรมดา
$notify->send("\n🛒 คำสั่งซื้อใหม่ #1234\n💰 ฿5,990");
// แจ้งพร้อมรูปภาพ
$notify->send(
"\n📦 สินค้าจัดส่งแล้ว",
imageUrl: 'https://yoursite.com/tracking.jpg',
);
// แจ้งพร้อมสติกเกอร์
$notify->send(
"\n✅ ชำระเงินเรียบร้อย",
stickerPackageId: '446',
stickerId: '1988',
);
<?php
namespace App\Console\Commands;
use App\Services\LineService;
use Illuminate\Console\Command;
class LineBroadcast extends Command
{
protected $signature =
'line:broadcast {message : ข้อความที่จะส่ง}';
protected $description =
'ส่งข้อความ LINE ถึงผู้ติดตามทุกคน';
public function handle(LineService $line): int
{
$message = $this->argument('message');
if (!$this->confirm("ส่ง: \"{$message}\" ?")) {
$this->info('ยกเลิก');
return 0;
}
$success = $line->broadcast([
['type' => 'text', 'text' => $message],
]);
$success
? $this->info('✅ ส่งเรียบร้อย!')
: $this->error('❌ ส่งไม่สำเร็จ');
return $success ? 0 : 1;
}
}
// ======================================
// ใช้งาน:
// php artisan line:broadcast "สวัสดีปีใหม่!"
// ======================================
class LineExpiryReminder extends Command
{
protected $signature = 'line:expiry-reminder';
protected $description = 'แจ้ง License ใกล้หมดอายุ';
public function handle(LineService $line): int
{
// หา License ที่จะหมดอายุใน 7 วัน
$expiring = License::with('user')
->where('status', 'active')
->whereBetween('expires_at', [
now(),
now()->addDays(7),
])
->get();
foreach ($expiring as $license) {
$user = $license->user;
if (!$user->line_user_id) continue;
$days = now()->diffInDays($license->expires_at);
$line->pushMessage($user->line_user_id, [
[
'type' => 'text',
'text' => "⏰ แจ้งเตือน License ใกล้หมดอายุ\n\n"
. "🔑 {$license->product->name}\n"
. "📅 หมดอายุอีก {$days} วัน\n"
. "({$license->expires_at->format('d/m/Y')})\n\n"
. "ต่ออายุได้ที่ xman.studio/licenses",
],
]);
$this->info("ส่งถึง {$user->name}");
}
$this->info("ส่งทั้งหมด {$expiring->count()} คน");
return 0;
}
}
// ตั้งเวลาให้ทำงานทุกวันตอน 9 โมงเช้า
Schedule::command('line:expiry-reminder')
->dailyAt('09:00')
->timezone('Asia/Bangkok');
// รายงานยอดขายรายวันให้ Admin
Schedule::command('line:daily-report')
->dailyAt('20:00')
->timezone('Asia/Bangkok');
// ======================================
// อย่าลืม! ตั้ง cron บน server:
// * * * * * cd /path && php artisan schedule:run
// ======================================
สรุป: เลือกใช้วิธีไหนดี? / Which approach to use?
- - แจ้งเตือนทีมงาน
- - Server monitoring
- - ไม่ต้องมี OA
- - ส่งได้ 1,000 ครั้ง/ชม.
- - แจ้งลูกค้ารายคน
- - Flex Message สวยๆ
- - Rich Menu
- - Push 500 msg/req
- - ตอบกลับอัตโนมัติ
- - Chatbot สั่งงานได้
- - ฟรี! ไม่จำกัดจำนวน
- - ต้องมี HTTPS domain
Apps Script + ระบบจองคิว + LINE
ระบบจองคิวออนไลน์ผ่าน Google Forms/Sheets + แจ้งเตือน LINE อัตโนมัติ
Flow ทั้งระบบ — End-to-End Architecture
(เว็บ / Google Forms)
(หรือ Database)
ดักจับข้อมูลใหม่
(ลูกค้า + แอดมิน)
(Cron / Time Trigger)
เหมาะสำหรับ: คลินิก, ร้านเสริมสวย, ร้านซ่อม, ร้านอาหาร, สำนักงาน — ทุกธุรกิจที่มีระบบจองคิว
// =============================================
// Google Apps Script — ส่งแจ้งเตือน LINE
// เมื่อมีการจองคิวใหม่จาก Google Forms
// =============================================
// ⚙️ ตั้งค่า — แก้ไขตรงนี้
const LINE_NOTIFY_TOKEN = 'YOUR_LINE_NOTIFY_TOKEN';
const LINE_NOTIFY_URL = 'https://notify-api.line.me/api/notify';
/**
* Trigger: ทำงานเมื่อมีการส่ง Google Form
* ตั้งค่า: Extensions → Apps Script → Triggers
* → เลือก onFormSubmit → From spreadsheet
* → On form submit
*/
function onFormSubmit(e) {
const values = e.values;
const timestamp = values[0]; // Timestamp
const name = values[1]; // ชื่อผู้จอง
const phone = values[2]; // เบอร์โทร
const service = values[3]; // บริการที่เลือก
const date = values[4]; // วันที่จอง
const time = values[5]; // เวลาที่จอง
const note = values[6] || '-'; // หมายเหตุ
// สร้างหมายเลขคิว
const sheet = SpreadsheetApp.getActiveSheet();
const queueNo = 'Q-' + String(
sheet.getLastRow()
).padStart(4, '0');
// เขียนหมายเลขคิวกลับลง Sheet (คอลัมน์ H)
sheet.getRange(
sheet.getLastRow(), 8
).setValue(queueNo);
// สร้างข้อความ LINE
const message = `
🔔 การจองคิวใหม่!
📋 หมายเลขคิว: ${queueNo}
👤 ชื่อ: ${name}
📱 โทร: ${phone}
💼 บริการ: ${service}
📅 วันที่: ${date}
⏰ เวลา: ${time}
📝 หมายเหตุ: ${note}
⏱️ จองเมื่อ: ${timestamp}`;
// ส่ง LINE Notify
sendLineNotify(message);
// (Optional) ส่ง Email ยืนยันให้ลูกค้า
const email = values[7]; // ถ้ามีช่อง email
if (email) {
MailApp.sendEmail({
to: email,
subject: `ยืนยันการจองคิว ${queueNo}`,
body: `สวัสดีคุณ ${name}\n\n`
+ `หมายเลขคิวของคุณคือ: ${queueNo}\n`
+ `บริการ: ${service}\n`
+ `วันที่: ${date} เวลา ${time}\n\n`
+ `ขอบคุณที่ใช้บริการครับ`,
});
}
}
/**
* ส่งข้อความผ่าน LINE Notify
* @param {string} message ข้อความที่จะส่ง
* @param {string} [imageUrl] URL รูปภาพ (optional)
* @returns {boolean} สำเร็จหรือไม่
*/
function sendLineNotify(message, imageUrl) {
const payload = { message: message };
if (imageUrl) {
payload.imageThumbnail = imageUrl;
payload.imageFullsize = imageUrl;
}
const options = {
method: 'post',
headers: {
'Authorization': `Bearer ${LINE_NOTIFY_TOKEN}`,
},
payload: payload,
muteHttpExceptions: true,
};
try {
const response = UrlFetchApp.fetch(
LINE_NOTIFY_URL, options
);
const code = response.getResponseCode();
if (code !== 200) {
Logger.log(
`LINE Error: ${code} - ${response.getContentText()}`
);
return false;
}
Logger.log('LINE Notify ส่งสำเร็จ');
return true;
} catch (error) {
Logger.log(`LINE Error: ${error.message}`);
return false;
}
}
/**
* ส่งข้อความผ่าน LINE Messaging API (Push)
* สำหรับส่งหาลูกค้ารายคนที่รู้ userId
*/
function sendLinePush(userId, message) {
const ACCESS_TOKEN =
'YOUR_CHANNEL_ACCESS_TOKEN';
const payload = {
to: userId,
messages: [
{ type: 'text', text: message }
],
};
UrlFetchApp.fetch(
'https://api.line.me/v2/bot/message/push',
{
method: 'post',
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${ACCESS_TOKEN}`
},
payload: JSON.stringify(payload),
}
);
}
/**
* ส่งเตือนคิวที่จะมาถึงใน 1 ชั่วโมง
* ตั้ง Time-driven trigger: ทุก 30 นาที
*
* Triggers → Add Trigger:
* Function: checkUpcomingQueues
* Event: Time-driven → Minutes timer → 30 min
*/
function checkUpcomingQueues() {
const sheet = SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName('การจอง');
const data = sheet.getDataRange().getValues();
const now = new Date();
// เริ่มจากแถว 2 (ข้ามหัวตาราง)
for (let i = 1; i < data.length; i++) {
const row = data[i];
const name = row[1]; // ชื่อ
const phone = row[2]; // เบอร์
const service = row[3]; // บริการ
const dateStr = row[4]; // วันที่
const timeStr = row[5]; // เวลา
const queueNo = row[7]; // หมายเลขคิว
const reminded = row[8]; // เคยเตือนแล้ว?
if (reminded === '✅') continue; // ข้าม
// รวมวันที่+เวลา
const appointmentDate = parseDateTime(
dateStr, timeStr
);
if (!appointmentDate) continue;
// คำนวณเวลาที่เหลือ (นาที)
const diffMs = appointmentDate - now;
const diffMin = diffMs / 60000;
// เตือนเมื่อเหลืออีก 30-90 นาที
if (diffMin > 30 && diffMin <= 90) {
const msg = `
⏰ แจ้งเตือนคิวของคุณ!
📋 คิว: ${queueNo}
👤 คุณ ${name}
💼 บริการ: ${service}
📅 ${dateStr} เวลา ${timeStr}
🕐 อีกประมาณ ${Math.round(diffMin)} นาที
กรุณามาถึงก่อนเวลา 10 นาทีครับ 🙏`;
sendLineNotify(msg);
// บันทึกว่าเตือนแล้ว (คอลัมน์ I)
sheet.getRange(i + 1, 9).setValue('✅');
}
}
}
/**
* Parse วันที่ + เวลา → Date object
* รองรับ: "31/03/2026", "14:30"
*/
function parseDateTime(dateStr, timeStr) {
try {
const [d, m, y] = String(dateStr).split('/');
const [h, min] = String(timeStr).split(':');
return new Date(y, m - 1, d, h, min);
} catch (e) {
return null;
}
}
/**
* สรุปการจองของวันถัดไป
* ตั้ง Trigger: Time-driven → Day timer → 8am-9am
*/
function sendDailySummary() {
const sheet = SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName('การจอง');
const data = sheet.getDataRange().getValues();
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tmrStr = Utilities.formatDate(
tomorrow,
'Asia/Bangkok',
'dd/MM/yyyy'
);
// กรองเฉพาะจองของพรุ่งนี้
const bookings = data.filter((row, i) =>
i > 0 && formatDate(row[4]) === tmrStr
);
if (bookings.length === 0) {
sendLineNotify(
`\n📅 สรุปวันพรุ่งนี้ (${tmrStr})\n\n`
+ `✨ ไม่มีการจอง — วันว่าง!`
);
return;
}
// จัดเรียงตามเวลา
bookings.sort((a, b) =>
String(a[5]).localeCompare(String(b[5]))
);
// สร้างข้อความ
let msg = `\n📅 สรุปวันพรุ่งนี้ (${tmrStr})`
+ `\n📊 ทั้งหมด ${bookings.length} คิว\n`;
bookings.forEach((row, idx) => {
msg += `\n${idx + 1}. ⏰ ${row[5]}`
+ ` | 👤 ${row[1]}`
+ ` | 💼 ${row[3]}`;
});
sendLineNotify(msg);
}
// Helper: format Date object เป็น dd/MM/yyyy
function formatDate(date) {
if (date instanceof Date) {
return Utilities.formatDate(
date, 'Asia/Bangkok', 'dd/MM/yyyy'
);
}
return String(date);
}
/**
* Deploy → Manage deployments → New → Web app
* Execute as: Me
* Who has access: Anyone
*
* เว็บไซต์สามารถเรียก POST ไปที่ URL นี้
* เพื่อจองคิวโดยไม่ต้องผ่าน Google Forms
*/
function doPost(e) {
try {
const data = JSON.parse(e.postData.contents);
const sheet = SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName('การจอง');
// สร้างหมายเลขคิว
const queueNo = 'Q-' + String(
sheet.getLastRow() + 1
).padStart(4, '0');
// เขียนลง Sheet
sheet.appendRow([
new Date(), // Timestamp
data.name, // ชื่อ
data.phone, // เบอร์โทร
data.service, // บริการ
data.date, // วันที่
data.time, // เวลา
data.note || '', // หมายเหตุ
queueNo, // หมายเลขคิว
]);
// แจ้งเตือน LINE
sendLineNotify(`
🔔 จองคิวใหม่จากเว็บไซต์!
📋 คิว: ${queueNo}
👤 ${data.name}
📱 ${data.phone}
💼 ${data.service}
📅 ${data.date} ⏰ ${data.time}`);
// ส่งกลับ JSON
return ContentService
.createTextOutput(JSON.stringify({
success: true,
queueNo: queueNo,
message: 'จองคิวสำเร็จ',
}))
.setMimeType(
ContentService.MimeType.JSON
);
} catch (error) {
return ContentService
.createTextOutput(JSON.stringify({
success: false,
message: error.message,
}))
.setMimeType(
ContentService.MimeType.JSON
);
}
}
// GET — ดูคิวของวันนี้ (JSON)
function doGet(e) {
const date = e.parameter.date
|| Utilities.formatDate(
new Date(), 'Asia/Bangkok', 'dd/MM/yyyy'
);
const sheet = SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName('การจอง');
const data = sheet.getDataRange().getValues();
const queues = data
.filter((r, i) =>
i > 0 && formatDate(r[4]) === date
)
.map(r => ({
queue: r[7],
name: r[1],
service: r[3],
time: r[5],
}));
return ContentService
.createTextOutput(JSON.stringify({
date, total: queues.length, queues,
}))
.setMimeType(
ContentService.MimeType.JSON
);
}
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class QueueBookingService
{
private string $appsScriptUrl;
public function __construct()
{
// URL ของ Apps Script Web App
$this->appsScriptUrl = config(
'services.google.apps_script_url'
);
}
/**
* จองคิวจากเว็บไซต์
* → ส่งไป Google Sheets + แจ้ง LINE อัตโนมัติ
*/
public function createBooking(array $data): array
{
$response = Http::timeout(15)
->post($this->appsScriptUrl, [
'name' => $data['name'],
'phone' => $data['phone'],
'service' => $data['service'],
'date' => $data['date'],
'time' => $data['time'],
'note' => $data['note'] ?? '',
]);
if ($response->failed()) {
Log::error('Queue booking failed', [
'status' => $response->status(),
]);
return [
'success' => false,
'message' => 'จองคิวไม่สำเร็จ กรุณาลองใหม่',
];
}
return $response->json();
}
/**
* ดึงคิววันนี้ (สำหรับแสดงผลบนเว็บ)
*/
public function getTodayQueues(): array
{
$today = now()->format('d/m/Y');
$response = Http::timeout(10)
->get($this->appsScriptUrl, [
'date' => $today,
]);
return $response->successful()
? $response->json()
: ['total' => 0, 'queues' => []];
}
}
class QueueBookingController extends Controller
{
public function __construct(
private readonly QueueBookingService $booking,
private readonly LineService $line,
) {}
// หน้าฟอร์มจองคิว
public function create()
{
$todayQueues = $this->booking->getTodayQueues();
return view('queue.create', [
'todayTotal' => $todayQueues['total'],
]);
}
// บันทึกการจอง
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'phone' => 'required|string|max:20',
'service' => 'required|string',
'date' => 'required|date|after:today',
'time' => 'required|string',
'note' => 'nullable|string|max:500',
]);
// ส่งไป Google Sheets (ผ่าน Apps Script)
$result = $this->booking->createBooking($validated);
if (!$result['success']) {
return back()
->withErrors(['error' => $result['message']])
->withInput();
}
// (Optional) ส่ง LINE Push ตรงถึงลูกค้า
// ถ้าลูกค้าเชื่อม LINE กับ Account
if (auth()->user()?->line_user_id) {
$this->line->pushMessage(
auth()->user()->line_user_id,
[[
'type' => 'text',
'text' => "✅ จองคิวสำเร็จ!\n\n"
. "📋 คิว: {$result['queueNo']}\n"
. "💼 {$validated['service']}\n"
. "📅 {$validated['date']}\n"
. "⏰ {$validated['time']}\n\n"
. "เราจะแจ้งเตือนก่อนถึงคิวครับ 🔔",
]],
);
}
return redirect()
->route('queue.success')
->with('queueNo', $result['queueNo']);
}
}
╔══════════════════════════════════════════╗
║ ขั้นตอนที่ 1: ขอ LINE Notify Token ║
╚══════════════════════════════════════════╝
1. ไปที่ https://notify-bot.line.me/
2. Login ด้วย LINE Account
3. My Page → Generate Token
4. เลือกห้องแชทที่จะรับแจ้งเตือน
- "1-on-1 chat" = ส่งมาหาตัวเอง
- เลือก Group = ส่งเข้ากลุ่ม
5. Copy Token เก็บไว้
╔══════════════════════════════════════════╗
║ ขั้นตอนที่ 2: สร้าง Google Sheets ║
╚══════════════════════════════════════════╝
1. สร้าง Google Sheets ใหม่
2. ตั้งชื่อ Sheet = "การจอง"
3. สร้างหัวตาราง Row 1:
A: Timestamp
B: ชื่อ
C: เบอร์โทร
D: บริการ
E: วันที่
F: เวลา
G: หมายเหตุ
H: หมายเลขคิว
I: แจ้งเตือน
╔══════════════════════════════════════════╗
║ ขั้นตอนที่ 3: เพิ่ม Apps Script ║
╚══════════════════════════════════════════╝
1. Extensions → Apps Script
2. ลบโค้ดเดิม → วาง Code.gs จากด้านบน
3. แก้ LINE_NOTIFY_TOKEN = Token ของคุณ
4. กด Save (Ctrl+S)
╔══════════════════════════════════════════╗
║ ขั้นตอนที่ 4: ตั้ง Trigger ║
╚══════════════════════════════════════════╝
1. คลิก ⏰ (Triggers) ที่เมนูซ้าย
2. + Add Trigger:
Function: onFormSubmit
Event source: From spreadsheet
Event type: On form submit
3. + Add Trigger อีกตัว:
Function: checkUpcomingQueues
Event source: Time-driven
Type: Minutes timer → 30 minutes
4. + Add Trigger อีกตัว:
Function: sendDailySummary
Event source: Time-driven
Type: Day timer → 8am to 9am
╔══════════════════════════════════════════╗
║ ขั้นตอนที่ 5: (Optional) Deploy Web App ║
╚══════════════════════════════════════════╝
1. Deploy → New deployment
2. Type: Web app
3. Execute as: Me
4. Who has access: Anyone
5. Copy URL → ใส่ใน Laravel .env:
GOOGLE_APPS_SCRIPT_URL=https://script.google.com/...
╔══════════════════════════════════════════╗
║ ทดสอบ: ส่ง Google Form → ดู LINE! 🎉 ║
╚══════════════════════════════════════════╝
XMAN Code Academy © 2026 — แหล่งเรียนรู้โค้ดมืออาชีพ