# Installing Clinic for a new customer

For a customer's **own** cPanel account, which we do not control and usually
cannot get SSH on. Written 2026-09-10 from the one deployment we have actually
done — `docs/DEPLOY-CPANEL.md` covers *moving* an existing clinic and is not
this.

Budget an hour the first time. Most of it is waiting for uploads.

---

## What you need before you start

| | |
|---|---|
| cPanel login for the customer's account | theirs; ask for it or have them drive |
| A licence key for this customer | created on the licence server first — an install with no key runs, but activation is one less thing to do later |
| A first-install package built here | `php tools/build-release.php <version> --install`; see step 3 |

Nothing here needs anything from another clinic. **Do not copy an `APP_KEY`,
signing key or `.env` from an existing installation** — that is only correct when
*moving* a clinic. A new clinic generates its own during install.

---

## 1. Subdomain

Create the subdomain with its document root at:

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

The application lives in `~/clinic` and only `public` is web-reachable. A
document root at `~/clinic` exposes `.env`, `storage/` and the document signing
key to anyone who guesses a URL.

**Check this before you upload anything, and check it again afterwards.** A
subdomain cPanel creates for you points at the folder itself, not at `public`
inside it — so on 2026-09-11 a new install spent three quarters of an hour
serving a directory listing of the entire application, `.env` included, before
anyone looked. Domains → the subdomain → Manage → New Document Root. When it is
right, `https://…/` redirects to the sign-in page and `https://…/.env` answers
403.

## 2. PHP version — check the MySQL client, not just the version number

PHP **8.2 or newer**. Extensions: `ctype`, `date`, `dom`, `fileinfo`, `filter`,
`hash`, `iconv`, `json`, `libxml`, `mbstring`, `openssl`, `pcre`, `session`,
`simplexml`, `tokenizer`, plus `pdo_mysql`, `gd` (QR codes) and `zip` (backup
archives). `intl` is **not** needed.

**Then check which MySQL client that PHP build is linked against.** This is the
trap that cost us most of a day on our own install and it is invisible in
`php -m`:

```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 database server's
major version is not: the binary prepared-statement protocol changed, and the
old client returns **every column of a row wrong**. Not empty, not an error —
wrong. One column's encrypted value arrives as another's and the page dies on a
cast. phpMyAdmin looks perfect throughout, because it uses a different client.

On our host `ea-php83` and `alt-php83` were both broken against MariaDB 11.4;
`ea-php82` and `ea-php84` were fine. Pick a build that reports `mysqlnd`.

## 3. The code

The release packages on the licence server have **no `vendor/`** in them — they
are built for updating an installation that already has one. A first install
has nothing, and on a host with no shell there is no way to run Composer.

So build a first-install package here instead. It is the same tree with the
dependencies already in it, built from the committed lock file without the dev
ones:

```bash
npm run build
php tools/build-release.php 1.0.4 --install
```

That writes `_deploy/clinic-1.0.4-install.zip` — about 22 MB, 13,500 files.
Upload it through cPanel's File Manager, extract into `~/clinic`, and Composer
never has to run on their server at all.

**Do not publish that file to the licence server.** It is not a release; there
it would be handed to every existing clinic as an update.

**If the host does give you a shell**, cloning works too and is easier to
update by hand later:

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

Whichever route: if you ever build an archive by hand, use `tar` or PHP's
`ZipArchive` and never Windows "Send to → Compressed folder", which writes
backslashes into the entry names — on Linux you get one flat file called
`app\Models\User.php` instead of directories.

## 4. Built assets

**Already done if you used the install package** — it carries them.

For a clone, they are missing: `public/build/` is **not** in the repository
(`.gitignore`), so a clone arrives with no CSS and no JavaScript at all. Build
here and upload:

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

Never try to run Vite on the server. cPanel has no Node.

## 5. Database and `.env`

Create a database and a user in cPanel and grant all privileges.

**cPanel puts the account name in front of both.** A database you name
`drnoman` is really `rackqtar_drnoman`, and so is the user. Copy the full names
from the "Current Databases" table rather than typing what you asked for — a
missing prefix produces `Access denied for user`, which reads like a wrong
password and is not.

**Set the password by copy-and-paste in both places, in one sitting.** Type it
once into cPanel's Change Password form and paste that same string into `.env`,
or paste what is already in `.env` into the form. Three attempts were lost on
2026-09-11 to the two ends drifting apart. And avoid `#` entirely: `.env` treats
everything after one as a comment, so `DB_PASSWORD=Abc#123` is silently read as
`Abc`.

Then copy `.env.example` to `.env` and set:

```dotenv
APP_ENV=production
APP_DEBUG=false
APP_URL=https://clinic.their-domain.com

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=<cpanel_db>
DB_USERNAME=<cpanel_user>
DB_PASSWORD=<what 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
```

Leave `APP_KEY` empty — the installer generates one. It encrypts national IDs
and document verification codes, so once there is data it can never be changed;
generating it now, on their server, is the whole point.

`BACKUP_ENCRYPTION_KEY` should be set before the first backup runs. Any long
random string; record it where you record their other credentials, because
backups taken with it are unreadable without it.

Then permissions:

```bash
chmod -R 775 storage bootstrap/cache
```

