EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / php -l ผ่าน แต่หน้าเว็บ 500 — ประตูตรวจก่อน commit ที่คอมไพล์จริง
Linux

php -l ผ่าน แต่หน้าเว็บ 500 — ประตูตรวจก่อน commit ที่คอมไพล์จริง

โดย Ton-Or ·7 ต.ค. 2026·อ่าน 9 นาที
php -l ผ่าน แต่หน้าเว็บ 500 — ประตูตรวจก่อน commit ที่คอมไพล์จริง

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

พอดีเดือนสิงหาคมปีนี้หน้าเว็บพัง 500 สองรอบในเดือนเดียว ทั้งสองรอบผมรัน php -l ผ่านเรียบร้อยก่อน commit ทั้งคู่ รอบแรกเป็นหน้ารายการที่ทีมเปิดทุกวัน รอบที่สองหนักกว่าเพราะเป็นหน้าพิมพ์เอกสารที่พังทุกเคส แล้วกว่าจะรู้ก็ตอนมีคนโทรมาบอก

สาเหตุของทั้งสองรอบเป็นเรื่องเดียวกันคือไฟล์ .blade.php ไม่ใช่ไฟล์ PHP จนกว่ามันจะถูกคอมไพล์ ผมเลยนั่งทำประตูตรวจขึ้นมาชุดหนึ่งไว้กันเรื่องนี้ถาวร บทความนี้คือของชุดนั้นทั้งหมด

ชื่อพาธ ชื่อตาราง และชื่อโมดูลในบทความเป็นค่าตัวอย่าง โครงและสคริปต์เป็นของจริง

php -l ไม่รู้จัก Blade

php -l อ่านไฟล์เป็น PHP ตรง ๆ พอเจอไฟล์ที่เนื้อหาส่วนใหญ่เป็น HTML ปนคำสั่งขึ้นต้นด้วย @ มันไม่เห็นอะไรเลยนอกจากข้อความธรรมดา ทุกอย่างผ่านหมด ไม่ว่าเราจะเขียนอะไรผิดไว้ก็ตาม

cat > /tmp/broken.blade.php <<'EOF'
@if ($rows->count())
  <ul>
  @foreach ($rows as $r)
    <li>{{ $r->name }}</li>
  @endforeach
  <!-- ลืม @endif ไปเฉย ๆ -->
EOF

php -l /tmp/broken.blade.php
# No syntax errors detected in /tmp/broken.blade.php

เห็นบรรทัดสุดท้ายแล้วน่าตกใจครับ ไฟล์ที่ขาด @endif ทั้งดุ้นยังผ่านฉลุย เพราะสำหรับ php -l แล้ว @if กับ @endif เป็นแค่ตัวอักษรในข้อความ ไม่ใช่ไวยากรณ์อะไรทั้งนั้น

อย่าเชื่อ php -l กับไฟล์ .blade.php

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

@php บรรทัดเดียวที่โดน precompiler เล่นงาน

รอบที่สองแสบกว่ารอบแรก เพราะโค้ดถูกต้องตามไวยากรณ์ Blade ทุกประการ แต่ไปเจอกับตัว precompiler ของไลบรารีฝั่ง UI ที่ทำงานก่อน Blade อีกชั้นหนึ่ง

{{-- พังตอนคอมไพล์ ทั้งที่หน้าตาถูกต้อง --}}
@php $total = $items->sum('amount'); $vat = round($total * 0.07, 2); @endphp

{{-- ปลอดภัย — ใช้รูปวงเล็บ หรือขึ้นบรรทัดใหม่ให้ชัดเจน --}}
@php($total = $items->sum('amount'))
@php($vat = round($total * 0.07, 2))

ตัวที่เขียน @php ... @endphp จบในบรรทัดเดียวจะถูกตัวคอมไพล์ชั้นแรกมองผิด แล้วส่งต่อผลที่ผิดไปให้ Blade คอมไพล์ต่อ ผลลัพธ์คือไฟล์ PHP ที่ผิดไวยากรณ์ ซึ่งไม่มีใครเห็นจนกว่าจะมีคนเปิดหน้านั้น

ส่วนตัวผมเลยตั้งกติกาไว้เลยว่าในโปรเจกต์นี้ห้ามใช้ @php แบบบล็อกอีก ใช้รูปวงเล็บอย่างเดียว มันเขียนยาวกว่านิดหน่อยแต่ไม่เคยพังเลยสักครั้ง ใครใช้ไลบรารีฝั่ง UI คนละตัวกับผมก็ลองดูก่อนได้ครับ แต่อย่าลองบนของจริง

