# Deploying on shared cPanel hosting (InMotion Power)

Follow this instead of `docs/deployment.md` when the host is a shared cPanel
plan rather than a server you control. Everything in `docs/deployment.md` §3
("Six things about this build you must know first") still applies in full and
is not repeated here — read it once before you start.

This runbook ends where `docs/go-live.md` begins.

---

## What is different, and why

A shared plan cannot do three things this application's normal deploy assumes.
Each has a workaround; one has a cost you must tell your staff about.

| Assumption | On shared hosting | What is used instead |
|---|---|---|
| A supervised `queue:work` service (§8) | Persistent processes are killed | A cron line running `queue:work --stop-when-empty --max-time=55` every minute |
| `npm ci && npm run build` on the host (§6 step 4) | No Node at all, and no npm | `package-shared.sh` builds `public/build` and `vendor/` on your machine (needs Node `^20.19.0 \|\| >=22.12.0` there) |
| A document root you choose (§7) | cPanel serves `public_html` | A subdomain whose document root is set to `crm/public` |

**The one real cost:** a go-live import now starts within a minute instead of
instantly. `docs/go-live.md` §2 describes a dead worker as *"Import started and
then nothing happens"* — on this host there is a legitimate version of that
symptom lasting up to 60 seconds. Tell whoever runs go-live, or they will
spend an hour debugging a system that is working.

**The ceiling.** A shared plan is a CloudLinux container: a fraction of a CPU,
roughly a gigabyte of RAM, and a cap on concurrent PHP processes. Filament is
a heavy panel. Four or five staff at the counter is comfortable. The two
stress points are the go-live import and the 07:45 billing run, and if they
are slow there is no tuning lever — only a bigger plan.

---

## Step 1 — prove the host can run it

Do this before anything else. Upload `preflight-shared.sh` to your home
directory over SFTP and run it:

```bash
bash preflight-shared.sh
```

It checks PHP and its extensions, proves the database can do what the schema
needs (by running a stored generated column and a locking read, not by reading
a version string), and checks cron. It prints **GO** or **NO-GO** with reasons.

**Do not continue unless it says GO.** If PHP 8.3 or cron cannot be had on the
plan, this application needs a VPS — where `docs/deployment.md` applies exactly
as written, with no changes at all.

Two results deserve a decision rather than a fix:

- **MariaDB rather than MySQL.** If both behaviour checks pass it will very
  probably work, but the combination is untested with this application. A
  double-entry ledger is a poor place to find a difference.
- **No `mysql` client.** The script then says *GO, with the database
  unverified*. Run its two SQL blocks in phpMyAdmin before you deploy: the
  generated column must yield `2026-10/-`, the locking read must yield `42`.

---

## Step 2 — build the bundle on your machine

```bash
bash package-shared.sh
```

This writes `dist/crm-release-YYYYmmdd-HHMMSS.tar.gz` containing the
application with `vendor/` and `public/build/` already in place, and without
`.env`, `.git`, `node_modules`, `tests` or any dev dependency.

It builds `vendor/` in a staging copy, so your own `pest` and `pint` stay
installed, and it refuses to finish if a dev dependency reaches the bundle or
if `public/build/manifest.json` was not produced.

It builds from the **committed** tree. If you have uncommitted changes it says
so and asks before continuing.

---

## Step 3 — cPanel

Six things, in this order.

**3.1 Database.** *MySQL Databases* → create a database and a user, then **Add
User To Database** with **ALL PRIVILEGES**. Migrations create tables, alter
them, add generated columns and add foreign keys; a read-mostly grant passes
every preflight check and then fails on the first migration.

cPanel prefixes both names with your account, so `crm` becomes something like
`acct123_crm`. Copy both exactly.

Check the charset is `utf8mb4` / `utf8mb4_unicode_ci`. The migrations assume
it, and a restore into a differently-collated schema is a slow-burning problem.

**3.2 PHP.** *Select PHP Version* → **8.3**, then tick these extensions:

```
intl  zip  gd  bcmath  mbstring  xml  curl  exif  fileinfo  pdo_mysql
```

`intl` and `zip` are the two shared hosts most often leave off, and `zip` is a
hard requirement in `composer.json`.

**3.3 Upload limits.** *MultiPHP INI Editor* → set `upload_max_filesize` and
`post_max_size` to at least **32M**, for the go-live CSVs and document scans.

**3.4 Subdomain.** Create `crm.yourdomain.com` and set its **document root** to
`crm/public`.

This is the security-critical step. `.env` holds `APP_KEY`, which decrypts
every employee IBAN; `storage/` holds the document scans. Both sit one level
above `public/` and must never be reachable over HTTP. Setting the document
root is exact; a symlink from `public_html` also works but is easier to get
subtly wrong.

**3.5 SSH.** *SSH Access* → enable it and add your key.

**3.6 SSL.** *SSL/TLS Status* → run AutoSSL on the subdomain. The session
cookie is marked Secure, so on plain HTTP you will sign in and be bounced
straight back out. That is correct behaviour, not a bug.

