EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / DirectAdmin / route:cache Turns Every GET into 405 — The Sub-path Trap
DirectAdmin

route:cache Turns Every GET into 405 — The Sub-path Trap

by Ton-Or ·7 Oct 2026·9 min read
route:cache Turns Every GET into 405 — The Sub-path Trap

405 means "this path exists, but the method you used to call it is wrong" — when it starts answering ordinary GET requests that worked five minutes ago, the problem is not with the caller but with what I just built for it to read.

I was deploying a Laravel app on an organization's internal server, placing it under a sub-path of the existing web server (/tracker/) because a legacy system already occupied the web root and could not be moved. Everything went smoothly until the final step, when I typed php artisan optimize as every deployment guide recommends, and every page that had just been working immediately turned into 405 Method Not Allowed.

I think this is worth writing about because the symptoms are quite misleading: every tool I use to check routes says everything is correct, and most people interpret 405 as "I used the wrong method," so they spend time checking forms, methods, and .htaccess first, even though the cause lies elsewhere. At the end, I have included three other issues that surfaced during the same deployment, because they are a set of problems that always seem to accompany deploying an app under a sub-path like this.

All hostnames, IP addresses, and paths in this article are examples.

Symptoms: route:list says it is correct, but the web returns 405

The first thing I did was send a request directly with curl, bypassing the browser, because I wanted to see the raw headers rather than an error page dressed up by the browser.

curl -sI http://10.10.10.7/tracker/
# HTTP/1.1 405 Method Not Allowed
# Allow: HEAD
# Content-Type: text/html; charset=UTF-8

Then I asked the app itself whether it knew this route.

php artisan route:list --path=/ --columns=method,uri,action
# +-----------+------+--------------------------------------------------+
# | GET|HEAD  | /    | App\Http\Controllers\DashboardController@index    |
# +-----------+------+--------------------------------------------------+

These two results clearly contradict each other. The app says it accepts GET, but an actual request says it only accepts HEAD, which is valuable information, and most people skip the Allow: line entirely in their rush to check the logs.

Reading Allow: — the router found the path, but the handoff failed

The Symfony router beneath Laravel works in two stages. First, it matches the path against the available routes. Second, it checks whether the incoming method is in the list that the route accepts. If the first stage finds nothing, I get a 404. But if it finds the path and fails at the second stage, I get a 405 along with the list of methods it thinks it accepts, included in the Allow: header.

Where the request is decidedApachealias /tracker->index.phpbootstrap->match pathfound = no 404->check methodfails here->405Allow: HEAD

This means the route really was loaded and really does exist, but the data describing which methods it accepts contains only HEAD, which is not what routes/web.php says. This was where I stopped suspecting the code and started suspecting what I had just built, because the only difference between "it worked a moment ago" and "now it returns 405" was the route cache file.

Proving it took less than half a minute.

php artisan route:clear && curl -sI http://10.10.10.7/tracker/ | head -1
# HTTP/1.1 200 OK

php artisan route:cache && curl -sI http://10.10.10.7/tracker/ | head -1
# HTTP/1.1 405 Method Not Allowed

I could switch back and forth at will, and the symptoms repeated every time. At that point, there was no need to guess.

The rule I have used for every 405 since

When a previously working route returns 405, do these three things before anything else. First, check what the Allow: header lists. Second, run route:clear and repeat the request. Third, if the problem disappears, it is a cache issue, not a code issue. Do not start changing controllers or forms before going through these three checks.

Two servers, two patterns — the whole site breaks, or just the home page

I have encountered this family of symptoms on two servers, in different projects and different years, but with the same root cause.

Internal server

Every GET becomes 405

The app sits under a sub-path through an Apache alias. The whole site fails at once after route:cache. Every page and every path returns the same 405. A single route:clear restores normal operation.

shared host

Only the root route breaks

Only /, registered as a controller callable, returns 405, while /dashboard and /login, written in exactly the same way, work normally. The difference is that the host has an .htaccess handling trailing slashes in between.

What the two cases share is that the app is not actually at the root of the document root. The first sits under an alias; the second sits behind rewrite rules that add or remove the trailing slash before passing the request to index.php. The affected route is the one with the shortest path, which happens to be the most sensitive to adding or removing a slash.

I will not claim to know at the source-code level exactly what overwrites what, because I could not reproduce either case even once on a dev machine without the same Apache setup. What I can confirm is the set of conditions that reproduces it 100% of the time and the way to make it disappear, which is enough for work I need to deliver. For anyone interested, try opening bootstrap/cache/routes-v7.php and comparing the methods values of affected and unaffected routes; that makes the picture much clearer.

The fixes I actually used — and why I kept the other caches

There are two approaches, and I used a different one on each server.

The first approach, for cases where only the root route breaks, is to change that one route to a closure, because Laravel does not include closures in the route cache. It therefore falls back to registering it fresh on every request, while all other routes still get the full benefit of the cache.

// routes/web.php — เฉพาะ route ราก
Route::get('/', function () {
    // ไม่ต้องยกตรรกะออกจาก controller ให้เรียกมันตรง ๆ
    return app(\App\Http\Controllers\DashboardController::class)->index();
});

The second approach, for the server where the whole site breaks, is to stop caching routes on that server altogether, but keep both the config cache and the view cache, because those two provide greater speed benefits in applications without many routes. The catch is that php artisan optimize includes route:cache, so I cannot use the combined command and have to run the commands separately.

