EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Web / Thai APP_NAME Makes Cookies -session: Pin Before Laravel 13
Web

Thai APP_NAME Makes Cookies -session: Pin Before Laravel 13

by Ton-Or ·7 Nov 2026·11 min read
Thai APP_NAME Makes Cookies -session: Pin Before Laravel 13

The cookie name users already have is determined by a default expression that nobody ever wrote into .env. Before a major upgrade, record the actual values and pin them firmly before touching composer.

1-minute summary

Symptom / problem
A Thai APP_NAME makes the session cookie name come out as -session, so a major upgrade risks logging users out.
Who should read
Maintainers of Laravel apps that use a Thai APP_NAME.
Fix in short
Read the real cookie, cache prefix and redis prefix from the running app and pin them in .env before upgrading from 12 to 13.
Jump to steps

I recently had a job upgrading Laravel from 12 to 13 for around a dozen apps under one hosting administrator, plus a billing app and another app under a different administrator. The largest app had several outstanding advisories across multiple packages, so it needed an upgrade. Before the actual run, I wrote a dry run script to check each app. At first, I was only worried about whether the code would work with 13, which was the wrong concern. The more worrying part was that the session cookie name every user already had was not written down anywhere. It was calculated from the app name, and most of my apps had Thai names.

The cookie name comes from APP_NAME, without anyone telling you

In Laravel's config, the defaults for three things used to keep apps separate come from the same expression: pass APP_NAME through Str::slug, then append a suffix. I found them here in the Laravel 13 skeleton.

// config/session.php
'cookie' => env('SESSION_COOKIE', Str::slug((string) env('APP_NAME', 'laravel')).'-session'),

// config/cache.php
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),

// config/database.php  (redis options)
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),

When APP_NAME is Thai, Str::slug cannot transliterate the Thai characters, so the result is an empty string. The actual names in use become -session, -cache- and -database-. They work normally, with no errors or warnings, and nobody had noticed all this time.

APP_NAME = Thai nameStr::slug(APP_NAME)Empty stringSession cookie-sessionCache prefix-cache-redis prefix-database-Change the default or APP_NAME: names change silentlyBrowser sends old cookie, server expects new name = all logged outOld Redis keys become leftovers nobody reads
All three names come from the same default expression. A Thai APP_NAME produces odd names, but they work as long as nothing changes.

Why this is dangerous during a major upgrade

The problem is not that the names look strange. The problem is that they depend entirely on default expressions. If the framework changes those expressions in a new major release, or someone changes APP_NAME one day, all three names change silently. Users' browsers still send the old cookie name, but the server looks for the new one. Everyone gets logged out at once. Meanwhile, Redis keys starting with the old prefixes become leftovers that nobody reads anymore. The entire cache is effectively discarded.

I cannot confirm that 13 changed the defaults

I did not compare the defaults in 12 and 13 line by line, so I cannot say whether the expressions actually changed. I therefore will not claim that the new major release changed them. The lesson holds either way: values users already have must not depend on defaults we did not write ourselves. I pinned them as a precaution.

A coat check counter where every ticket is stamped from a Thai sign the stamping machine cannot read, leaving only a hyphen and the word ticket
Every ticket is printed from a sign the machine cannot read. It works as long as the machine and sign stay the same. Once they change, nobody accepts the old tickets.

First, check which apps are affected

Do not blindly pin every app yet. I first checked which apps actually derived all three names from default expressions. Apps that already had SESSION_COOKIE set in .env needed no action. Apps with a Thai APP_NAME were immediately recognizable by names starting with a hyphen. I wrote a short loop to print the actual values for every app in one table. It only reads; it changes nothing.

