# Moving Clinic to cPanel — clinic.tmsoagency.com

Written 2026-09-08 for a **move**, not a copy: cPanel becomes the only live
system and this Laragon box is retired. The domain stays
`clinic.tmsoagency.com`, which is what keeps the licence valid and keeps the QR
codes on already-printed reports resolving.

Same shape as the Life Associate deployment on `kashif.tmsoagency.com`: Laravel
on cPanel, subdomain document root pointed at `public/`.

**Where the two differ today, measured rather than assumed:** the `tmsoagency.com`
zone is on Cloudflare, but `kashif` is *proxied* (orange cloud) while `clinic` is
**DNS-only** — a plain A record to this box's public IP, `186.190.220.98`, served
by its own Apache with no CDN in front. TTL is 300 seconds, so a cutover
propagates in about five minutes.

That means the cutover is a one-value A-record change, and that turning the
Cloudflare proxy *on* for `clinic` is a separate decision with its own
consequence — see the client-IP check in the verification list.

---

## Read this first

**Three things must travel unchanged or data is lost, not merely inconvenienced.**

| | Why |
|---|---|
| **`APP_KEY`** | Encrypts `issued_documents.verification_code`, `issued_documents.token` and `patients.national_id`. A new key does not fail loudly — it silently makes every verification link dead and every national ID unreadable. Copy the existing value; do not run `key:generate`. |
| **`storage/app/private/keys/`** | The ECDSA key that signed the 6 issued documents. Lose it and previously issued documents cannot be re-verified. |
| **`storage/app/private/`** (29 MB) | Patient documents, portal uploads, and the filed report PDFs whose SHA-256 is what makes a forged report detectable. |

**What is small:** the database is 13.7 MB across 79 tables. Patient files are
29 MB. Nothing here is a big transfer.

**Do not upload `vendor/` (229 MB) or `node_modules/` (92 MB).** Composer runs
on the server; the built assets are 1.4 MB and go up as files.

---

## What you will need that this document does not contain

cPanel login, the MySQL password you create there, and Cloudflare DNS access.
Those are yours — none of them appear in this file or in the repository.

---

## Phase 0 — before the window (no downtime, do it unhurried)

The site keeps running normally throughout this phase.

### 1. cPanel: subdomain and document root

Create the subdomain `clinic` on `tmsoagency.com` with its document root at:

```
/home/<cpanel-user>/clinic/public
```

The application lives in `~/clinic`, and only `public` is web-reachable. Putting
the document root at `~/clinic` instead would expose `.env`, `storage/` and the
signing key to anybody who guessed a URL.

### 2. cPanel: PHP version and extensions

PHP **8.2 or newer** (`composer.json` requires `^8.2`; this box runs 8.3.30).

Required extensions, taken from `composer check-platform-reqs` rather than from
memory: `ctype`, `date`, `dom`, `fileinfo`, `filter`, `hash`, `iconv`, `json`,
`libxml`, `mbstring`, `openssl`, `pcre`, `session`, `simplexml`, `tokenizer`.

Plus three the packages do not declare but the product needs at runtime:
`pdo_mysql` (the database), `gd` (renders the QR codes) and `zip` (writes the
backup archives).

**`intl` is not required** — an earlier draft of this document listed it, but
nothing in the dependency tree asks for it and no code here uses `Collator`,
`NumberFormatter` or `IntlDateFormatter`. Do not switch it on to satisfy this
file.

**Verified on the target 2026-09-08:** PHP 8.3 with every one of the above
already enabled.

#### Do not run this on cPanel's PHP 8.3 — check the MySQL client first

The extension list is not the whole question. **Which MySQL client the PHP build
is linked against matters more, and it is not visible from `php -m`.**

On this server, `ea-php83` and `alt-php83` are built against **libmysqlclient
10.5.5** while the database server is **MariaDB 11.4**. The binary
prepared-statement protocol changed between those versions, and the old client
mis-parses the result set: every column of a fetched row comes back wrong. Not
empty, not an error — *wrong*. A hundred characters of one column's encrypted
value arrived as the value of another, and the page 500'd on an enum cast.

It cost several hours because everything else looked perfect. The database was
correct in phpMyAdmin, which uses a different client. The code and the Laravel
build were byte-identical to the working box. Only the application's own reads
were corrupt.

Measured on the target, all against the same row:

| Build | PHP | Client | Reads rows correctly |
|---|---|---|---|
| `ea-php83` | 8.3.31 | libmysqlclient 10.5.5 | **no** |
| `alt-php83` | 8.3.31 | libmysqlclient 10.5.5 | **no** |
| `ea-php82` | 8.2.31 | mysqlnd 8.2.31 | yes |
| `ea-php84` | 8.4.21 | mysqlnd 8.4.21 | yes |

**The domain is set to `ea-php84` for this reason.** Before changing it, or
before deploying to any other cPanel account, check the client:

```php
$pdo = DB::connection()->getPdo();
echo $pdo->getAttribute(PDO::ATTR_CLIENT_VERSION), ' vs ',
     $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
```