view:cache อย่างเดียวยังไม่พอ

ทางแก้ที่ดูตรงไปตรงมาคือสั่งคอมไพล์ทุกไฟล์ แล้วดูว่ามันล้มหรือเปล่า

php artisan view:clear
php artisan view:cache && echo "[OK] คอมไพล์ผ่าน" || echo "[ERR] คอมไพล์ล้ม"

อันนี้จับกรณี @php บรรทัดเดียวได้ครับ เพราะตัวคอมไพล์โยน error ออกมาตรง ๆ แต่จับกรณี @if ขาด @endif ไม่ได้ ผมพลาดตรงนี้ไปเกือบเดือน คิดว่าปิดรูแล้ว จนไปลองปลูกบั๊กดูเองถึงรู้ว่ายังไม่ปิด

เหตุผลคือ view:cache มีหน้าที่ "แปลงแล้วเขียนไฟล์ออกมา" เท่านั้น มันไม่ได้ตรวจว่าไฟล์ที่เขียนออกมาเป็น PHP ที่ถูกต้องหรือเปล่า พอ @if ไม่มีคู่ปิด ตัวแปลงก็เขียน <?php if(...): ?> ที่ไม่มี endif ออกมาให้หน้าตาดี ๆ แล้วจบงานด้วยสถานะสำเร็จ

แต่ละด่านมองเห็นที่ชั้นไหนphp -l กับไฟล์นี้ตรง ๆ ตอบว่าไม่พบข้อผิดพลาด ทุกครั้ง.blade.phpต้นฉบับ ยังไม่ใช่ PHPไฟล์ PHP ที่คอมไพล์storage/framework/viewsphp -lเห็น parse errorview:cachelint ซ้ำ@php บรรทัดเดียวview:cache ล้มตอนแปลง จับได้ที่ด่านนี้@if ขาด @endifview:cache ผ่าน เห็นเฉพาะตอน lint ไฟล์ที่คอมไพล์
view:cache อย่างเดียวจับ @php บรรทัดเดียวได้ แต่ @if ที่ขาด @endif ผ่านการแปลงไปเป็นไฟล์ PHP ที่ผิดไวยากรณ์ ต้อง lint ไฟล์ที่คอมไพล์ออกมาซ้ำถึงจะเห็น
view:cache อย่างเดียว

จับได้แค่ของที่ตัวแปลงเองสำลัก

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

คอมไพล์แล้ว lint ผลซ้ำ

ตรวจของจริงที่ PHP จะรันจริง

ไฟล์ที่ออกมาจากการคอมไพล์เป็น PHP ล้วนแล้ว ตรงนี้ php -l ทำงานได้เต็มที่ ของที่ไม่สมดุลจะโผล่เป็น parse error ทันทีโดยไม่ต้องรอให้ผู้ใช้เปิดหน้า

