EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Web / composer update on PHP 8.4 Broke Workers and Cron on Older PHP
Web

composer update on PHP 8.4 Broke Workers and Cron on Older PHP

by Ton-Or ·28 Oct 2026·10 min read
composer update on PHP 8.4 Broke Workers and Cron on Older PHP

A 200 response from the main site does not mean the whole app is safe, because the same vendor directory is loaded by several PHP versions, and a small file Composer writes will reject every runner below the minimum version without anyone being alerted.

1-minute summary

Symptom / problem
Running composer update on PHP 8.4 breaks workers, cron jobs and subdomains that still use older PHP, one by one.
Who should read
People whose single vendor directory is loaded by several PHP versions on one server.
Fix in short
List every runner and the PHP it uses before touching composer, target the lowest runner version, then verify after the update.
Jump to steps

While I was updating packages flagged by composer audit across about a dozen Laravel apps on a single DirectAdmin host, I ran composer with PHP 8.4 and checked that every app's main site returned 200, so I told myself I was done, but afterward I found that a queue worker had been restarting for nearly an hour without anyone knowing, two cron jobs had failed, and two subdomains of the same app were returning 500; the cause was neither a broken package nor a missing extension, but a single small file Composer had written automatically without me asking.

I have replaced all app names, unit names, domains and users in this article with example values, but the symptoms, error messages and sequence of events are real.

Symptoms: the main sites passed, but everything around them failed one by one

This machine is a shared host running several PHP versions side by side, with each domain and subdomain having its own PHP-FPM pool and PHP version, while the command-line php command is a symlink pointing to the older 8.2 version, which many cron jobs and one worker's systemd unit were using, and when planning the update I had not counted these as entry points into the apps at all.

What I saw after the update, in the order I found it

  1. Every app's main site returned 200, passing the check I had set.
  2. One app's queue worker, whose ExecStart pointed to the shared symlink (8.2), failed, systemd restarted it, and it failed again, repeating this loop for about an hour before someone noticed.
  3. Both the schedule:run cron job and the cron job for publishing scheduled articles, which also called the shared php, failed.
  4. Two subdomains of the same app, one set to 8.2 and the other to 8.3 in DirectAdmin, returned 500 until I moved them to 8.4, with one taking about 40 minutes.
About 12Laravel apps updated this time
1 unitqueue worker stuck restarting
2 cronjobs failed through shared PHP
2 instancessubdomains returning 500
About 1 hrworker failed unnoticed
About 40 minuteslongest subdomain outage

My first wrong guess: a broken package

My first guess was that one of the newly updated packages was broken, or that an extension the app needed was missing from an older version, and the app logs had nothing to show because autoload failed before the app could start; the real answer was in the worker's journal and the error page FPM returned.

$ journalctl -u app-a-queue.service -n 6 --no-pager
app-a-queue.service: Main process exited, code=exited, status=255/EXCEPTION
Composer detected issues in your platform:
Your Composer dependencies require a PHP version ">= 8.4". You are running 8.2.x.
PHP Fatal error: Uncaught RuntimeException: Composer detected issues in your platform
  in /home/webuser/app-a/vendor/composer/platform_check.php
app-a-queue.service: Scheduled restart job, restart counter is at <N>.

The message plainly said that PHP 8.4 or later was required but it was running on 8.2, and read carefully, this was Composer protecting me rather than a bug; I wasted time investigating packages because I had not read this line first.

How platform_check.php works

Every time composer install or composer update runs, Composer combines the php requirements of all packages in the lock file and writes them into vendor/composer/platform_check.php, which the autoloader calls whenever vendor is loaded, whether through the web, CLI or a worker.

<?php
// vendor/composer/platform_check.php  (@generated by Composer)
// ตัวเลขข้างล่างมาจากแพ็กเกจที่ต้องการ PHP สูงสุดใน lock file
$issues = array();

if (!(PHP_VERSION_ID >= 80400)) {
    $issues[] = 'Your Composer dependencies require a PHP version ">= 8.4". '
              . 'You are running ' . PHP_VERSION . '.';
}

if ($issues) {
    if (!headers_sent()) {
        header('HTTP/1.1 500 Internal Server Error');   // ฝั่งเว็บ: 500
    }
    if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
        fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL);  // ฝั่ง CLI: เขียน STDERR
    }
    throw new \RuntimeException('Composer detected issues in your platform: ' . implode(' ', $issues));
}

What I had not considered was that this requirement does not look at which PHP versions are installed on the machine: I ran update with 8.4, so Composer selected newer package versions, some of which required 8.4 or later (if I had run it with an older version, it would have had to select package versions that supported that version instead), raising the minimum in the file accordingly and rejecting everything below 8.4, however it loaded this vendor directory.

