EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Linux / php -l Passes, Web Pages Return 500 — A Compile Gate Before Commit
Linux

php -l Passes, Web Pages Return 500 — A Compile Gate Before Commit

by Ton-Or ·7 Oct 2026·9 min read
php -l Passes, Web Pages Return 500 — A Compile Gate Before Commit

The most expensive bugs are not the ones that are hard to find, but the ones our tools say have passed even though they are broken, because I will trust the tools and commit with complete confidence.

This August, web pages failed with 500 errors twice in the same month. Both times, I had run php -l successfully before committing. The first was a listing page the team opens every day. The second was worse because it was a document printing page that failed in every case, and I only found out when someone called to tell me.

Both incidents had the same cause: a .blade.php file is not a PHP file until it has been compiled. So I sat down and built a set of checks to prevent this for good. This article covers that entire set.

The paths, table names, and module names in this article are examples. The structure and scripts are real.

php -l Does Not Understand Blade

php -l reads a file directly as PHP. When most of the content is HTML mixed with directives starting with @, it sees nothing but plain text. Everything passes, regardless of what mistakes I have written.

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

That last line is alarming. A file missing an entire @endif still passes without a problem, because to php -l, @if and @endif are just characters in text, not syntax of any kind.

Do not trust php -l on .blade.php files

This is the worst case for a checking tool — it does not say "cannot check"; it says "no errors found," which the reader interprets as a pass. Those are completely different things. If a tool cannot check something, I need to know exactly what it cannot check, or I will commit with a false sense of confidence.

A Single-Line @php Block Tripped Up by a Precompiler

The second incident was worse than the first because the code was entirely valid Blade syntax, but it ran into a UI library's precompiler that runs one layer before 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))

The version with @php ... @endphp on a single line gets misinterpreted by the first compiler, which then passes the incorrect output to Blade for further compilation. The result is a PHP file with invalid syntax that no one sees until someone opens that page.

So I set a rule for this project: no more @php blocks. Use only the parenthesized form. It takes a little more typing, but it has never broken once. If you use a different UI library from mine, you can try it first, but do not try it in production.

view:cache Alone Is Not Enough

The obvious fix is to compile every file and see whether it fails.

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

This catches the single-line @php case because the compiler throws an error directly, but it does not catch an @if missing its @endif. I missed this for almost a month, thinking I had closed the gap, until I deliberately planted a bug and discovered that I had not.

The reason is that view:cache only handles "transforming and writing files." It does not check whether the files it writes are valid PHP. When an @if has no closing counterpart, the compiler happily writes out <?php if(...): ?> without an endif and finishes with a success status.

What each stage can seephp -l on this source reports no errors, every time.blade.phpSource, not yet PHPCompiled PHPstorage/framework/viewsphp -lSees parse errorsview:cacheLint againSingle-line @phpview:cache fails; caught here@if missing @endifview:cache passes; only compiled lint catches it
view:cache alone catches single-line @php, but an @if missing its @endif gets converted into PHP with invalid syntax. Linting the compiled files is required to catch it.
view:cache alone

Only catches what the compiler itself chokes on

It catches Blade syntax the compiler cannot read, such as malformed directives. But unbalanced structures, such as a missing closing directive, pass without a problem because checking those is not the compiler's job.

Compile, then lint the output

Check the actual code PHP will run

The compiled files are now pure PHP. At this point, php -l can do its full job. Unbalanced structures immediately show up as parse errors without waiting for a user to open the page.

# ด่านที่ถูกต้อง: คอมไพล์ก่อน แล้ว 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
The sequence that worksview:clearClear old files->view:cacheCompile every file->php -l compiledThe essential check->view:clearRestore the original state

Prove It by Planting a Bug

This is a lesson I want readers to take away even more than the Blade issue — I have to prove that a checker I write turns red when it should, not just feel reassured because it turns green.

# ปลูกบั๊กลงไฟล์จริงชั่วคราว แล้วดูว่าประตูตรวจจับได้ไหม
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

The first time I did this, the gate reported a pass even though the file was missing an @endif right in front of me. That was the moment I knew I needed to add a php -l check on the compiled files. If I had not tried planting a bug myself, I would probably have believed the gap was closed for a long time.

6 stagesIn one script
2 failuresIn one month
4 jobsLinting in parallel
exit 1Means do not commit

A Six-Stage Gate in One Script

Once I had the essential check, I collected all the things I had previously missed into one script, with a rule: if it exits with any code other than 0, do not 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; }

The fifth stage is only a warning and does not block anything, but I included it because I once spent twenty hours on "I fixed it, but it is still broken in exactly the same way." The explanation was that a long-running worker had been holding the old code in RAM since startup.

Sweep Every GET Route Through the Kernel

The fourth stage catches the most problems. The idea is to create a request in memory and feed it directly into the Kernel, without going through HTTP or needing a web server. It is much faster and gives a full stack trace when something breaks.

// 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)));

Two things make this kind of script useful rather than decorative: logging in as a user with full permissions before the sweep, and mapping parameters from real ids in the database. If I simply put in 1, every page will return 404, and I will see green across the board without having checked anything.

Skip routes with side effects

This script makes real requests against a real database. Routes that write anything or send anything outside the system must always be excluded first — webhook endpoints, export routes that leave generated files behind, and pages where a GET changes state (which should not exist but often do). I have already had a sweep send out one actual email.

Data Consistency and Leftover Test Data

In the same script, I also included a set of questions that expose silent bugs. These are all things I have missed before; none of them are theoretical.

-- ยอดรวมหัวเอกสารไม่ตรงกับผลรวมของรายการย่อย
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 '%ทดสอบ%';

The last item looks minor, but it is not. The document number counter advances during testing too. That means if I test issuing five real documents and then delete them, the customer's first real document will no longer be numbered 0001. My rule is that whenever I test a route that assigns document numbers, I must restore the counter before finishing the work.

Why This Gate Belongs Before Commit, Not After Deploy

At this point, readers might think this could go in a pipeline after push. I used to think so too, but when I tried it, I found it was not the same, because the gap between writing the code and finding out it is broken is the real cost.

  1. Check before commit — fix it immediately in the same context. It takes minutes, and no one sees it.
  2. Check after push — reopen the same issue and get back into context. It takes tens of minutes, and someone else has already pulled the code.
  3. Check after deploy — users find it before I do, and I end up working under pressure, which is when people are most likely to make another mistake.

So my standard workflow comes down to one line: finish the build, run the gate, and once it passes, review the logic that the script cannot check for me, then commit.

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

What a script like this cannot handle for me is the domain-specific side — money, permissions, and things sent outside the system. I still have to work through those myself. The automated gate exists to catch repeated mistakes I have already made, leaving me the energy to think about things a machine cannot think through for me. Feel free to adapt it to your own project. I ask just one thing: remember to plant a bug and test that it really catches it. If I have left anything out of the explanation or made a typo, I will come back and fix it later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first