---

## Step 4 — unpack

Upload the bundle to your home directory, then:

```bash
mkdir -p ~/crm
tar -xzf ~/crm-release-YYYYmmdd-HHMMSS.tar.gz -C ~/crm
```

`~/crm/public` must now be the path you set as the document root in 3.4.

---

## Step 5 — configure

```bash
cd ~/crm
cp .env.example.shared .env
chmod 600 .env
nano .env
```

Edit every line marked `EDIT`:

- `APP_URL` — the exact subdomain URL, `https`, no trailing slash
- `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` — the prefixed names from 3.1
- `MAIL_*` — or staff and customers receive nothing

Leave `APP_KEY` blank for now. Leave `DB_HOST=localhost` as it stands:
`localhost` makes PHP use the local socket, while `127.0.0.1` forces TCP,
which some cPanel hosts do not listen on at all.

---

## Step 6 — generate the key, once and only once

```bash
php artisan key:generate
```

**Copy the value into your password manager before you do anything else.**
There is no key history: a new key makes every stored employee IBAN
permanently unreadable, and nothing will fail until somebody opens a payroll
record. See `docs/deployment.md` §3.1 — it is the single most expensive
mistake available on this install.

Never run this again, and never as part of a deploy. `deploy-shared.sh` does
not, and refuses to run if `APP_KEY` is empty.

---

## Step 7 — create the schema and the reference data

```bash
php artisan migrate --force
```

Then the five seeders, **by class**. Never plain `php artisan db:seed` on a
production database — `DatabaseSeeder` would reach `DemoSeeder`, which writes a
fictional business centre into your ledger. (Two guards now refuse that, but
name the seeders anyway.)

```bash
php artisan db:seed --class=RolesAndPermissionsSeeder --force
php artisan db:seed --class=ChartOfAccountsSeeder --force
php artisan db:seed --class=AlertRuleSeeder --force
php artisan db:seed --class=UnitTypeSeeder --force
php artisan db:seed --class=AssetCategorySeeder --force
```

Without the first, nobody can be given a role and therefore nobody can sign
in at all. `docs/deployment.md` §6 step 6 has the table of what each one is
for.

---

## Step 8 — the first branch and the first user

The panel cannot be opened until both exist: a user needs a branch, so the
branch comes first. This is the only `tinker` this install needs.

```bash
php artisan tinker --execute '$b = App\Models\Branch::firstOrCreate(["code" => "HO"], ["name" => "Head Office", "emirate" => "dubai", "is_active" => true]); echo "branch ".$b->id." ".$b->code."\n";'
```

`emirate` must be one of the enum's backing values, lower case with
underscores: `abu_dhabi`, `dubai`, `sharjah`, `ajman`, `umm_al_quwain`,
`ras_al_khaimah`, `fujairah`. `"Dubai"` throws.

Then the user:

```bash
php artisan crm:create-admin
```

It asks for the name, email and branch, then the password **twice at a hidden
prompt**. There is deliberately no `--password` option: an option would sit in
your shell history and be visible in `ps` to every other account on the box
while the command ran — which on shared hosting is not a theoretical concern.

Everyone else is created in the panel under **Setup → Staff**, never on the
server.

---

## Step 9 — cache

```bash
php artisan optimize
```

Once configuration is cached, `env()` returns `null` everywhere outside
`config/`. That is a Laravel rule, and this application already respects it.

---

## Step 10 — the two cron lines

*Cron Jobs* in cPanel. Use the **absolute** PHP path — cron gets no login
`PATH`. `preflight-shared.sh` printed the right one for your host; it is
usually `/opt/cpanel/ea-php83/root/usr/bin/php`.

**The scheduler.** Without it nobody is invoiced, no lease ever expires, and no
expiry alert is ever sent:

```
* * * * * cd ~/crm && /opt/cpanel/ea-php83/root/usr/bin/php artisan schedule:run >> ~/logs/crm-schedule.log 2>&1
```

**The queue worker.** Without it every go-live import says *Import started* and
nothing happens, and no payment reminder is ever sent:

```
* * * * * cd ~/crm && /opt/cpanel/ea-php83/root/usr/bin/php artisan queue:work --stop-when-empty --max-time=55 --tries=3 --timeout=120 >> ~/logs/crm-queue.log 2>&1
```

`--stop-when-empty` exits when the `jobs` table drains. `--max-time=55`
guarantees the worker is gone before the next minute starts, so two never
overlap. `--timeout=120` must stay below the connection's `retry_after` of 180
seconds, or the queue would hand the same job to a second worker while the
first was still running it.

```bash
mkdir -p ~/logs
```

**If the host refuses `* * * * *`** and caps you at `*/5`, that is still fine:
every one of the eight scheduled commands lands on a minute divisible by five
(`00`, `05`, `15`, `30`, `45`), so all eight still fire. This is a property
worth preserving — a future command scheduled at `:07` would silently never
run under a capped cron. `tests/Feature/ScheduleTest.php` pins the times but
not this property.

---

## Step 11 — check it