A `mysqlnd` client is safe. A `libmysqlclient` older than the server's major
version is not. `PDO::ATTR_EMULATE_PREPARES => true` also works around it — the
text protocol is unaffected — but that is a workaround for a broken build, not
a fix, and it changes parameter binding everywhere. Move to a `mysqlnd` build
instead.

**The scheduler cron must use the same binary.** `/usr/local/bin/php` is the
server default (8.3) regardless of what the domain is set to, so a cron written
against it runs the nightly backups and audit verification through the broken
client. Use the full path: `/opt/cpanel/ea-php84/root/usr/bin/php`.

### 3. cPanel: database

Create a database and a user, grant all privileges. One account is fine on
cPanel; the runtime/migration split on this box exists because it is also a
developer machine.

### 4. Code

Already on GitHub, so clone rather than upload:

```bash
cd ~
git clone https://github.com/GT-IMRAN/clinic.git clinic
cd clinic
composer install --no-dev --optimize-autoloader
```

If the host has no SSH, download the repository zip and upload it, then run
Composer from cPanel's Terminal or its PHP-Composer tool.

### 5. Built assets

cPanel usually has no Node, and **`public/build/` is not in the repository** —
`.gitignore` line 33 excludes it. An earlier draft of this document said the
build was committed and came with the clone; it does not, and a deployment that
trusts that arrives with no CSS and no JavaScript at all.

So the built assets are always a separate step. Build here, upload the
directory, and never try to run Vite on the server:

```bash
npm run build          # on this machine
tar -czf public-build.tar.gz -C public build
# upload, then: tar -xzf public-build.tar.gz -C ~/clinic/public
```

The same applies to a release package: anything that ships a new stylesheet has
to carry a fresh `public/build/`, because cloning or pulling will not produce
one.

### 6. `.env`

Copy `.env.example`, then set:

```dotenv
APP_ENV=production
APP_DEBUG=false
APP_URL=https://clinic.tmsoagency.com
APP_KEY=            # ← copy verbatim from this box's .env. Do not regenerate.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=<cpanel_db>
DB_USERNAME=<cpanel_user>
DB_PASSWORD=<the one you just set>

SESSION_DRIVER=database
SESSION_SECURE_COOKIE=true
SESSION_ENCRYPT=true
QUEUE_CONNECTION=database
CACHE_STORE=file
FILESYSTEM_DISK=local
LOG_CHANNEL=stack
LOG_LEVEL=error

BACKUP_ENCRYPTION_KEY=   # ← copy from this box, or last month's backups become unreadable
```

There is no `mysql_migrations` connection to recreate — that split is specific
to this machine.

### 7. Patient files and keys

Prepared already, in `C:\laragon\backups\transfer\`:

| File | What |
|---|---|
| `storage-private.tar.gz` (25 MB) | patient files, portal uploads, issued PDFs, **the signing key**, licence token |
| `public-build.tar.gz` (0.6 MB) | built CSS/JS, in case the clone is missing them |
| `clinic-rehearsal.sql` (5.7 MB) | database snapshot for Phase 0 testing only — **replaced by a fresh dump in Phase 1** |

Upload and extract into `~/clinic/storage/app/private`:

```bash
tar -xzf storage-private.tar.gz -C ~/clinic/storage/app/private
```

It must end up containing `keys/`, `patients/`, `issued-documents/`,
`portal-quarantine/` and `license/`.

> **Use `tar`, not Windows "Send to → Compressed folder", if you ever rebuild
> these.** PowerShell's `Compress-Archive` writes Windows backslashes into the
> entry names, and Linux unzip treats a backslash as an ordinary character — so
> instead of a `keys/` directory you get one flat file literally called
> `keys\document-signing.key`, and every patient file lands in the root. The
> archives above were rebuilt with `tar` for exactly this reason and verified:
> signing key present, 21 issued PDFs, 66 patient files, all with forward
> slashes.

Then:

```bash
php artisan storage:link
chmod -R 775 storage bootstrap/cache
```

Confirm `~/clinic/storage/app/private/keys/document-signing.key` exists and is
**not** reachable at `https://clinic.tmsoagency.com/storage/keys/...`. It should
404. (This audit closed a route that used to serve exactly that directory; the
config is correct in the repository, so this is a confirmation, not a fix.)

### 8. Rehearsal import

Take a dump from this box and import it, so the whole thing can be tested before
any freeze:

```bash
# on this box
mysqldump --single-transaction --no-tablespaces -u clinic_app -p clinic > clinic-rehearsal.sql
```

Test the new install by pointing your own machine's `hosts` file at the cPanel
IP for `clinic.tmsoagency.com`, so real users are unaffected. Work through the
verification list below. When it passes, you are ready for the window — and the
rehearsal data gets replaced by the real dump in Phase 1.

---

## Phase 1 — the freeze window (this is the downtime)

Pick a time with no clinic activity. Expect 15–30 minutes.

**1. Stop writes on the old box.** This is the step that prevents a patient
record being created after the dump and then vanishing:

```bash
php artisan down --render="errors::503"
```

Also disable the `Clinic-Scheduler` scheduled task, so nothing writes behind you.

