diff --git a/docs/project-state.md b/docs/project-state.md index d347161..cacffb6 100644 --- a/docs/project-state.md +++ b/docs/project-state.md @@ -80,6 +80,26 @@ nginx gateway, with the four build traps and the "5x faster than typing" busines - **Done when:** ≥2 gotcha posts live (these are `notes`/`devops`, no Chinese translation required per §8). - ⚠ **Note:** `post-guideline.md` §8 (newer) says *every* post gets a ZH twin — the "no Chinese required" note above is stale. The RDPGuard post was published EN + ZH. +**Step B2b — Draft bank (written, held as `draft: true`, publish when content runs short).** ✅ Drafted 2026-09-20 +Two finished posts (EN + ZH, each with frontmatter pointing at OG + banner paths) sitting in the repo but +**not built or listed** — `draft: true` excludes them from all listings and generates no pages. + +| Slug | Category | Status | Assets | +|---|---|---|---| +| `migrating-codeigniter-iis-to-openlitespeed` | `engineering` | drafted, unpublished | OG + banner PNGs **not yet generated** | +| `upgrading-codeigniter-46-to-47` | `notes` | drafted, unpublished | OG + banner PNGs **not yet generated** | + +**To publish one later:** +1. Flip `draft: true` → `draft: false` in **both** `src/content/posts/.md` and `src/content/posts/zh/.md`. +2. Set the real `pubDate` (currently `2026-09-20`, the draft date) in both files. +3. Generate its images: `node scripts/og-gen/generate.mjs ` and `node scripts/banner-gen/generate.mjs ` (add a `TERMINALS[slug]` / `BANNERS[slug]` entry first for the custom panel). +4. `npm run build`, commit, `git push origin main`. +5. Verify both URLs return 200 and the language switcher links them. + +- **Why these two:** `engineering` had only 1 post and `tutorials` only 1 — the blog was ~all `devops`/`case-studies`. These put PHP/CodeIgniter (the actual day-job stack) on the blog, which is what a PHP full-stack recruiter searches for. +- **Governing doc:** `content-guide.md` §3/§4, `post-guideline.md` §8. +- **Done when:** both are published live with EN+ZH, custom OG + banner, and verified 200. + **Step B3 — Adopt the "hard job → post" habit.** Every solved problem becomes a `notes` entry the same week. - [ ] Revisit cadence target: 2 posts/month → 1/week (`content-guide.md` §5). diff --git a/src/content/posts/migrating-codeigniter-iis-to-openlitespeed.md b/src/content/posts/migrating-codeigniter-iis-to-openlitespeed.md new file mode 100644 index 0000000..46112f7 --- /dev/null +++ b/src/content/posts/migrating-codeigniter-iis-to-openlitespeed.md @@ -0,0 +1,240 @@ +--- +title: "Migrating a CodeIgniter 4 App From IIS to OpenLiteSpeed" +description: "I moved a 17,000-line CodeIgniter 4 app from Windows IIS to OpenLiteSpeed on CyberPanel. Two fatal errors appeared that IIS had been hiding behind SSO." +pubDate: 2026-09-20 +category: engineering +tags: [codeigniter, php, openspeedway, cyberpanel, iis, migration, litespeed, linux] +ogImage: /og/migrating-codeigniter-iis-to-openlitespeed.png +banner: /banners/migrating-codeigniter-iis-to-openlitespeed.png +draft: true +--- + +I maintain a numerology report generator: a CodeIgniter 4 application that takes a birth date and a name, runs them through a numerology engine, and renders a 19-page A4 report. It had run on Windows Server with IIS for years. In September 2026 I moved it to OpenLiteSpeed on CyberPanel, on Linux, on a different domain. + +The migration itself was unremarkable — copy the files, point the docroot at `public/`, install dependencies. What made it worth writing about is that **the first unauthenticated request to the new host fatally crashed on two routes that had been working fine on IIS for years.** + +Both bugs were real. Both were present on IIS the whole time. Neither was visible, because IIS had an authentik SSO gate in front of the exact routes that were broken. + +## Why this matters + +If you run a self-hosted app behind an authentication gateway, your auth layer is doing more than protecting data — it is also **hiding your bugs**. Anything that only breaks for unauthenticated users never runs unauthenticated, so it never fails. The failure surfaces at the worst possible moment: when you migrate, remove the gate, or expose the route to the public internet. + +In my case the app had been reporting "healthy" for as long as I'd owned it. The moment I pointed a new domain at it, two routes returned HTTP 500. Nothing had changed in the code. The only thing that changed was that the gate was gone. + +The fix for both took about twenty minutes. Finding them took two hours, because the actual error messages were hidden behind CodeIgniter's CLI error renderer. + +## The setup + +The app is a fairly ordinary CI4 project with an unusual architecture detail: the report engine is not a library, it is a **set of HTTP endpoints on the same host**. The controllers generate a request, `curl` it back to `/api/single` on their own domain, and the engine returns JSON with the computed numbers. The controller then renders that into the printable report view. + +That self-call design is what made `CONST_IIS_INTERNAL_BASE` necessary on IIS. Let me come back to it — it turns out to be the interesting part. + +| | Before | After | +|---|---|---| +| OS | Windows Server | Ubuntu (CyberPanel) | +| Web server | IIS | OpenLiteSpeed 1.9.0 | +| PHP | 8.4 (Windows build) | 8.4.25 (lsphp84) | +| Docroot | `C:\inetpub\calc.hoelee.com` | `/home//public_html/public` | +| Auth gate | authentik SSO on `/lifecode`, `/api/*` | none | + +## Step 1: Point the docroot at `public/`, not the project root + +CI4 ships with a two-folder layout. `app/`, `vendor/`, `writable/` and `.env` live in the project root. Only `public/` is meant to be web-accessible. + +CyberPanel creates the document root as `public_html`. My first instinct was to extract the whole project into `public_html` and leave the docroot alone. + +**That would have exposed `app/`, `vendor/`, the `.env` file, and `writable/` session data over HTTP.** The `.env` in this project contains a webhook credential. Anyone who guessed `/../.env` — or just fetched `/.env`, since the file sits directly under a served directory — gets it. + +The correct layout is to extract into `public_html` and then repoint the vhost docroot one level deeper, at `public_html/public`: + +```bash +# extract the project so that app/, vendor/ and .env sit UNDER public_html +cd /home//public_html +tar -xzf /tmp/deploy.tar.gz + +# the docroot must be public_html/public, NOT public_html +sudo sed -i 's#/home//public_html\$#/home//public_html/public#' \ + /usr/local/lsws/conf/vhosts//vhost.conf + +sudo /usr/local/lsws/bin/lshttpd -t +sudo systemctl restart lsws +``` + +This is the single most important step in the migration, and it is the one most guides skip. If you do nothing else, do this. + +## Step 2: The two fatals IIS was hiding + +### Fatal 1 — `env()` called too early + +The first 500 had no body at all. That is unusual — CI4 normally renders something. An empty 500 usually means PHP died before the framework's error handler was installed. + +CI4's bootstrap loads `app/Config/Constants.php` very early in `Boot::bootWeb()`, via `Boot::loadConstants()`. That happens **before** the `Common.php` helper file is loaded, which is where `env()` is defined. + +So this line: + +```php +// app/Config/Constants.php — BROKEN +define('CONST_fullBase', env('hoelee.fullBase')); +``` + +fails with: + +``` +Fatal error: Uncaught Error: Call to undefined function env() +in app/Config/Constants.php:96 +``` + +The rule is absolute: **`Constants.php` may only contain plain constants.** No `env()`, no `getenv()`, no config helper. If you need environment-dependent values there, define them further along the bootstrap — or make the constant a fallback and read the real value later. + +That is exactly what I did. `CONST_fullBase` became a plain string, and the helper that consumes it checks the framework's own `app.baseURL` (which *is* `.env`-driven) first: + +```php +// app/Helpers/hoelee_helper.php +function getFullBase(bool $selfCall = false): string +{ + // the .env-driven baseURL wins; CONST_fullBase is only a fallback + if ($selfCall && defined('CONST_IIS_INTERNAL_BASE') && CONST_IIS_INTERNAL_BASE) { + return rtrim(CONST_IIS_INTERNAL_BASE, '/'); + } + $appBase = config('App')->baseURL; + if ($appBase) return rtrim($appBase, '/'); + if (defined('CONST_fullBase') && CONST_fullBase) return rtrim(CONST_fullBase, '/'); + return ''; +} +``` + +**This one was my own doing.** I had introduced it during a secrets-cleanup refactor in the same week — I moved a hardcoded URL into `.env` and called `env()` in `Constants.php` to read it. It worked on my machine because the local `.env` was being read through a different path. It never worked on a clean boot. I caught it within an hour *only because* I deployed and tested; a code review would plausibly have waved it through. + +### Fatal 2 — `parent::__construct()` in a CI4 controller + +The second failure was on `/lifecode`, and this one is a genuine pre-existing bug in the application — not something I introduced. + +The error, once I got past the CLI renderer, was: + +``` +Error: Cannot call constructor +``` + +CI4's base `CodeIgniter\Controller` class **has no constructor**. It implements `initController()`, which the framework calls with the request, response, and logger objects. The standard pattern is: + +```php +// correct CI4 pattern +public function initController( + RequestInterface $request, + ResponseInterface $response, + LoggerInterface $logger +) { + parent::initController($request, $response, $logger); + // your setup here +} +``` + +But `Lifecode.php` and `ApiEn.php` declared a classic constructor and called `parent::__construct()`: + +```php +// BROKEN — CodeIgniter\Controller has no __construct() +public function __construct() +{ + parent::__construct(); + // ... +} +``` + +`parent::__construct()` on a parent class that does not define `__construct()` is a fatal in PHP 8. Converting both controllers to `initController()` fixed it — and required adding the three `use` statements for the interface types in the new signature. + +I checked whether the base class *really* had no constructor rather than trusting the error message: + +```bash +grep -n 'function __construct\|function initController' \ + vendor/codeigniter4/framework/system/Controller.php +``` + +Only `initController()` came back. Worth doing — "Cannot call constructor" also fires when a parent *has* a constructor that errors internally, and you want to know which case you are in before you rewrite the signature. + +## Why neither bug showed up on IIS + +This is the part that changed how I think about the deployment. + +On the IIS host, `/lifecode` and `/api/*` sat behind authentik SSO. An unauthenticated request to either returned **HTTP 302 to `auth.hoelee.com`** — it never reached the controller at all. The broken constructor was never executed. The route had presumably been broken since it was written, and it had never once been asked to serve a request. + +I confirmed this by comparing the two hosts directly: + +```bash +curl -sI https://calc.hoelee.com/lifecode | head -1 +# HTTP/2 302 <- authentik redirect; controller never runs + +curl -sI http:///lifecode | head -1 +# HTTP/1.1 500 <- no gate; controller runs and fatals +``` + +The 302 is why the bug survived. The app looked healthy because the unhealthy parts were unreachable. + +**The lesson, stated plainly: an auth gate in front of a route means that route has no working test coverage for its own code.** If you migrate or remove the gate, budget time to hit every previously-gated route unauthenticated before you call the migration done. On a small app that is a ten-line `curl` loop. Here it would have found both bugs in seconds instead of two hours: + +```bash +for p in / /read/single /read/partner /lifecode /api/date /api/single; do + printf '%-16s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://$p)" +done +``` + +## Step 3: The self-call that stops being a loopback + +Now the interesting architectural consequence. + +The report engine is reached by the controller `curl`-ing its own host. On IIS, `CONST_IIS_INTERNAL_BASE` pointed that call at `http://localhost:7296` — the loopback interface — so the request never left the machine and never touched TLS or DNS. + +On LiteSpeed that constant is deliberately left **undefined**, so `getFullBase()` falls through to `app.baseURL`. Which means every report generation now makes a **real outbound HTTPS request to the app's own public URL** and comes back in through Cloudflare. + +Which raises the obvious question: does that work at all? + +The answer is yes, but it is worth testing explicitly, because the failure mode is confusing. Here is the exact test — run it **on the server**, not from your laptop: + +```bash +curl -s -o /dev/null -w 'HTTPS self-call: %{http_code}\n' \ + -X POST 'https:///api/single' -d 'nameCn=test&dob=1990-01-01' +``` + +Two things to watch for: + +- **Run it from the server.** From my Windows machine the same URL returned 404 and 500 — an artefact of DNS resolution and Cloudflare bot protection on my caller, not a server problem. Testing from the wrong host produces confident, wrong conclusions. +- **Watch the JSON, not just the status code.** A 200 with a truncated body is worse than a clean failure. I checked the response was parseable before trusting it. + +There is a real trade-off hiding here, and I have not fully resolved it: a public-URL self-call means report generation depends on Cloudflare being up, costs a TLS handshake per report, and occupies two PHP workers for the duration. On LiteSpeed with a small worker pool, a burst of concurrent reports could deadlock — each request waiting on another request that has no free worker. For the current traffic level it is fine. Before this scales, the self-call should move to an internal path that bypasses the CDN. + +## Step 4: Verify the actual output, not the status code + +A 200 on `/` proves the landing page renders. It does not prove the report engine works, and the engine is the entire product. So the last step was generating a real report end to end: + +```bash +curl -s -X POST 'https:///read/single' \ + -d 'nameCn=test&dob=1990-01-01&gender=m' \ + -o report.html -w 'HTTP:%{http_code} bytes:%{size_download}\n' +``` + +The result: **HTTP 200, 99,189 bytes, 19 A4 pages.** I ran it again with Chinese-character input to exercise the UTF-8 path through the engine — HTTP 200, 99,205 bytes, 19 pages, name rendering correctly, zero PHP warnings in the output. + +Byte-comparable page counts between the old and new host is the check that matters. It proves the fonts, the DPI, and the pagination logic all survived the move. + +A side note on the UTF-8 test: my first attempt at it returned a 500 and I nearly went bug-hunting. The cause was my own shell — the Chinese characters were being mangled by the terminal encoding on the way into `curl`, so the app received invalid bytes. Running the same request from a script on the server, where the encoding is under my control, returned 200. **Before you debug an encoding failure, confirm the bytes actually arriving at the server are the bytes you meant to send.** + +## What I'd do differently + +**Test unauthenticated routes before migrating, not after.** The whole two-hour debugging session was avoidable with a `curl` loop over the route list. I now treat "list every route, hit it unauthenticated, record the status" as step zero of any migration. + +**Do not call `env()` in `Constants.php`.** I have left a comment in the file itself saying so, because the next person — probably me in six months — will be tempted. + +**Check the framework version's project-space config before the move.** The same week, I upgraded CI4 from 4.6.3 to 4.7.4, and it fataled twice on properties the upgrade guide did not mention: `Config\App::$permittedURIChars`, required by the 4.7 Router, and `Config\Format::$jsonEncodeDepth`, required by the JSONFormatter — the second of which broke the engine's JSON self-call specifically. Composer updates `vendor/` but never merges `app/Config/*.php`, because those are project-space files. I ended up merging new properties into 14 config files. That is a separate post, but the migration lesson is the same shape: **the framework tells you what changed in `vendor/`; nothing tells you what changed in `app/`.** + +**Confirm the docroot before anything else.** If I had extracted into `public_html` and stopped, the app would have worked — and quietly served `.env` over HTTP. A migration that works is not the same as a migration that is safe. + +## The result + +One evening. Six routes verified 200, byte-comparable report output on both Chinese and ASCII input, zero PHP warnings, and two long-standing latent bugs removed from the codebase that IIS had been concealing behind an auth gate. + +The app is now running on OpenLiteSpeed with a properly separated docroot, on a host I can automate, with the credential in `.env` rather than a constant. + +--- + +**Want this done for your own application?** I migrate PHP applications between IIS, Apache, nginx and LiteSpeed — including the awkward parts: self-calling architectures, SSO gates that hide bugs, and framework upgrades that touch project-space config. + +[WhatsApp +60 12-797 2969](https://wa.me/60127972969) · [me@hoelee.com](mailto:me@hoelee.com?subject=CodeIgniter%20migration) · [hoelee.com](https://hoelee.com) diff --git a/src/content/posts/upgrading-codeigniter-46-to-47.md b/src/content/posts/upgrading-codeigniter-46-to-47.md new file mode 100644 index 0000000..ada4e96 --- /dev/null +++ b/src/content/posts/upgrading-codeigniter-46-to-47.md @@ -0,0 +1,162 @@ +--- +title: "Upgrading CodeIgniter 4.6 to 4.7: The Breaking Changes the Guide Misses" +description: "The official CodeIgniter 4.7 upgrade guide lists eight breaking changes. The two that actually fataled my app weren't in it — and both are the same class of problem." +pubDate: 2026-09-20 +category: notes +tags: [codeigniter, php, upgrade, composer, breaking-changes, config, framework] +ogImage: /og/upgrading-codeigniter-46-to-47.png +banner: /banners/upgrading-codeigniter-46-to-47.png +draft: true +--- + +I upgraded a CodeIgniter 4 app from 4.6.3 to 4.7.4. I read the upgrade guide, checked every documented breaking change against the codebase, and confirmed none of them applied. Then I ran the app and it fataled twice. + +Both fatal errors were the same kind of problem, and that kind of problem is **not mentioned anywhere in the upgrade guide**. This is a short post about what actually breaks and why the guide can't warn you. + +## Why this matters + +Composer has a scope boundary that is easy to forget. `composer update` writes to `vendor/` — the **system scope**, the framework's own files, which it owns and can replace freely. + +Your `app/Config/*.php` files are **project scope**. They are yours. Composer will never touch them, and the upgrade guide describes them as "you may want to merge these changes" rather than "this will break". + +But when 4.7's framework code reads a property that your 4.6-era config class does not define, you get a fatal — not a deprecation, not a warning. The guide cannot enumerate this, because the set of properties is the intersection of *what the new framework reads* and *what your config file happens to contain*, and it has no visibility into the second half. + +If you are upgrading a CI4 app more than a minor version behind, budget for this. It took me about an hour and 14 config files. + +## What I checked first + +The documented breaking changes in 4.7.0, and whether each applied: + +| Documented change | Affects this app? | +|---|---| +| `Model::insertBatch()` / `updateBatch()` return values | No — no Models used | +| Entity casting changes | No — no Entities | +| Validation rule changes (`regex_match`, `differs`) | No | +| Encryption handler defaults | No | +| Uploaded-file / image validation behaviour | No — no uploads | +| `IncomingRequest` internal changes | No | +| Removed `Session` class properties | No — see below | +| `PageCache` constructor signature | No — see below | + +Two of those needed a closer look, and both turned out to be **false alarms** worth describing, because they look identical to a real hit if you only grep: + +**"Removed `Session` class properties"** — grepping for `session` in my configs lit up immediately. But the properties in question (`sessionDriver`, `sessionCookieName`, and friends) on my `Config\App` are **`Config\App`'s own properties**, which were not removed. The guide refers to properties on the `Session` class. Same word, different class. + +**"`PageCache` constructor signature changed"** — I reference `pagecache` — but only as a filter alias in `Config\Filters`. That does not extend or instantiate the class, which is the only thing that breaks. A reference is not a subclass. + +Both checks taught me the same thing: **grep finds *mentions*, not *usages*.** Verify the class in the match is the class the guide means, and that you actually extend or call the thing that changed. + +## The two fatals that were not in the guide + +Both surfaced on a clean boot. Both are properties that 4.7 framework code reads and 4.6-era config files do not define. + +### 1. `Config\App::$permittedURIChars` + +``` +Undefined property: Config\App::$permittedURIChars +``` + +Required by 4.7's Router, which validates incoming URI characters against this property. Absent from a 4.6-era `App.php`, the Router throws before it routes anything. + +```php +// app/Config/App.php — append to the class +/** + * CI4 4.7 compatibility: allowed characters in a URI. + * Required by the 4.7 Router. + */ +public string $permittedURIChars = 'a-z 0-9~%.:_\-'; +``` + +### 2. `Config\Format::$jsonEncodeDepth` + +``` +Undefined property: Config\Format::$jsonEncodeDepth +``` + +Required by 4.7's `JSONFormatter`. This one broke the app's **report engine**, because the engine's JSON response is decoded with `json_decode()` and the failure surfaced much further downstream as a confusing parse error rather than the real "undefined property" message. + +```php +// app/Config/Format.php — append to the class +/** + * CI4 4.7 compatibility: json_encode() depth limit. + */ +public int $jsonEncodeDepth = 512; +``` + +That second one is worth dwelling on. The visible error was: + +``` +Failed to parse JSON string. Malformed UTF-8 characters +``` + +Which points at encoding. The actual cause was a missing config property. **If a framework error names a symptom rather than a cause, go looking for the real exception in the log before acting on the message you were given** — I lost time chasing UTF-8 that was never broken. + +## How to find the rest before they bite you + +Both fatal errors were found by *running* the app. Running is the reliable method, but you can get ahead of it by diffing property coverage. + +The approach: parse the framework's shipped config defaults from `vendor/`, parse your project's config classes, and list properties defined in the former but missing from the latter. That gives you the whole class of problem at once instead of one fatal per restart. + +```python +import re, pathlib + +sys_ = pathlib.Path('vendor/codeigniter4/framework/system/Config') +app_ = pathlib.Path('app/Config') + +def props(path): + try: + src = path.read_text(encoding='utf-8', errors='replace') + except FileNotFoundError: + return set() + # public/protected/private $name = ... + return set(re.findall(r'(?:public|protected|private)\s+(?:[\w\\\[\]|?]+\s+)?\$(\w+)', src)) + +# names whose properties 4.7 requires but a stale config may omit +for name in ['App', 'Cache', 'ContentSecurityPolicy', 'CURLRequest', 'DocTypes', + 'Email', 'Encryption', 'Exceptions', 'Format', 'Honeypot', + 'Migrations', 'Paths', 'Routing', 'Toolbar', 'View']: + missing = props(sys_ / f'{name}.php') - props(app_ / f'{name}.php') + if missing: + print(name, '->', sorted(missing)) +``` + +Run it against the **new** `vendor/` after `composer update` and before you boot the app. Anything it prints is a candidate fatal. + +In this project it flagged 14 config files. I merged the missing properties into each one, appended in a marked block at the end of the class so existing settings and behaviour were untouched: + +```php + // ---------------------------------------------------------------- + // CI4 4.7 compatibility — properties the 4.7 framework reads. + // Appended as a block so existing settings above are unchanged. + // ---------------------------------------------------------------- +``` + +Two config files the framework now ships had no counterpart at all and needed creating rather than merging: `Hostnames.php` and `WorkerMode.php`. Copy those straight from `vendor/codeigniter4/framework/app/Config/`. + +## Also worth knowing before you upgrade + +**Validate `app.baseURL` properly.** 4.7 throws a `ConfigException` on values 4.6 accepted. `'http://localhost/'` — which had been sitting in my local `.env` for months, working fine — now throws. Production was unaffected because it used a real URL, but any dev environment with a shorthand baseURL will refuse to boot. + +**The security case is real.** 4.7.4 alone patched an uploaded-file extension bypass (`is_image` / `mime_in`), a SQL injection in `deleteBatch()`, and a path traversal in `UploadedFile::move()`. None were exploitable in this app — it does no uploads and uses no database — but all three become live the moment those features are added. Upgrading before you need those features is the cheaper order. + +**PHP floor is now 8.2.** Check your host before starting, not after. + +## What I'd do differently + +**Run the app against the new `vendor/` before doing anything else.** `composer update`, then boot it, then fix what breaks. Reading the guide tells you what *might* change; only running tells you what *did*. + +**Do the property diff as a matter of course.** It converts a serial debugging session into one report. + +**Do the upgrade as its own commit, on its own.** I did, and it made the two fatals trivially bisectable — one `git show` told me exactly which change introduced each. Mixing a framework bump into a feature branch turns a five-minute diagnosis into an archaeology exercise. + +**Read the changelog, not just the upgrade guide.** The guide lists what the maintainers judged *likely* to break you. The changelog lists what changed. They are not the same set, and the gap is exactly where these two fatals lived. + +## The result + +4.6.3 → 4.7.4 on a 17,000-line CodeIgniter app: 14 config files merged, two new config files added, two fatals found and fixed, and every route verified returning 200 — including a real report generation producing 19 A4 pages with byte-comparable output to the pre-upgrade baseline. Zero errors in the application log. + +--- + +**Running an older CodeIgniter app?** I do framework upgrades like this one — CI4 minor bumps, PHP version migrations, and the config-merging work that the upgrade guide assumes you will figure out yourself. + +[WhatsApp +60 12-797 2969](https://wa.me/60127972969) · [me@hoelee.com](mailto:me@hoelee.com?subject=CodeIgniter%20upgrade) · [hoelee.com](https://hoelee.com) diff --git a/src/content/posts/zh/migrating-codeigniter-iis-to-openlitespeed.md b/src/content/posts/zh/migrating-codeigniter-iis-to-openlitespeed.md new file mode 100644 index 0000000..2a1e3a9 --- /dev/null +++ b/src/content/posts/zh/migrating-codeigniter-iis-to-openlitespeed.md @@ -0,0 +1,240 @@ +--- +title: "把 CodeIgniter 4 应用从 IIS 迁移到 OpenLiteSpeed" +description: "我把一个 17,000 行的 CodeIgniter 4 应用从 Windows IIS 搬到了 CyberPanel 上的 OpenLiteSpeed。迁移后冒出两个致命错误——而 IIS 一直靠 SSO 把它们藏着。" +pubDate: 2026-09-20 +category: engineering +tags: [codeigniter, php, openspeedway, cyberpanel, iis, migration, litespeed, linux] +ogImage: /og/migrating-codeigniter-iis-to-openlitespeed.png +banner: /banners/migrating-codeigniter-iis-to-openlitespeed.png +draft: true +--- + +我维护着一个数字命理报告生成器:一个 CodeIgniter 4 应用,接收出生日期和姓名,跑一遍命理引擎,渲染出 19 页 A4 报告。它在 Windows Server + IIS 上跑了很多年。2026 年 9 月,我把它迁到 CyberPanel 上的 OpenLiteSpeed,跑在 Linux 上,换了个域名。 + +迁移本身没什么好说的——拷文件、把 docroot 指向 `public/`、装依赖。真正值得写下来的是:**新主机上第一个未认证请求,就在两条路由上直接致命崩溃——而这两条路由在 IIS 上"一直好好的"。** + +两个 bug 都是真的。两个从一开始就存在于 IIS 上。两个都看不见,因为 IIS 在出问题的路由前面恰好挡了一层 authentik SSO。 + +## 为什么这件事重要 + +如果你的自托管应用前面挂了一层认证网关,那这层网关做的事不止是保护数据——它同时**在帮你藏 bug**。任何"只对未认证用户出错"的东西永远不会以未认证身份运行,所以永远不会报错。而它会在最糟的时刻暴露:你迁移的时候、你拆掉网关的时候、你把路由暴露到公网的时候。 + +我这个应用,就"健康"了整整几年。我把新域名指过去的那一刻,两条路由返 HTTP 500。代码一行没改。唯一变的是网关没了。 + +两个修复加起来大概二十分钟。找到它们花了两个小时——因为真正的错误信息被 CodeIgniter 的 CLI 错误渲染器挡在了后面。 + +## 环境 + +这是一个相当普通的 CI4 项目,但有一个不太常见的架构细节:报告引擎不是库,而是**同一台主机上的 HTTP 端点**。控制器拼一个请求,`curl` 回自己域名的 `/api/single`,引擎返回算好的 JSON 数值,控制器再把它渲染成可打印的报告。 + +正是这个"自调用"设计,让 IIS 上必须有 `CONST_IIS_INTERNAL_BASE`。这点我后面会回来说——它才是真正有意思的部分。 + +| | 迁移前 | 迁移后 | +|---|---|---| +| 操作系统 | Windows Server | Ubuntu (CyberPanel) | +| Web 服务器 | IIS | OpenLiteSpeed 1.9.0 | +| PHP | 8.4 (Windows 版) | 8.4.25 (lsphp84) | +| Docroot | `C:\inetpub\calc.hoelee.com` | `/home/<域名>/public_html/public` | +| 认证网关 | authentik SSO 挡 `/lifecode`、`/api/*` | 无 | + +## 第一步:docroot 指向 `public/`,不是项目根目录 + +CI4 是两段式目录结构。`app/`、`vendor/`、`writable/` 和 `.env` 都在项目根目录,只有 `public/` 本该对 Web 可见。 + +CyberPanel 建的站点根目录叫 `public_html`。我的第一反应是把整个项目解压进 `public_html`,docroot 不动。 + +**那样会把 `app/`、`vendor/`、`.env` 文件和 `writable/` 的 session 数据全部暴露在 HTTP 之下。** 这个项目的 `.env` 里有一个 webhook 凭证。任何人猜到 `/../.env`——或者干脆直接请求 `/.env`,因为这文件就躺在被服务的目录下面——就拿到了。 + +正确做法是解压进 `public_html`,然后把 vhost 的 docroot 往下一层,改成 `public_html/public`: + +```bash +# 解压成 app/、vendor/、.env 位于 public_html 之下 +cd /home/<域名>/public_html +tar -xzf /tmp/deploy.tar.gz + +# docroot 必须是 public_html/public,不是 public_html +sudo sed -i 's#/home/<域名>/public_html\$#/home/<域名>/public_html/public#' \ + /usr/local/lsws/conf/vhosts/<域名>/vhost.conf + +sudo /usr/local/lsws/bin/lshttpd -t +sudo systemctl restart lsws +``` + +这是整个迁移里最重要的一步,也是大多数教程跳过的一步。其他什么都不做,这一步也得做。 + +## 第二步:IIS 藏起来的两个致命错误 + +### 致命错误一 —— `env()` 调用得太早 + +第一个 500 完全没有响应体。这不太寻常——CI4 正常会渲染点东西出来。一个空白的 500 通常意味着 PHP 在框架的错误处理器装上之前就死了。 + +CI4 的启动流程会在 `Boot::bootWeb()` 里很早加载 `app/Config/Constants.php`(通过 `Boot::loadConstants()`)。而这件事发生在 **`Common.php` 助手文件加载之前**——`env()` 就定义在那个文件里。 + +所以这一行: + +```php +// app/Config/Constants.php —— 错误写法 +define('CONST_fullBase', env('hoelee.fullBase')); +``` + +会失败于: + +``` +Fatal error: Uncaught Error: Call to undefined function env() +in app/Config/Constants.php:96 +``` + +规则是绝对的:**`Constants.php` 里只能放普通常量。** 不能有 `env()`、不能有 `getenv()`、不能有配置助手。如果你真需要依赖环境的值,就定义到启动流程更靠后的位置——或者把这个常量降级成 fallback,真正的值后面再读。 + +我做的正是后者。`CONST_fullBase` 变回普通字符串,消费它的助手函数改为**先**查框架自己的 `app.baseURL`(那个才是 `.env` 驱动的): + +```php +// app/Helpers/hoelee_helper.php +function getFullBase(bool $selfCall = false): string +{ + // .env 驱动的 baseURL 优先;CONST_fullBase 只是 fallback + if ($selfCall && defined('CONST_IIS_INTERNAL_BASE') && CONST_IIS_INTERNAL_BASE) { + return rtrim(CONST_IIS_INTERNAL_BASE, '/'); + } + $appBase = config('App')->baseURL; + if ($appBase) return rtrim($appBase, '/'); + if (defined('CONST_fullBase') && CONST_fullBase) return rtrim(CONST_fullBase, '/'); + return ''; +} +``` + +**这个是我自己搞出来的。** 同一周做凭证清理重构时引入的——我把一个硬编码 URL 挪进 `.env`,然后在 `Constants.php` 里用 `env()` 读它。在我机器上是通的,因为本地 `.env` 走的是另一条路径被读到。干净启动下它从来没通过。我是在**部署测试**后一小时内抓到的;如果只是代码审查,很可能就这么放过去了。 + +### 致命错误二 —— CI4 控制器里的 `parent::__construct()` + +第二个失败在 `/lifecode`。这个是应用里**原本就存在**的 bug,不是我引入的。 + +绕过 CLI 渲染器之后,真正的报错是: + +``` +Error: Cannot call constructor +``` + +CI4 的基础类 `CodeIgniter\Controller` **根本没有构造函数**。它实现的是 `initController()`,由框架把 request、response、logger 三个对象传进去。标准写法是: + +```php +// CI4 的正确写法 +public function initController( + RequestInterface $request, + ResponseInterface $response, + LoggerInterface $logger +) { + parent::initController($request, $response, $logger); + // 你的初始化代码 +} +``` + +但 `Lifecode.php` 和 `ApiEn.php` 用的是传统构造函数,还在调 `parent::__construct()`: + +```php +// 错误 —— CodeIgniter\Controller 没有 __construct() +public function __construct() +{ + parent::__construct(); + // ... +} +``` + +对一个没有定义 `__construct()` 的父类调 `parent::__construct()`,在 PHP 8 里是致命错误。把两个控制器改成 `initController()` 就修好了——同时要为新签名补上三个 interface 的 `use` 语句。 + +我没有盲信报错信息,而是先确认父类**确实**没有构造函数: + +```bash +grep -n 'function __construct\|function initController' \ + vendor/codeigniter4/framework/system/Controller.php +``` + +只出来 `initController()`。这一步值得做——"Cannot call constructor" 在父类**有**构造函数但内部报错时也会出现,重写签名之前你得先搞清楚自己是哪种情况。 + +## 为什么这两个 bug 在 IIS 上都不出现 + +这是让我改变部署观念的部分。 + +在 IIS 主机上,`/lifecode` 和 `/api/*` 挡在 authentik SSO 后面。对这两条路由的未认证请求返回 **HTTP 302 跳转到 `auth.hoelee.com`**——压根不会碰到控制器。那个坏掉的构造函数从来没被执行过。这条路由大概从写出来那天就是坏的,而它从来没被要求真正处理过一次请求。 + +我直接对比了两台主机来确认: + +```bash +curl -sI https://calc.hoelee.com/lifecode | head -1 +# HTTP/2 302 <- authentik 跳转;控制器根本没运行 + +curl -sI http://<新主机>/lifecode | head -1 +# HTTP/1.1 500 <- 没有网关;控制器运行并致命崩溃 +``` + +那个 302 就是这个 bug 活下来的原因。应用看起来健康,只是因为它不健康的部分根本触不到。 + +**教训说白了就是:一条路由前面挂了认证网关,就意味着这条路由自己的代码没有任何有效的测试覆盖。** 如果你要迁移或者拆网关,请专门留出时间,用未认证身份把每一条曾被网关挡着的路由都打一遍,再宣布迁移完成。小应用上这只是十行 `curl` 循环。在我这里,它本来几秒钟就能找出这两个 bug,而不是两个小时: + +```bash +for p in / /read/single /read/partner /lifecode /api/date /api/single; do + printf '%-16s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://<主机>$p)" +done +``` + +## 第三步:那个不再是 loopback 的自调用 + +现在是这个架构里真正有意思的后果。 + +报告引擎是靠控制器 `curl` 自己的主机来调用的。在 IIS 上,`CONST_IIS_INTERNAL_BASE` 把这个调用指向 `http://localhost:7296`——loopback 接口,请求不出机器,也不碰 DNS 和 TLS。 + +在 LiteSpeed 上这个常量被**故意不定义**,于是 `getFullBase()` 落到 `app.baseURL`。这意味着每一次生成报告,现在都是**一个真实的对外 HTTPS 请求,打到应用自己的公网 URL,再从 Cloudflare 绕回来**。 + +那问题就来了:这到底能不能跑? + +答案是能,但值得专门测一次,因为它的失败模式很容易误导人。这是确切的测法——**在服务器上跑**,不是在你的笔记本上: + +```bash +curl -s -o /dev/null -w 'HTTPS self-call: %{http_code}\n' \ + -X POST 'https://<域名>/api/single' -d 'nameCn=test&dob=1990-01-01' +``` + +两个要注意的地方: + +- **在服务器上跑。** 我从 Windows 机器上打同一个 URL,拿到的是 404 和 500——那是我这台机器 DNS 解析和 Cloudflare 机器人防护的产物,不是服务器的问题。在错误的主机上测试,会得出很有把握的错误结论。 +- **看 JSON,不只看状态码。** 一个 200 配一个被截断的 body,比干脆失败更糟。我在信任之前先确认了响应能被解析。 + +这里藏着一个真实的取舍,我还没完全解决:走公网 URL 的自调用意味着生成报告依赖 Cloudflare 在线、每次报告多一次 TLS 握手、并且在整个过程中占用两个 PHP worker。在 LiteSpeed 上 worker 池不大的情况下,并发报告一多就可能死锁——每个请求都在等另一个请求,而后者拿不到空闲 worker。以目前的流量没问题。但在规模上去之前,这个自调用应该换成绕过 CDN 的内部路径。 + +## 第四步:验证真实产出,不是状态码 + +`/` 返 200 只证明落地页能渲染。它不证明报告引擎能用,而引擎才是整个产品。所以最后一步是端到端生成一份真报告: + +```bash +curl -s -X POST 'https://<域名>/read/single' \ + -d 'nameCn=test&dob=1990-01-01&gender=m' \ + -o report.html -w 'HTTP:%{http_code} bytes:%{size_download}\n' +``` + +结果:**HTTP 200、99,189 字节、19 页 A4。** 我又用中文字符输入跑了一遍,把引擎的 UTF-8 路径也走通——HTTP 200、99,205 字节、19 页、姓名渲染正常、输出里零 PHP 警告。 + +真正有意义的检查,是新旧主机之间页数可比。它证明字体、DPI 和分页逻辑都完整地熬过了这次搬迁。 + +关于那个 UTF-8 测试的插曲:我第一次跑的时候返回了 500,差点就一头扎进去查 bug。原因是我自己的 shell——中文字符在进 `curl` 的路上被终端编码搞坏了,应用收到的是非法字节。把同一个请求写成服务器上的脚本再跑,编码在我控制之内,就返回了 200。**在你调试编码故障之前,先确认服务器真正收到的字节就是你打算发出去的字节。** + +## 如果重来一次,我会怎么改 + +**迁移前就测未认证路由,而不是迁移后。** 那两个小时的排查,用一个遍历路由列表的 `curl` 循环就能完全避免。我现在把"列出所有路由、以未认证身份逐个请求、记录状态码"当成任何迁移的第零步。 + +**不要在 `Constants.php` 里调 `env()`。** 我在文件里留了注释专门写这件事,因为下一个人——多半是六个月后的我自己——一定会想这么干。 + +**迁移前先查框架版本对应的 project-space 配置。** 同一周我把 CI4 从 4.6.3 升到 4.7.4,它在两个升级指南里根本没提的属性上致命崩溃了两次:`Config\App::$permittedURIChars`(4.7 的 Router 要求)和 `Config\Format::$jsonEncodeDepth`(JSONFormatter 要求,而第二个正好打断了引擎的 JSON 自调用)。Composer 会更新 `vendor/`,但**永远**不会合并 `app/Config/*.php`,因为那些属于 project-space。我最后往 14 个配置文件里手工合并了新属性。那是另一篇文章,但迁移的教训是同一个形状:**框架会告诉你 `vendor/` 里改了什么;没有任何东西会告诉你 `app/` 里改了什么。** + +**先确认 docroot,再动别的。** 如果我解压进 `public_html` 就收手,应用是能跑的——同时静静地把 `.env` 通过 HTTP 服务出去。能跑的迁移,不等于安全的迁移。 + +## 结果 + +一个晚上。六条路由全部验证 200,中英文输入的报告产出在字节层面可比,零 PHP 警告,另外还从代码库里清掉了两个长期潜伏的 bug——而这两个 bug 一直被 IIS 的认证网关遮着。 + +应用现在跑在 OpenLiteSpeed 上,docroot 正确分离,主机可自动化管理,凭证放在 `.env` 而不是写死在常量里。 + +--- + +**想让你的应用也做这套迁移?** 我可以在 IIS、Apache、nginx 和 LiteSpeed 之间迁移 PHP 应用——包括那些麻烦的部分:自调用架构、藏 bug 的 SSO 网关,以及会碰到 project-space 配置的框架升级。 + +[WhatsApp +60 12-797 2969](https://wa.me/60127972969) · [me@hoelee.com](mailto:me@hoelee.com?subject=CodeIgniter%20迁移) · [hoelee.com](https://hoelee.com) diff --git a/src/content/posts/zh/upgrading-codeigniter-46-to-47.md b/src/content/posts/zh/upgrading-codeigniter-46-to-47.md new file mode 100644 index 0000000..85042ee --- /dev/null +++ b/src/content/posts/zh/upgrading-codeigniter-46-to-47.md @@ -0,0 +1,162 @@ +--- +title: "CodeIgniter 4.6 升级到 4.7:官方指南没说到的破坏性变更" +description: "CodeIgniter 4.7 官方升级指南列了八项破坏性变更。真正让我应用致命崩溃的那两项不在里面——而且它们属于同一类问题。" +pubDate: 2026-09-20 +category: notes +tags: [codeigniter, php, upgrade, composer, breaking-changes, config, framework] +ogImage: /og/upgrading-codeigniter-46-to-47.png +banner: /banners/upgrading-codeigniter-46-to-47.png +draft: true +--- + +我把一个 CodeIgniter 4 应用从 4.6.3 升到了 4.7.4。我读了升级指南,把每一项文档化的破坏性变更都对着代码库核了一遍,确认没有一项影响到我。然后我跑了一下应用,它致命崩溃了两次。 + +两个致命错误属于同一类问题,而**升级指南里完全没提这类问题**。这是一篇短文,讲真正会坏的是什么,以及为什么指南没法提前警告你。 + +## 为什么这件事重要 + +Composer 有一条容易忘掉的作用域边界。`composer update` 只写 `vendor/`——也就是**系统作用域**,框架自己的文件,它可以随意替换。 + +而你的 `app/Config/*.php` 是**项目作用域**。那是你的文件。Composer 永远不会碰它们,升级指南对它们的措辞是"你可能想要合并这些变更",而不是"这会导致崩溃"。 + +但当 4.7 的框架代码去读一个属性,而你 4.6 时代的配置类里根本没定义它时,你得到的是致命错误——不是废弃警告,不是 notice。指南没法穷举这件事,因为这个属性的集合是「新框架读什么」与「你的配置文件里恰好有什么」的交集,而它对后半边完全不可见。 + +如果你要升级一个落后不止一个 minor 版本的 CI4 应用,请为此留出预算。我花了一个小时左右,改了 14 个配置文件。 + +## 我先核了什么 + +4.7.0 文档化的破坏性变更,以及每一项是否适用: + +| 文档化的变更 | 是否影响本应用 | +|---|---| +| `Model::insertBatch()` / `updateBatch()` 返回值变化 | 否——没用 Model | +| Entity 类型转换变更 | 否——没用 Entity | +| 验证规则变更(`regex_match`、`differs`) | 否 | +| 加密处理器默认值 | 否 | +| 上传文件/图片验证行为 | 否——没有上传 | +| `IncomingRequest` 内部变更 | 否 | +| 移除的 `Session` 类属性 | 否——见下文 | +| `PageCache` 构造函数签名 | 否——见下文 | + +其中两项需要细看,结果两个都是**假警报**,值得讲一下,因为你如果只 grep,它们看起来和真命中一模一样: + +**"移除的 `Session` 类属性"** —— 在配置里 grep `session`,立刻一片飘红。但那些属性(`sessionDriver`、`sessionCookieName` 之类)是我 `Config\App` **自己的属性**,并没有被移除。指南指的是 `Session` 类上的属性。同一个词,不同的类。 + +**"`PageCache` 构造函数签名变更"** —— 我确实引用了 `pagecache`,但只是把它作为 `Config\Filters` 里的一个 filter 别名。这并不等于继承或实例化这个类,而只有后者才会坏。**引用不等于子类。** + +这两次核对教给我同一件事:**grep 找到的是「提及」,不是「使用」。** 要确认命中的那个类是指南说的那个类,以及你确实继承或调用了那个发生变化的东西。 + +## 指南里没有的两个致命错误 + +两个都在干净启动时冒出来。两个都是 4.7 框架代码要读、而 4.6 时代的配置文件没有定义的属性。 + +### 1. `Config\App::$permittedURIChars` + +``` +Undefined property: Config\App::$permittedURIChars +``` + +4.7 的 Router 要求这个属性,用它校验进来的 URI 字符。4.6 时代的 `App.php` 里没有它,于是 Router 在路由任何东西之前就抛异常了。 + +```php +// app/Config/App.php —— 追加到类里 +/** + * CI4 4.7 compatibility: allowed characters in a URI. + * Required by the 4.7 Router. + */ +public string $permittedURIChars = 'a-z 0-9~%.:_\-'; +``` + +### 2. `Config\Format::$jsonEncodeDepth` + +``` +Undefined property: Config\Format::$jsonEncodeDepth +``` + +4.7 的 `JSONFormatter` 要求它。这一个直接打断了应用的**报告引擎**——因为引擎的 JSON 响应是用 `json_decode()` 解的,失败信号在很下游才爆出来,表现为一个让人一头雾水的解析错误,而不是真正的"属性未定义"。 + +```php +// app/Config/Format.php —— 追加到类里 +/** + * CI4 4.7 compatibility: json_encode() depth limit. + */ +public int $jsonEncodeDepth = 512; +``` + +第二个值得多说一句。表面上看到的错误是: + +``` +Failed to parse JSON string. Malformed UTF-8 characters +``` + +指向编码问题。真实原因是缺一个配置属性。**当框架的错误信息描述的是症状而不是病因时,先去日志里找真正的异常,再照着你拿到的那句话行动**——我在一个从来没坏过的 UTF-8 上白白耗了时间。 + +## 怎么在它们咬到你之前找出剩下的 + +两个致命错误都是靠**运行应用**发现的。运行是可靠的方法,但你可以先一步靠"属性覆盖差异对比"把这一类问题一次挖出来。 + +思路是:解析 `vendor/` 里框架自带的配置默认值,再解析你项目里的配置类,列出前者有、后者缺的属性。这样你一次拿到整类问题,而不是每重启一次收获一个致命错误。 + +```python +import re, pathlib + +sys_ = pathlib.Path('vendor/codeigniter4/framework/system/Config') +app_ = pathlib.Path('app/Config') + +def props(path): + try: + src = path.read_text(encoding='utf-8', errors='replace') + except FileNotFoundError: + return set() + # public/protected/private $name = ... + return set(re.findall(r'(?:public|protected|private)\s+(?:[\w\\\[\]|?]+\s+)?\$(\w+)', src)) + +# 4.7 需要、而陈旧配置可能漏掉的属性所在类 +for name in ['App', 'Cache', 'ContentSecurityPolicy', 'CURLRequest', 'DocTypes', + 'Email', 'Encryption', 'Exceptions', 'Format', 'Honeypot', + 'Migrations', 'Paths', 'Routing', 'Toolbar', 'View']: + missing = props(sys_ / f'{name}.php') - props(app_ / f'{name}.php') + if missing: + print(name, '->', sorted(missing)) +``` + +在 `composer update` **之后**、启动应用**之前**,拿新的 `vendor/` 跑一遍。它打印出来的任何东西都是潜在的致命错误。 + +在这个项目里它标出了 14 个配置文件。我把缺失的属性合并进每一个文件,以标记块的形式追加在类的末尾,这样上面原有的设置和行为完全不动: + +```php + // ---------------------------------------------------------------- + // CI4 4.7 compatibility — properties the 4.7 framework reads. + // Appended as a block so existing settings above are unchanged. + // ---------------------------------------------------------------- +``` + +框架新带出来的两个配置文件在项目里完全没有对应物,需要新建而不是合并:`Hostnames.php` 和 `WorkerMode.php`。直接从 `vendor/codeigniter4/framework/app/Config/` 拷过去就行。 + +## 升级前还值得知道的几点 + +**`app.baseURL` 的校验变严了。** 4.7 会对 4.6 接受的值抛 `ConfigException`。`'http://localhost/'`——这个值在我本地 `.env` 里躺了好几个月、一直好好的——现在直接抛异常。生产环境没受影响,因为它用的是真实 URL,但任何用了简写 baseURL 的开发环境都会拒绝启动。 + +**安全上的理由是真的。** 单是 4.7.4 就修了上传文件扩展名绕过(`is_image` / `mime_in`)、`deleteBatch()` 的 SQL 注入、以及 `UploadedFile::move()` 的路径穿越。这三个在本应用里都不可利用——它没有上传、也不碰数据库——但它们会在你加上这些功能的那一刻变成真实风险。**在你需要这些功能之前先升级,是更省事的顺序。** + +**PHP 最低版本现在是 8.2。** 动手前先确认你的主机,不是事后。 + +## 如果重来一次,我会怎么改 + +**先拿新的 `vendor/` 跑一遍应用,别的什么都别做。** `composer update`、启动、然后修坏掉的地方。读指南告诉你可能变什么;只有运行告诉你实际变了什么。 + +**把属性差异对比当成常规动作。** 它把一串串行的调试过程变成一份报告。 + +**把升级做成独立的一次提交。** 我就是这么做的,这让两个致命错误变得极易二分定位——一条 `git show` 就精确告诉我每个错误是哪次改动引入的。把框架升级混进功能分支,会把五分钟的诊断变成一场考古。 + +**读 changelog,不要只读升级指南。** 指南列的是维护者判断「可能会弄坏你」的东西。changelog 列的是实际变了的东西。这两者不是同一个集合,而这两个致命错误正好住在两者的缝隙里。 + +## 结果 + +一个 17,000 行的 CodeIgniter 应用从 4.6.3 升到 4.7.4:合并 14 个配置文件、新增 2 个配置文件、找到并修复 2 个致命错误,所有路由验证返回 200——包括一次真实的报告生成,产出 19 页 A4,与升级前的基线在字节层面可比。应用日志零错误。 + +--- + +**手上跑着旧版 CodeIgniter?** 我做这类框架升级——CI4 minor 版本升级、PHP 版本迁移,以及那些升级指南默认你自己会搞定的配置合并工作。 + +[WhatsApp +60 12-797 2969](https://wa.me/60127972969) · [me@hoelee.com](mailto:me@hoelee.com?subject=CodeIgniter%20升级) · [hoelee.com](https://hoelee.com)