Four conveyor lanes in a server room, with red barriers blocking old-version boxes in three lanes and only one lane allowing new-version boxes through
Four entry points (main site, subdomain, cron, worker) share the same vendor directory, but the version barrier rejects old-version boxes in three lanes; the main site I checked was the only lane that passed
One vendor/, several PHP runnerscomposer updateRun with PHP 8.4platform_check.phpPHP_VERSION_ID>= 80400Rewritten on updateMain domain FPM (8.4)Passes, returns 200FPM subdomain (8.2)500 on every requestcron schedule:run (8.2)php symlink, fails each runsystemd worker (8.2)ExecStart=symlink, restartsDifferent binaries, same autoload file; older versions fail on the first line
One file in vendor is loaded by four PHP runners, with only one meeting the requirement, and that happened to be the main site I checked
A passing main site does not mean a passing app

The main site is only one of four runners loading the same vendor directory, and if this file fails, the other entry points fail together without any change to the app's code; I checked only the runner that passed, so all I saw were good results.

Proving which PHP each runner uses

The quickest way to prove this is to ask the binary that is actually called, rather than ask what is installed on the machine, because several PHP versions coexist on the same machine.

# 1) worker: ดู ExecStart แล้วถาม binary ตัวนั้นตรง ๆ
systemctl cat app-a-queue.service | grep ExecStart
/usr/local/bin/php -v | head -1

# 2) cron: ดูว่าบรรทัดไหนเรียก php ตัวไหน
crontab -l -u webuser | grep app-a

# 3) symlink ตัวกลางชี้ไปไหน
readlink -f /usr/local/bin/php

# 4) subdomain: ดูรุ่นที่ DirectAdmin ตั้งให้ แล้วเทียบกับเกณฑ์ใน platform_check.php
grep PHP_VERSION_ID /home/webuser/app-a/vendor/composer/platform_check.php

The results matched every symptom: ExecStart and both cron lines pointed to the shared symlink running 8.2, while the two subdomains were separately configured for 8.2 and 8.3, unlike the main domain's 8.4, and all were below the minimum written in the file.

I have written about FPM and CLI using different binaries and producing different results in another case, tinker sends mail, but the website cannot, where the cause was different (proc_open was disabled in FPM) but the lesson was similar: the runner that passes the test may not be the one doing the actual work, and this case was also unrelated to stale caches as described in Deployed but the site has not updated — tracing five cache layers, because the error message clearly identified the PHP version as the sole issue.

Find every runner before touching composer

The first lesson is to have a list of each app's runners before running composer every time, so I wrote a short script to search for the app's path in the places jobs tend to hide.

#!/bin/bash
# list_runners.sh <app_dir>  — อ่านอย่างเดียว ไม่แก้อะไรทั้งนั้น
APP=${1:?app dir}

echo "== cron ที่เรียกแอปนี้"
grep -l "$APP" /var/spool/cron/crontabs/* 2>/dev/null

echo "== systemd unit ที่ชี้แอปนี้"
grep -l "$APP" /etc/systemd/system/*.service 2>/dev/null

echo "== ไฟล์ override ของ DirectAdmin (subdomain ที่ชี้ docroot นี้)"
grep -rl "$APP" /usr/local/directadmin/data/users/*/domains/ 2>/dev/null | grep -i override

The third item was something I nearly missed, because in DirectAdmin a subdomain's PHP version is not in the main domain's settings page but in a docroot.override file beside the domain's conf file, and without searching I would not have seen which subdomains were running the same code as the main site.

What I did this time

Only checked

  • Updated with PHP 8.4
  • Compared HTTP on main URLs before and after
  • Left cron, workers and subdomains off the list
  • Learned of failures from whoever noticed first
What I will do next

Find runners first

  • Run list_runners.sh for every app first
  • Choose PHP to match the oldest runner
  • Check HTTP on every URL and the worker journal
  • Alert on repeated worker restarts

My approach: choose PHP to match the oldest runner

Once I have the runner list, there are two options: first, run composer with the oldest PHP version in the group, which makes Composer choose packages supporting that version and write a correspondingly lower minimum into platform_check.php; second, move every runner to the newer version first, then run composer with that version, and my patch script accepts a PHP variable so I can use the first option directly.

# ทางแรก: แอปนี้มี runner ต่ำสุดเป็น 8.3 -> รัน composer ด้วย 8.3
PHP=/usr/local/php83/bin/php bash laravel_composer_patch.sh apps.txt