# พิมพ์ชื่อ cookie / cache prefix / redis prefix ที่แต่ละแอปใช้อยู่จริงตอนนี้
for app in /home/webuser/domains/example.com/app-a /home/webuser2/domains/example.org/app-b; do
  printf '%s: ' "$app"
  (cd "$app" && /usr/local/php84/bin/php artisan tinker --execute="
    echo json_encode([config('session.cookie'), config('cache.prefix'), config('database.redis.options.prefix')]);")
  echo
done

The output separates the apps into three groups: those already pinned, those using ordinary English names, and those with leading hyphens. The first two groups' names do not depend on the defaults in the risky way. The last group was the one where I needed to pin every app.

Group Example cookie name At risk during a major upgrade?
SESSION_COOKIE already set in .env Configured value No risk; the name does not depend on the default
English APP_NAME app-a-session At risk if the default or app name changes
Thai APP_NAME -session Equally at risk, and harder to spot

Read the actual values from the running app, then pin them in .env

The fix is straightforward: before running composer, read the values the app actually uses now and write those exact values into .env so they no longer depend on default expressions. I checked in two ways. First, I inspected the cookie the browser received from the live app. Second, I asked the app itself.

# ใช้ PHP ตัวเดียวกับที่ FPM ของแอปนี้ใช้ (ใส่พาธเต็ม)
cd /home/webuser/domains/example.com/app-a
/usr/local/php84/bin/php artisan tinker --execute="
echo config('session.cookie'), PHP_EOL;
echo config('cache.prefix'), PHP_EOL;
echo config('database.redis.options.prefix'), PHP_EOL;"

The results were the three lines I expected. I put them in .env, quoting every value because values starting with a hyphen are safer when quoted, even though the dotenv version in use could read them either way.

# .env  (ค่าต้องตรงกับที่แอปใช้อยู่ตอนนี้เป๊ะ ๆ)
SESSION_COOKIE="-session"
CACHE_PREFIX="-cache-"
REDIS_PREFIX="-database-"

After the actual upgrade, users who were logged in stayed logged in, and the cache was not discarded. That was the result I wanted.

Before and after: on the left, customers hold tickets the new counter does not recognize; on the right, a manager writes the old ticket text on a card attached to the counter
Pinning means writing the old ticket text on a card and attaching it to the counter. The tickets customers already hold still work after the counter changes.

The point to remember is that pinning preserves continuity. If I actually want to change these names one day, I need to do it deliberately and tell users they will have to log in again once. It should not happen on its own because someone changes the app name.

Why not just change APP_NAME to English?

The likely next question is: if the app name causes this, can I just change it to English and be done? Yes, but not during a major upgrade, because changing the app name also changes the cookie name. The result is exactly the same: every user gets logged out and the cache is discarded. That gets mixed in with the risks of the version upgrade, making it hard to tell what caused any problems.

I split this into two jobs on separate days. On upgrade day, everything users can see stays the same. If I want nicer names later, I can schedule a day when requiring another login is acceptable, notify users in advance, and change the pinned SESSION_COOKIE value. I would not change it through APP_NAME, which is also used for other things, such as the name in emails.

Unpinned

Names depend on defaults

Saves one step during the upgrade, but if the defaults change or someone edits APP_NAME, every user gets logged out and the cache is discarded without anyone knowing why.

Pinned

Names stay as written

Add three lines to .env per app. Users notice nothing during the major upgrade, and I can deliberately change the names later on a day I choose.

The batch upgrade procedure that worked for me

Once I had dealt with pinning, I added it to my batch upgrade script, which follows the sequence in this diagram for each app. The important part of the order is that pinning always comes before composer.

1 Back up filesvendor, lock, .envas a zstd tarball2 dry runPreview changesNo live changes yet3 Pin in .envSESSION_COOKIECACHE/REDIS_PREFIX4 composer updateto ^13Within constraints5 composer auditSeveral -> 0Largest app6 Rebuild cachesconfig route view7 Check all runnersworker cron subdomainRight PHP version?8 smoke test106 pages = 200POST no key = 401
The sequence for each app: always pin values before composer, and finish by testing external callers that bypass CSRF.
  1. Back up vendor, composer.json, composer.lock and .env as a zstd tarball for each app
  2. Run a dry run to see what will change, without touching the live app yet
  3. Pin SESSION_COOKIE, CACHE_PREFIX and REDIS_PREFIX in .env
  4. Upgrade to ^13 only within the constraints composer allows
  5. Run composer audit; the result must be zero
  6. Clear and rebuild the config, route and view caches
  7. Check that every runner for the app uses the correct PHP version
  8. Smoke test every page and test external callers

The backup in the first step provides a way back. If an app fails its smoke test or audit, the rollback is to extract that app's tarball over its files, clear the caches, and run the smoke test again to confirm the site is back to how it was before I touched it. I planned rollback per app, rather than for the whole batch, because apps that had passed should not be rolled back because another app broke.

# ย้อนกลับเฉพาะแอปที่ไม่ผ่าน
cd /home/webuser/domains/example.com/app-a
zstd -dc /root/pre-l13/app-a.tar.zst | tar -xf -
/usr/local/php84/bin/php artisan optimize:clear

Step 7 has its own trap: workers and cron jobs using a different PHP version from the web app. I covered that separately in the article on platform_check. Here, I will just say it needs checking before the job is finished. For step 5, I followed the same approach as the Laravel security checklist, using composer audit as the deciding check.

# สำรองก่อนทุกแอป (ตัวอย่างหนึ่งแอป)
cd /home/webuser/domains/example.com/app-a
tar -cf - vendor composer.json composer.lock .env | zstd -T2 -19 -o /root/pre-l13/app-a.tar.zst

composer audit            # ก่อนอัป ต้องมี advisory ให้เห็น
composer update "laravel/*" --with-dependencies
composer audit            # หลังอัป ต้องได้ No security vulnerability advisories found
-> 0Advisories in the largest app
16Apps with clean audits
106Pages tested
43/43Tests for one app

The results were that advisories in the largest app dropped from several to zero, and all 16 apps passed with clean audits. I smoke tested 106 pages across 12 domains, and all returned 200. Laravel's logs had no new errors, the login pages and Livewire admin panels opened, and the queue workers were still running on PHP 8.4. Before touching the live apps, I had already run one app's test suite locally on 13, with 43 out of 43 passing.

I missed an app by counting account owners

In the first pass, one app was missing from my list because it was under a different hosting administrator from the batch I was checking. I switched to searching the entire machine for artisan files instead of working through accounts by owner.

# หาทุกแอป Laravel ทั้งเครื่อง ไม่สนว่าเจ้าของบัญชีเป็นใคร
find /home -maxdepth 6 -name artisan -not -path '*/vendor/*' 2>/dev/null | sort

External callers that POST without going through CSRF

One app in this batch continuously receives data from external devices that POST to an endpoint exempted from CSRF in bootstrap/app.php. I was concerned that the upgrade might stop those devices from sending data without my side noticing.

// bootstrap/app.php  (endpoint ที่อุปกรณ์ภายนอกเรียก ตรวจสิทธิ์ด้วย API key ของตัวเอง)
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'devices/*',
    ]);
})

I used three checks. First, a POST without an API key had to return 401, which is the endpoint's own response. A 419 would mean CSRF was protecting the endpoint again. Second, the devices had to keep adding new rows to the table. Third, one manually triggered send had to return 200 with a body containing ok:true. This round passed all three checks, with no changes needed on the devices.

# ไม่มี key ต้องได้ 401 ไม่ใช่ 419
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://example.com/devices/push -d '{}'

One more thing: production code differed from my local source

Another issue I found along the way was that my local source for one app was far behind production because the tool previously used to develop it had been removed. I pulled the code from production into my workspace, ran composer install, and used a script to compare the files for drift. All 76 out of 76 files matched. The lesson is to always pull down what is actually running before changing anything, rather than assuming the local copy is the running version.

Another practice I am carrying forward is adding these three lines to .env from day one in every new app, with names I deliberately choose rather than names derived by converting the app name. The cost is three lines, in exchange for not having to worry about this during the next major upgrade or check every app retroactively as I did this time.

To sum up, before a major upgrade, record the cookie name and prefixes the app actually uses, pin them in .env, and only then touch composer. Whether to pin them is up to each site's needs if you are prepared to have every user log in again. I chose to keep everyone logged in. I will come back and make further edits 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