**2. Final dump, with the site already down:**

```bash
mysqldump --single-transaction --no-tablespaces -u clinic_app -p clinic > clinic-final.sql
```

**3. Sync any patient files** added since the Phase 0 upload — re-zip
`storage/app/private` and replace.

**4. Import on cPanel**, replacing the rehearsal data:

```bash
mysql -u <cpanel_user> -p <cpanel_db> < clinic-final.sql
php artisan migrate --force        # should report nothing pending
php artisan config:cache
```

`config:cache` is correct **here** and wrong on the Laragon box. The reason is
in `docs/DEPLOYMENT.md`: that box is also the test working copy, and a cached
config makes the test suite retarget the live database. cPanel does not run the
suite, so caching is safe and worth having.

**Do not run `route:cache`.** Cached routes hide the module routes — the module
kernel registers them at boot — and the site loses most of itself.

---

## Phase 2 — cutover

**1. Cloudflare DNS:** change the `clinic` A record from `186.190.220.98` to the
cPanel IP. TTL is 300s, so allow about five minutes.

Leave it **DNS-only** (grey cloud) for the cutover, which is how it runs today —
one variable at a time. Turning the proxy on afterwards is worth doing for the
DDoS and TLS benefits, but do it as a separate step and re-run the client-IP
check below, because behind a proxy the app sees Cloudflare's address unless the
host restores the visitor's.

**2. SSL:** issue the certificate in cPanel (AutoSSL) so the origin is HTTPS
too. Cloudflare SSL mode should be **Full (strict)**.

**3. Bring it up:** `php artisan up` on cPanel.

**4. Scheduler cron.** The Windows task is gone; cPanel needs its replacement.
Without this there are no backups, no audit verification, nothing:

```
* * * * * cd /home/<cpanel-user>/clinic && /opt/cpanel/ea-php84/root/usr/bin/php artisan schedule:run >> /dev/null 2>&1
```

The full binary path is deliberate, not pedantry — see the MySQL client warning
in step 2. `/usr/local/bin/php` is the *server* default and ignores the domain's
PHP setting, so writing the cron that way runs every backup and every audit
verification through the client that reads rows wrong.

**5. Backups.** Set `mysqldump_path` — the cPanel path is usually
`/usr/bin/mysqldump`, not the bare `mysqldump` this box uses. Then finish the
off-site bucket in **Settings → Backups**, which is still outstanding.

---

## Verification — do all of these before telling anyone it has moved

The first four are the ones that catch a broken `APP_KEY`, which is the failure
that looks fine until a patient turns up with a report.

- [ ] Sign in as an administrator.
- [ ] Open a lab report. **Then scan its QR code**, or visit `/v/{token}` — it
      must say the document is valid. If verification fails, `APP_KEY` did not
      travel correctly; stop and fix that before anything else.
- [ ] Open a patient with a national ID recorded — it must be readable, not
      empty or garbled.
- [ ] Download a patient document from a patient record.
- [ ] `php artisan audit:verify --from=147` → chain intact.
- [ ] `php artisan system:verify` → environment, database, storage, cache all OK.
- [ ] Licence page still shows activated. The domain is unchanged, so it should;
      if not, `php artisan license:verify`.
- [ ] Register a test patient, then delete it. Proves writes and MRN sequencing.
- [ ] **Check the recorded IP.** Make any audited action, then look at the newest
      `audit_logs` row. It must show *your* IP, not a Cloudflare one
      (`104.x`, `172.6x`, `162.15x`). If it shows Cloudflare's, the host is not
      restoring the visitor IP, and two things are quietly wrong: the audit trail
      records the wrong origin for every action, and the per-IP login throttle
      lumps every visitor together. Fix by enabling the host's Cloudflare/
      `mod_remoteip` module, or by adding trusted proxies to the application.
- [ ] Confirm `https://clinic.tmsoagency.com/storage/keys/document-signing.key`
      returns **404**.

---

## Phase 3 — retire this box

Only after the verification list passes, and after a day or two of real use.

1. Leave the Laragon vhost stopped and the `Clinic-Scheduler` task disabled.
2. **Keep the `clinic` database on this box, untouched, for at least 30 days.**
   It is the rollback.
3. Keep `C:\laragon\backups\daily\` until off-site backups on cPanel have run
   successfully for a week — otherwise retiring this box also retires the only
   backups that exist.

---

## Rollback

Valid until DNS is pointed away and, after that, until somebody enters a patient
record on cPanel.

- **Before any real use of the new site:** point Cloudflare back at this box,
  `php artisan up`, re-enable the scheduled task. Nothing was lost because
  nothing was written.
- **After real use:** rolling back means losing whatever was entered on cPanel.
  Dump the cPanel database first and decide deliberately — do not roll back
  reflexively.

---

## What is *not* carried over, on purpose

- `mysql_migrations` / `clinic_migrate` — specific to this developer box.
- `.env.testing` and the test database.
- The Windows scheduled tasks, replaced by cron.
- `vendor/` and `node_modules/`, rebuilt on the server.