```bash
php artisan about --only=environment     # Debug Mode OFF, Environment production
php artisan schedule:list                # eight commands, in UAE time
php artisan queue:monitor default        # [0] OK
```

```bash
curl -s -o /dev/null -w '%{http_code}\n' https://crm.yourdomain.com/up
curl -s -o /dev/null -w '%{http_code}\n' https://crm.yourdomain.com/admin/login
```

Both must print `200`. A `500` on `/admin/login` with `/up` at `200` is almost
always a missing `public/build/manifest.json` — re-upload the bundle.

Then open it in a browser and confirm it is styled: the product name, the mark
and the teal primary colour.

Two more, which nothing else proves:

- **Mail leaves.** `php artisan alerts:scan` is the only scheduled command that
  sends email. With `MAIL_MAILER=log` it writes to `storage/logs` and sends
  nothing to anybody.
- **A document scan downloads.** Upload a scan against any document record,
  then click its download link. A 404 means `storage/app/documents` is missing
  or unwritable; authorisation failures give 403.

**One expected failure before the branch exists.** The 07:45
`billing:run --all-branches` exits non-zero with *"There are no active branches
to bill."* until step 8 is done. That is deliberate: for a running business,
every branch being inactive means billing has silently stopped, which should
be loud.

---

## Every deploy after the first

On your machine:

```bash
bash package-shared.sh
```

Upload, then on the host:

```bash
cd ~/crm
tar -xzf ~/crm-release-YYYYmmdd-HHMMSS.tar.gz
bash deploy-shared.sh
```

`deploy-shared.sh` migrates, rebuilds the caches and signals the queue
workers. It never writes `.env`, never generates a key and never seeds.

**Seeders are not part of a deploy, and two of them sometimes need to be.** If
the release notes mention a new alert horizon or a new ledger account:

```bash
php artisan db:seed --class=AlertRuleSeeder --force
php artisan db:seed --class=ChartOfAccountsSeeder --force
```

Both are safe to re-run and change nothing when there is nothing new.

---

## Backups

cPanel's own backup covers the database and your files, which includes
`storage/app/documents` — the document scans, which are **not** in the database.

It does **not** give you a usable copy of two things, and without them a
restore cannot work:

1. **`APP_KEY`** — without the original, every restored IBAN is unreadable for
   ever.
2. **`DB_PASSWORD`** and the SMTP password.

All of those live in `.env`. Keep a copy in your password manager, and never
beside the database dump.

To take your own copy:

```bash
mkdir -p ~/backups
mysqldump --user=acct123_crm --password --single-transaction --quick \
    --routines --triggers --default-character-set=utf8mb4 \
    acct123_crm | gzip -9 > ~/backups/db-$(date +%Y%m%d).sql.gz
tar -czf ~/backups/documents-$(date +%Y%m%d).tar.gz -C ~/crm/storage/app documents
```

**Download them off the host.** A backup on the same account as the database
is not a backup. `docs/deployment.md` §13 has the restore procedure and the
`information_schema` query that verifies a restored schema properly — do not
verify by diffing two `mysqldump` outputs, which reports cosmetic differences
as real ones.

---

## When something is wrong

`docs/deployment.md` §16 covers the application-level symptoms and still
applies. These are the ones specific to shared hosting:

| Symptom | Almost certainly |
|---|---|
| Every admin page 500s, `/up` is fine | `public/build/manifest.json` is missing. Re-upload the bundle — it is never built on the host. |
| An import sits at *Import started* for under a minute | Normal here. The queue runs from cron, not a daemon. |
| An import never starts at all | The queue cron line is missing, or its PHP path is wrong. Check `~/logs/crm-queue.log`. |
| Nobody is invoiced | The scheduler cron line is missing. `grep billing ~/logs/crm-schedule.log`. |
| Cron logs say `command not found` | The cron line is using a bare `php`. Use the absolute `ea-php83` path. |
| Signed in, then signed straight out | The subdomain is on plain HTTP. Run AutoSSL. |
| `.env` or `/storage` is reachable in a browser | The document root is wrong. It must be `crm/public`, not `crm`. Fix it immediately: `.env` holds `APP_KEY`. |
| A deploy changed nothing | Workers still running old code. `php artisan queue:restart`, and check `CACHE_STORE=database` — with `array` the signal dies with the process. |

Logs:

```bash
tail -f ~/crm/storage/logs/laravel-$(date +%Y-%m-%d).log
tail -f ~/logs/crm-schedule.log
tail -f ~/logs/crm-queue.log
```

---

## Handing off to go-live

You now have a working, empty system: schema, reference data, one branch, one
head-office user, a scheduler and a queue worker. `docs/go-live.md` takes over.

Two things it needs that are easy to miss:

- **A second head-office user**, created under **Setup → Staff**.
  `ManualJournalService` refuses to let anyone post a manual journal they
  prepared themselves, so the opening trial balance needs two people. A lone
  operator can never complete a go-live.
- **The up-to-a-minute import delay** above. Say it out loud before anyone
  uploads the first CSV.