## 6. Set APP_KEY before anything else — nothing runs without it

**`php artisan key:generate` cannot do this on a fresh install.** The
application resolves the encrypter while it boots, so with an empty `APP_KEY`
*every* artisan command dies at startup with "No application encryption key has
been specified" — including the command whose job is to create the key, and
including `clinic:install`. Learned the hard way on 2026-09-11.

Have the server write one itself, so the key is never typed or copied:

```bash
cd ~/clinic && KEY=$(php -r 'echo "base64:".base64_encode(random_bytes(32));') \
  && sed -i "s|^APP_KEY=.*|APP_KEY=$KEY|" .env
```

Run that **once**. Never leave it in a repeating cron: a second run replaces the
key, and after that every encrypted column — national IDs, document
verification codes — is unreadable.

## 7. Install

In principle one command does the schema, the modules, the seed data, the first
administrator and the licence:

```bash
php artisan clinic:install --license=LA-XXXX-XXXX-XXXX
```

It refuses to run on an installation that already has accounts, and it does not
write `.env` or cache anything.

**The administrator password must satisfy the clinic's own policy**, which this
enforces: at least **10 characters**, with an upper-case letter, a lower-case
letter and a digit. A symbol is not required. From a cron entry that rejection
is visible only in the log, so it is worth getting right first time.

**No shell at all? Split it in two.** Cron has no terminal, so nothing can be
prompted for and every value must be an option — and the half that needs no
password is worth running on its own first, because it is the half that takes
minutes and fails in interesting ways:

```
*/5 * * * * cd /home/USER/clinic && /opt/cpanel/ea-php84/root/usr/bin/php artisan migrate --force >> /home/USER/install.log 2>&1 && /opt/cpanel/ea-php84/root/usr/bin/php artisan module:sync >> /home/USER/install.log 2>&1 && /opt/cpanel/ea-php84/root/usr/bin/php artisan db:seed --force >> /home/USER/install.log 2>&1 && /opt/cpanel/ea-php84/root/usr/bin/php artisan storage:link >> /home/USER/install.log 2>&1
```

Then the administrator on its own, and **delete that entry the moment it
succeeds** — the password sits in the crontab file in clear until you do:

```
*/5 * * * * cd /home/USER/clinic && /opt/cpanel/ea-php84/root/usr/bin/php artisan install:admin --name="Dr Name" --email="admin@their-domain.com" --password="TenCharsMin1" --must-change >> /home/USER/install.log 2>&1
```

Then the licence, which needs no password:

```
cd /home/USER/clinic && /opt/cpanel/ea-php84/root/usr/bin/php artisan license:activate LA-XXXX-XXXX-XXXX
```

Three things about those lines. Use the **full path to the PHP build you chose**
— `/usr/local/bin/php` is the server default and may be the broken one from step
2. `%` is a line separator in crontab, not a character, so a password containing
one silently truncates the command. And a **new cron does not fire at its first
tick**: one added at 10:27 skipped 10:30, 10:35 and 10:40 and first ran at
10:45. Wait fifteen minutes before concluding it is broken.

**cPanel's own API is not available from cron.** `uapi` exists in the jail but
cannot reach `/usr/local/cpanel/cpanel`, so anything you were hoping to automate
through it — setting a database password, for instance — has to be done by hand
in the cPanel interface.

## 7. Scheduler

One cron entry, every minute, same PHP binary. This is what runs the licence
heartbeat, the nightly backups, the audit verification and the daily update
check:

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

Without it the clinic still works, and everything unattended silently does not.

## 8. Config cache

```bash
php artisan config:cache
```

Correct on a customer's server (it is not a developer machine, so the trap
described in `docs/DEPLOYMENT.md` does not apply). Updates clear it themselves
from release 1.0.2 onward. Do **not** run `route:cache` — a cached route table
hides module routes on this codebase.

---

## Verification — before telling them it is ready

```bash
php artisan system:verify
```

FAIL means stop. Then by hand:

- sign in as the administrator
- **Settings → System info** shows the licence as active
- a QR code on a printed document resolves at `/verify` — this is the public
  page and the one most likely to be broken by a document root mistake
- `https://.../storage/keys/document-signing.key` returns **404**
- **Settings → Mail** → send a test email to yourself

---

## What the clinic sets up itself

None of this is ours to fill in, and all of it is in Settings:

| Screen | Why it matters on day one |
|---|---|
| **Mail** | their own SMTP server. Until it is set, password resets go to a log file and nobody is told |
| **Backups** | their own S3 or R2 bucket. Until it is set, there is no off-site copy of the database |
| **Branding** | name, logo and colours on every document they print |
| **General** | timezone, currency, date format |
| **Numbering** | the shape of MRNs, invoice and prescription numbers |

Mail and Backups are the two that fail silently. Set them during the handover,
with the customer watching, rather than leaving them on a list.

---

## What this does not cover

- **Migrating an existing clinic onto a new host.** That is
  `docs/DEPLOY-CPANEL.md`, and it has the opposite rule about `APP_KEY`.
- **Multiple branches.** The install creates one primary branch; further
  branches are added in the application.
- **A web installer.** There isn't one. Every step above needs a shell or a
  cron entry, and `clinic:install` is what a web installer should call rather
  than reimplement.