# สคริปต์ deploy บนเครื่องที่ route cache ใช้ไม่ได้
php artisan config:cache
php artisan view:cache
php artisan route:clear          # ต้องมีบรรทัดนี้ ไม่ใช่ปล่อยว่าง
php artisan about --only=cache   # ยืนยันว่าสถานะแคชเป็นอย่างที่ตั้งใจ

The route:clear line matters more than it might seem, because if the server still has a cache file from a previous deployment, "not running route:cache" does not mean the old file disappears. It is still there and still being read. I once wasted half an hour on this after applying the fix and finding that the symptoms remained.

Do not use the same command set on every server

A deployment script copied from a blog with optimize in it will break the server again on every subsequent deployment. If a server has an exception, put that exception in its script with a comment explaining why, rather than keeping it in one person's head.

Extra issue one: an app under a sub-path loses the asset prefix

Once the 405 was gone, the next problem appeared immediately. The page loaded without CSS or images because helpers such as asset() and url() generated links from the domain root rather than the sub-path root where I had placed the app. The resulting links were /css/app.css instead of /tracker/css/app.css.

My approach was to force the URL root during boot and read the value from config rather than hardcoding it.

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    // APP_URL=http://srv1.example.local/tracker
    if (config('app.url')) {
        \Illuminate\Support\Facades\URL::forceRootUrl(config('app.url'));
    }
    if (str_starts_with((string) config('app.url'), 'https://')) {
        \Illuminate\Support\Facades\URL::forceScheme('https');
    }
}

The web server also needs to point to the right place. The alias must target the app's public/ directory, not the project root; otherwise, both .env and storage/ are accessible from the web.

Alias /tracker /var/www/tracker/public

<Directory /var/www/tracker/public>
    AllowOverride All
    Require all granted
</Directory>

One thing to watch is that config('app.url') is read from the config cache. If I change .env without running config:cache again, the old value remains, producing the familiar "I changed it, but nothing changed" symptom that can send me down another long wrong turn.

Extra issue two: the summary page times out with 500 because a subquery scans row by row

During that same deployment, one data quality check page would not load. It waited until it timed out and returned 500, while other pages were as fast as usual. The cause was a query counting records "present in table A but absent from table B," written as a correlated subquery on a column without an index.

// ช้าจนหมดเวลา — สำหรับทุกแถวใน A ฐานข้อมูลต้องกวาด B หนึ่งรอบ
$missing = DB::table('rep_records')
    ->whereNotExists(function ($q) {
        $q->select(DB::raw(1))->from('stm_records')
          ->whereColumn('stm_records.tran_id', 'rep_records.tran_id');
    })->count();

Table A had fifty thousand rows and table B forty-five thousand. Multiplying them gives billions of comparisons, which could not possibly finish in 30 seconds. I had two options. The proper fix was to add an index to the tran_id column in both tables, but that database was a file generated as read-only and managed by an ETL process, so I first changed the app to fetch the keys and compute a set difference in memory, leaving the indexes for the next database build.

// ดึงคีย์ล้วน ๆ สองชุด แล้วหาส่วนต่างในหน่วยความจำ
$a = DB::table('rep_records')->pluck('tran_id')->all();
$b = array_flip(DB::table('stm_records')->pluck('tran_id')->all());
$missing = count(array_filter($a, fn ($k) => ! isset($b[$k])));
50,000Rows in table A
45,000Rows in table B
>30 secBefore the fix
1.2 secAfter the fix

This approach consumes RAM and works for hundreds of thousands of records, not tens of millions. If your data is larger, use indexes or joins instead. Do not apply this blindly.

Extra issue three: .env arrived on the host with 664 permissions

This has nothing to do with 405, but I found it while inspecting the app's paths, and it was more serious than all the issues above combined. Files uploaded from a Windows machine often receive permissions based on the destination's umask, which on a host shared by multiple accounts can mean "other people on the same machine can read this."

find /var/www/tracker -maxdepth 1 -name '.env' -printf '%m %u:%g %p\n'
# 664 tracker:tracker /var/www/tracker/.env

chmod 600 /var/www/tracker/.env
find /var/www/tracker/storage -type d -exec chmod 770 {} \;
find /var/www/tracker -type f -name '*.php' -perm /o+w -print   # ต้องไม่มีผลลัพธ์
  1. Check after every upload, not just during the initial server setup because a subsequent deployment can always reset the permissions to their original values. Put the chmod line at the end of the deployment script.
  2. Do not set 777 on directories containing code because that allows the web process to overwrite its own code, which is the final step toward taking over the server. Use 770 for directories that need to be writable and 640 for code files.
  3. If credentials in the file were ever broadly readable, treat them as compromised and change the database password and every key in that file, rather than simply restricting permissions and calling it done.

Checklist before running optimize on a production server

Here is what became routine for me after this incident. Leave out whichever items you like, but I think these four are worth the time.

My sequence for every deploymentUpload files->config:cache->view:cache->Routes per hostcache or clear->curl -Imust get 200->chmod .env

First, do not run optimize as a single combined step on a server where route caching has not yet been proven to work. Run each command separately the first time, then combine them once they pass. Second, every deployment must include a curl command that requests an actual page and checks the status, rather than opening a browser manually and declaring it done, because the browser has its own cache that can mislead me. Third, write any server-specific exceptions into that server's script. Fourth, always restrict permissions on secret files as the final step.

For anyone debugging something along the lines of "the app is running, but the result is not what the code says," I wrote about another case in Laravel behind a proxy sees the IP as 127.0.0.1. It concerns a different layer, but the reasoning is the same: stop looking for the word error and look for something working "correctly according to what it was told," where the information it was given is wrong. If I have left anything out or made a typo, I will come back and correct 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