# ทางที่สอง: ย้ายทุกตัวไป 8.4 ก่อน โดยชี้ binary เต็ม path ไม่ใช้ symlink กลาง
# crontab ของ webuser
* * * * * cd /home/webuser/app-a && /usr/local/php84/bin/php artisan schedule:run >> /dev/null 2>&1
# /etc/systemd/system/app-a-queue.service (เฉพาะส่วนสำคัญ)
[Service]
User=webuser
WorkingDirectory=/home/webuser/app-a
ExecStart=/usr/local/php84/bin/php artisan queue:work --sleep=3 --tries=3
Restart=always
RestartSec=5

Personally, I choose the second option when I have time, because specifying the full path to the version I want removes the runner's dependence on the shared symlink, so someone changing that symlink next time will not affect this app, but if I need to finish patching vulnerabilities that day, the first option is safer; choose whichever fits, but whichever option I choose, I must not change the shared php symlink itself.

Do not fix this by changing the shared symlink

The /usr/local/bin/php symlink is used by other users' cron jobs on the same machine, so changing it to fix just my app creates the same problem for someone else.

platform-check false only silences the message

composer config platform-check false makes the message disappear, but packages may actually use 8.4 syntax that PHP 8.2 cannot parse, causing the app to fail partway through with a parse error instead, which is much harder to track down; I do not recommend it as a fix.

Post-update steps and alerts

Even before this incident, my patch script already backed up vendor, composer.json and the lock file for each app, updated only packages flagged by audit within the ranges allowed by composer.json, reloaded php-fpm, checked HTTP, and automatically restored the originals if a page changed from below 500 to 500 or above, but its gap was that it checked only the app's main URL, so these are the steps I will use from now on.

  1. Run list_runners.sh for every app first and record which PHP version each runner uses
  2. Choose a PHP version for composer that matches the oldest in the group, or finish moving every runner first
  3. Back up the app's vendor, composer.json and lock file, then run update only for packages flagged by audit
  4. Check HTTP on the main domain and every subdomain pointing to the same code
  5. Watch the worker's journalctl for at least a few minutes after restarting, and wait for one cron cycle
  6. If any runner fails, restore vendor from the backup and return to step 2
Steps for every composer update1 Find runnersEvery user crontabsystemd unitsSubdomain overridesusing the app path2 Choose PHPUse the oldest inthe runner group, ormove all to 8.4before proceeding3 composer updateBack up vendor andcomposer.json/lockUpdate only packagesflagged by audit4 Check all pathsMain domain HTTPand every subdomainWorker journalctlRestore on failureOriginal script's gap: checked only the main site URLThe other three paths (subdomain, cron, worker) went uncheckedDo 1 and 2 before 3, or failures occur before the runners are known
Steps 1 and 2 were missing; the original script only had step 3 and a single URL check in step 4

The HTTP check should loop through every URL associated with the app, rather than just one.

for u in https://example.com/ https://sub1.example.com/ https://sub2.example.com/; do
  printf '%s %s\n' "$(curl -sk -o /dev/null -w '%{http_code}' --max-time 20 "$u")" "$u"
done

As for the missing alert, the worker failed for nearly an hour without anyone knowing because systemd restarted it immediately, leaving no persistent failed status to see; the regular check should include a restart counter.

n=$(systemctl show -p NRestarts --value app-a-queue.service)
[ "${n:-0}" -gt 3 ] && echo "[WARN] app-a-queue restarted $n times"
Before and after, with one key trying to open four doors with different keyholes compared with every door fitted with the same type of lock
Before: updating with a single 8.4 key left three other doors with different keyholes unable to open; after: inspect every door first, then fit all of them with the same type of lock

What I took away

I had done nothing wrong with Composer itself, which worked exactly as designed; my mistake was assuming that an app had only one entry point, the website, when it actually had at least four, and running several PHP versions side by side meant those entry points did not necessarily use the same version.

The points I am keeping for future use are

  • platform_check.php is rewritten on every update, and its minimum comes from the package requiring the highest PHP version in the lock file, which depends on the PHP version used to run update.
  • Everything loading the same vendor directory, including the main domain's FPM, subdomain FPM, cron and systemd, must meet the same requirement.
  • Before update, find every runner; after update, check every entry point, rather than only the main site.
  • Point cron and ExecStart to the full binary path for the intended version, rather than a shared symlink that others also use.
  • If a supervisor restarts a process immediately, do not assume silence means everything is normal; there must be a restart counter or an alert for repeated restarts.

I have not yet added automatic runner discovery and checks for every URL to the patch script, so for now I still run list_runners.sh manually before each update; if I have missed an explanation 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