# ด่านที่ถูกต้อง: คอมไพล์ก่อน แล้ว lint ทุกไฟล์ที่คอมไพล์ออกมาซ้ำอีกชั้น
php artisan view:clear
php artisan view:cache || exit 1
ERR=$(ls storage/framework/views/*.php | xargs -n1 -P4 php -l 2>&1 | grep -c "Errors parsing")
[ "$ERR" -eq 0 ] || { echo "[ERR] เทมเพลตพัง $ERR ไฟล์"; exit 1; }
php artisan view:clear      # คืนพฤติกรรมเดิมให้เครื่องที่เสิร์ฟจาก working tree
ลำดับที่ใช้ได้จริงview:clearล้างของเก่า->view:cacheคอมไพล์ทุกไฟล์->php -l compiledด่านที่ขาดไม่ได้->view:clearคืนสภาพเดิม

พิสูจน์ด้วยการปลูกบั๊กลงไปเอง

ตรงนี้เป็นบทเรียนที่ผมอยากให้ติดไปมากกว่าเรื่อง Blade ด้วยซ้ำ — ตัวตรวจที่เราเขียนเอง ต้องพิสูจน์ว่ามันแดงจริงตอนที่ควรแดง ไม่ใช่แค่เห็นว่ามันเขียวแล้วสบายใจ

# ปลูกบั๊กลงไฟล์จริงชั่วคราว แล้วดูว่าประตูตรวจจับได้ไหม
cp resources/views/reports/summary.blade.php /tmp/summary.bak
sed -i 's/@endif//' resources/views/reports/summary.blade.php   # ถอดคู่ปิดออกหนึ่งตัว

bash scripts/auto-checks.sh; echo "exit=$?"      # ต้องได้ exit ที่ไม่ใช่ 0
cp /tmp/summary.bak resources/views/reports/summary.blade.php   # คืนไฟล์ทันที
bash scripts/auto-checks.sh; echo "exit=$?"      # ต้องกลับมาเป็น 0

ครั้งแรกที่ผมทำแบบนี้ ผลคือประตูตรวจบอกว่าผ่าน ทั้งที่ไฟล์ขาด @endif อยู่ตรงหน้า นั่นคือวินาทีที่ผมรู้ว่าต้องเพิ่มด่าน php -l บนไฟล์ที่คอมไพล์ออกมา ถ้าไม่ได้ลองปลูกบั๊กดูเอง ผมคงเชื่อไปอีกนานว่าปิดรูแล้ว

6 ด่านในสคริปต์เดียว
2 รอบที่พังในเดือนเดียว
4 งานที่ lint ขนานกัน
exit 1แปลว่าห้าม commit

ประตูตรวจ 6 ด่านในสคริปต์เดียว

พอได้ด่านสำคัญแล้วผมก็รวบของที่เคยพลาดมาแล้วทั้งหมดใส่ไว้ในสคริปต์เดียว ตั้งกติกาว่าออกด้วยรหัสอื่นที่ไม่ใช่ 0 เมื่อไหร่ ห้าม commit

#!/bin/bash
# scripts/auto-checks.sh — รันหลัง build ก่อน commit ทุกครั้ง
set -u
SELF="$(cd "$(dirname "$0")" && pwd)"     # หา dir ตัวเองก่อน cd ไม่งั้นเรียกด้วยพาธสัมพัทธ์แล้วหาไฟล์ลูกไม่เจอ
cd /home/appuser/app || exit 1
FAIL=0

# 1) php -l เฉพาะไฟล์ที่แก้
CHANGED=$(git diff HEAD --name-only -- '*.php' | grep -v '\.blade\.php$')
for f in $CHANGED; do
  [ -f "$f" ] && ! php -l "$f" >/dev/null 2>&1 && { echo "[ERR] syntax: $f"; FAIL=1; }
done

# 2) คอมไพล์ Blade ทุก view + lint ไฟล์ที่คอมไพล์ออกมา (ด่านที่ขาดไม่ได้)
php artisan view:clear >/dev/null 2>&1
if php artisan view:cache >/dev/null 2>&1; then
  ERR=$(ls storage/framework/views/*.php 2>/dev/null | xargs -n1 -P4 php -l 2>&1 | grep -c "Errors parsing")
  [ "$ERR" -eq 0 ] || { echo "[ERR] เทมเพลตพัง $ERR ไฟล์"; FAIL=1; }
else
  echo "[ERR] view:cache ล้ม"; FAIL=1
fi
php artisan view:clear >/dev/null 2>&1

# 3) รูปแบบโค้ด (เตือนอย่างเดียว ไม่บล็อก)
[ -n "$CHANGED" ] && vendor/bin/pint --test $CHANGED >/dev/null 2>&1 || echo "[WARN] รูปแบบโค้ดตก"

# 4) กวาดหน้า + ความสอดคล้องข้อมูล + ของทดสอบตกค้าง
OUT=$(timeout 600 php artisan tinker --execute="include '$SELF/sweep.php';" 2>&1)
printf '%s\n' "$OUT" | sed 's/^/   /'
printf '%s' "$OUT" | grep -q 'SWEEP: ผ่านครบ' || FAIL=1

# 5) เตือนเรื่อง worker ถือโค้ดเก่า
echo "$CHANGED" | grep -qE 'app/(Jobs|Services)/|config/' && \
  echo "[WARN] แตะ Jobs/Services/config — ต้อง restart worker แล้วยืนยันด้วยงานจริงหนึ่งงาน"

# 6) กันความลับหลุดลง diff
git diff HEAD | grep -qiE "password[\"'] *=> *[\"'][^\"']{6,}|Bearer [A-Za-z0-9]{20,}|-----BEGIN" && \
  { echo "[ERR] diff มีรูปแบบคล้ายรหัสผ่านหรือโทเคน"; FAIL=1; }

[ "$FAIL" -eq 0 ] && { echo "[OK] ผ่านครบ commit ได้"; exit 0; } || { echo "[ERR] ไม่ผ่าน"; exit 1; }

ด่านที่ห้าเป็นแค่คำเตือนไม่ได้บล็อก แต่ผมใส่ไว้เพราะเคยเสียเวลาไปยี่สิบชั่วโมงกับอาการ "แก้แล้วแต่ยังพังเหมือนเดิม" ซึ่งคำตอบคือ worker ตัวยาวถือโค้ดเก่าไว้ใน RAM ตั้งแต่ตอนสตาร์ต

sweep ยิง GET ทุก route ผ่าน Kernel

ด่านที่สี่เป็นด่านที่จับของได้มากที่สุด แนวคิดคือสร้าง request ขึ้นมาเองในหน่วยความจำแล้วยัดเข้า Kernel ตรง ๆ ไม่ต้องผ่าน HTTP ไม่ต้องมีเว็บเซิร์ฟเวอร์ เร็วกว่ามากและได้ stack trace เต็ม ๆ ตอนพัง

// scripts/sweep.php — include ผ่าน tinker
$admin = App\Models\User::whereHas('roles', fn ($q) => $q->where('name', 'admin'))->first();
auth()->login($admin);

// พารามิเตอร์ในเส้นทางแบบ {id} ต้องแมปจาก id ล่าสุดของตารางที่เกี่ยวข้อง ไม่งั้นได้ 404 ทุกหน้า
$map = ['invoices' => 'invoices', 'documents' => 'documents', 'parties' => 'parties'];
$latest = fn (string $t) => DB::table($t)->orderByDesc('id')->value('id');

$bad = []; $pass = 0;
foreach (Route::getRoutes() as $r) {
    if (! in_array('GET', $r->methods(), true)) { continue; }
    $uri = $r->uri();
    if (preg_match('#^(_|storage|broadcasting)#', $uri) || str_contains($uri, 'webhook')
        || in_array($uri, ['login', 'logout'], true)) { continue; }

    $url = '/'.$uri;
    if (str_contains($url, '{')) {
        $table = null;
        foreach ($map as $prefix => $t) { if (str_starts_with($uri, $prefix)) { $table = $t; break; } }
        $v = $table ? ($latest($table) ?: 1) : 1;
        $url = preg_replace_callback('/\{(\w+)\??\}/', fn ($m) => match ($m[1]) {
            'month'  => now(config('app.display_tz'))->format('Y-m'),
            'userId' => (string) $admin->id,
            default  => (string) $v,
        }, $url);
    }
    if (str_contains($url, '{') || str_contains($url, '//')) { continue; }

    $req = Request::create($url, 'GET');
    $req->setUserResolver(fn () => $admin);
    try {
        $code = app(Illuminate\Contracts\Http\Kernel::class)->handle($req)->getStatusCode();
        $code >= 500 ? $bad[] = "$url ($code)" : $pass++;
    } catch (\Throwable $e) {
        $bad[] = $url.' EXC:'.mb_substr($e->getMessage(), 0, 60);
    }
}
printf("  กวาด GET ผ่าน %d หน้า · พัง %d หน้า %s\n", $pass, count($bad), implode(' · ', array_slice($bad, 0, 5)));

สองจุดที่ทำให้สคริปต์แบบนี้ใช้ได้จริงแทนที่จะเป็นของประดับคือ การล็อกอินเป็นผู้ใช้ที่มีสิทธิ์ครบก่อนกวาด และการแมปพารามิเตอร์จาก id จริงในฐานข้อมูล ถ้าใส่เลข 1 ไปดื้อ ๆ ทุกหน้าจะตอบ 404 แล้วเราจะเห็นเป็นสีเขียวทั้งกระดานโดยที่ไม่ได้ตรวจอะไรเลย

ข้ามเส้นทางที่มีผลข้างเคียง

สคริปต์นี้ยิงของจริงบนฐานข้อมูลจริง เส้นทางที่เขียนอะไรลงไปหรือส่งอะไรออกนอกระบบต้องคัดออกก่อนเสมอ — ปลายทาง webhook, เส้นทาง export ที่สร้างไฟล์ทิ้งไว้, หน้าที่กด GET แล้วเปลี่ยนสถานะ (ซึ่งไม่ควรมีอยู่แล้วแต่ก็มักจะมี) ผมเจอมาแล้วว่ากวาดเพลิน ๆ แล้วมีเมลออกไปจริงหนึ่งฉบับ

ความสอดคล้องข้อมูลกับของทดสอบตกค้าง

ในสคริปต์เดียวกันผมใส่ชุดคำถามที่เป็นบั๊กเงียบไว้ด้วย พวกนี้คือของที่เคยพลาดมาแล้วทั้งนั้น ไม่มีข้อไหนเป็นทฤษฎี

-- ยอดรวมหัวเอกสารไม่ตรงกับผลรวมของรายการย่อย
SELECT COUNT(*) FROM invoices i
 WHERE EXISTS (SELECT 1 FROM invoice_items x WHERE x.invoice_id = i.id)
   AND ROUND(i.subtotal, 2) <> (SELECT ROUND(SUM(amount), 2) FROM invoice_items WHERE invoice_id = i.id);

-- รายการบัญชีไม่สมดุล เดบิตไม่เท่าเครดิต
SELECT COUNT(*) FROM (
  SELECT entry_id FROM journal_lines GROUP BY entry_id
   HAVING ROUND(SUM(debit),2) <> ROUND(SUM(credit),2)
) x;

-- งานอ่านเอกสารค้างสถานะเกินหนึ่งชั่วโมง
SELECT COUNT(*) FROM documents WHERE extract_status IN ('queued','running') AND updated_at < NOW() - INTERVAL 1 HOUR;

-- ของทดสอบที่ลืมลบ
SELECT COUNT(*) FROM invoices WHERE payer_name LIKE '%ทดสอบ%';

ข้อสุดท้ายดูเหมือนเรื่องเล็กแต่ไม่เล็กเลยครับ ตัวนับเลขที่เอกสารเดินไปตามการทดสอบด้วย แปลว่าถ้าเราทดสอบออกใบจริงไปห้าใบแล้วลบทิ้ง ใบจริงใบแรกของลูกค้าจะไม่ใช่เลข 0001 อีกต่อไป กติกาที่ผมตั้งคือทดสอบเส้นทางที่ออกเลขเอกสารเมื่อไหร่ ต้องคืนตัวนับให้เรียบร้อยก่อนจบงาน

ทำไมด่านนี้ต้องอยู่ก่อน commit ไม่ใช่หลัง deploy

คนอ่านถึงตรงนี้อาจคิดว่าเอาไปวางไว้ใน pipeline หลัง push ก็ได้ ผมเคยคิดแบบนั้นเหมือนกัน แต่พอลองแล้วพบว่าไม่เหมือนกัน เพราะระยะห่างระหว่างตอนเขียนกับตอนรู้ว่าพัง คือต้นทุนที่แท้จริง

  1. ตรวจตอนยังไม่ commit — แก้ทันทีในบริบทเดิม ใช้เวลาเป็นนาที ไม่มีใครเห็น
  2. ตรวจหลัง push — ต้องกลับมาเปิดเรื่องเดิมใหม่ ใช้เวลาเป็นสิบนาที และมีคนอื่นดึงโค้ดไปแล้ว
  3. ตรวจหลัง deploy — ผู้ใช้เจอก่อนเรา แล้วเราจะทำงานภายใต้ความกดดันซึ่งเป็นเวลาที่คนพลาดซ้ำมากที่สุด

ลำดับงานมาตรฐานของผมเลยเหลือแค่บรรทัดเดียว คือ build เสร็จรันประตูตรวจ ผ่านแล้วค่อยตรวจงานเชิงตรรกะที่สคริปต์ทำแทนไม่ได้ แล้วค่อย commit

bash scripts/auto-checks.sh && git commit -am "แก้หน้ารายงาน: ถอด @php บล็อกออกทั้งไฟล์ กันของเดิมที่เคยพัง"

สิ่งที่สคริปต์แบบนี้ทำแทนเราไม่ได้คือมิติเฉพาะงาน — เรื่องเงิน เรื่องสิทธิ์ เรื่องของที่ยิงออกนอกระบบ พวกนั้นยังต้องนั่งไล่เอง ประตูตรวจอัตโนมัติมีไว้กันของซ้ำ ๆ ที่เคยพลาดมาแล้ว เพื่อให้เราเหลือแรงไปคิดเรื่องที่เครื่องคิดแทนไม่ได้ ใครจะหยิบไปปรับให้เข้ากับโปรเจกต์ตัวเองก็ตามสะดวกครับ ขอแค่ข้อเดียวคืออย่าลืมปลูกบั๊กลงไปทดสอบว่ามันจับได้จริง ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย