# Deployment guide — cPanel / Plesk shared hosting

Target: Apache + PHP 8.2+ on shared hosting, **SQLite** database, no shell access required.

Work through the steps in order. Steps 3, 4 and 9 are the ones people skip and
then regret.

---

## 1. Requirements

| Requirement | Value |
|---|---|
| PHP | **8.2 or newer** (8.3 recommended) |
| Extensions | `pdo_sqlite`, `mbstring`, `openssl`, `tokenizer`, `ctype`, `json`, `fileinfo`, `curl` |
| Database | SQLite — built into PHP, nothing to provision |
| Node.js | **Not required.** No frontend build step. |
| Composer | Needed once during install, or upload a build made elsewhere |

Verify on the host:

```bash
php -v
php -m | grep -E "pdo_sqlite|mbstring|openssl|tokenizer"
```

---

## 2. Upload the code

Upload the project so the application root sits **above** `public_html`:

```
/home/CPANELUSER/school/          <- application root (.env, app/, vendor/, storage/)
/home/CPANELUSER/school/public/  <- document root
```

This layout matters. With the app root one level above the docroot, `.env`,
`vendor/`, `storage/` and the SQLite database are all outside the web root and
cannot be downloaded even if a server rule is ever missing.

**Do not upload:** `.env`, `database/database.sqlite`, `storage/logs/*.log`,
`node_modules`, `.gitignore`, `phpunit.xml`, `tests/`, and the dev-only
`composer.lock` changes below. Upload from a clean checkout, not this working
directory, which has demo data in it.

If your host forces `public_html`, see [Alternative layout](#alternative-layout-if-you-cannot-change-the-document-root).

---

## 3. Set the document root

**cPanel:** *Domains → Domains →* edit the domain → *Document Root* →
`/home/CPANELUSER/school/public`.

**Plesk:** *Websites & Domains →* domain → *Document Root* → the `public`
folder.

Verify: `https://school.example.com/` shows the login page.
`https://school.example.com/.env` must return **403/404**, not your secrets.

---

## 4. Install production dependencies

```bash
cd /home/CPANELUSER/school
composer install --no-dev --optimize-autoloader
```

**`--no-dev` is not optional.** It drops `spatie/laravel-ignition`, which
registers `_ignition/execute-solution` and `_ignition/update-config` routes.
Those endpoints can rewrite application config from a browser. Ignition's own
guard only blocks them outside `APP_ENV=local|development`, so a production box
running `APP_DEBUG=true` that someone left on `local` is exploitable. Removing
the package removes the routes entirely.

You can install on your own machine and upload `vendor/` instead if the host has
no shell — but remember to run the same `--no-dev` command locally first.

---

## 5. Configure the environment

There are two ways to do this. **The installer is recommended.**

### Option A — the web installer (recommended)

Upload the files with the document root pointing at `public/` (step 3), then
visit:

```
https://school.example.com/install
```

The installer walks you through three screens:

1. **Requirements** — PHP version, extensions, writable folders and a database
   connection check, with plain-English fixes for anything that fails.
2. **Configuration** — application name, URL, environment, timezone, SQLite
   path, SMTP settings, school details and your administrator account. Submitting
   it writes `.env`, generates the key, runs the migrations and creates your
   administrator in one go.
3. **Summary** — what was created, plus the remaining go-live steps.

### Option B — edit .env by hand

```bash
cp .env.production.example .env
php artisan key:generate
php artisan migrate --force
```

Then create your first administrator through the UI.

### Either way, three settings are load-bearing

- **`SESSION_DRIVER` must stay `database`.** `App\Support\SessionInvalidator`
  revokes a user's sessions by deleting rows from the `sessions` table when an
  account is deactivated, or a password/role changes. With `file` sessions those
  deletes are a silent no-op and revoked accounts keep working until the session
  expires on its own. This is the single most damaging setting to get wrong.
- **`DB_DATABASE` must be non-empty or unset** — never `DB_DATABASE=` with
  nothing after the `=`. Empty overrides Laravel's fallback and every query dies
  with `SQLSTATE[HY000] [14] unable to open database file`.
- **`MAIL_MAILER` must not stay `log`.** The `log` driver discards every message,
  so password-reset links are never delivered and "forgot password" silently
  does nothing. Configure real SMTP.

`.env` must live in the application root, **not** in `public/`, and must be
`chmod 600`.

`TRUSTED_PROXIES` is optional. Shared hosts terminate TLS in front of PHP; if
yours sends `X-Forwarded-Proto` but your generated URLs still come out as
`http://`, set it to your host's proxy address, or `*` only if nothing can reach
PHP directly.

---

## 5a. About the installer and security

The installer can write your `.env`, run migrations and create an administrator
account. Anyone who can reach it on an un-installed site can therefore take the
site over, so it is closed as soon as installation finishes:

- It writes `storage/installed.lock` on success.
- The `not.installed` middleware returns **404** for every installer route once
  that file exists.
- Independently, a database that already contains user accounts also closes the
  installer. This matters in practice: redeployments often replace the code
  directory and wipe `storage/`, and without that second check a lost lock file
  would silently re-expose the installer on a live site.

If you ever have to re-run it, delete `storage/installed.lock` **and** point the
application at an empty database. There is deliberately no artisan command that
reopens the installer in production.

Demo accounts are **off by default** and only created when explicitly ticked on
the configuration screen. If you tick it, the installer creates the foundation
demo administrator (`admin@school.local`, password `password`); deactivate it
before going live:

```sql
UPDATE users SET is_active = 0 WHERE email LIKE '%@school.local';
```

---

## 6. Permissions

```bash
chmod -R 775 storage bootstrap/cache
find storage bootstrap/cache -type d -exec chmod 775 {} \;
find storage bootstrap/cache -type f -exec chmod 664 {} \;
chmod 600 .env
```

`storage/` and `bootstrap/cache/` must be writable by PHP, or you get
"Unable to write cache" or "Please provide a valid cache path".

Keep the SQLite file itself out of `public/` (see step 2) and writable by PHP:
`chmod 664 database/database.sqlite`.

---

## 7. Create the database and migrate

Only needed for **Option B** — the installer does this for you.

```bash
touch data/database.sqlite
php artisan migrate --force
```

### Do not seed

```bash
# php artisan db:seed     <-- DO NOT RUN IN PRODUCTION
```

The seeder creates **14 demo accounts, all with the password `password`**, each
holding a different role — including `admin@school.local` and
`super_admin@school.local`. Running it on a live site publishes a
ready-made administrator login. Run `migrate --force` only, then create the
first real account through the UI.

---

## 8. Optimise

```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
```

Caches must be **cleared and rebuilt after every deploy** — otherwise the old
config (including `APP_DEBUG=true`) stays baked in:

```bash
php artisan config:clear && php artisan config:cache
php artisan route:clear && php artisan route:cache
php artisan view:clear  && php artisan view:cache
```

---

## 9. Harden before going live

- [ ] `/install` returns **404** (the installer closed itself)
- [ ] `APP_DEBUG=false` **and** `APP_ENV=production`
- [ ] `composer install --no-dev` completed (no Ignition routes)
- [ ] Document root is `public/`, and `/.env` returns 403
- [ ] HTTPS is active, and `SESSION_SECURE_COOKIE=true`
- [ ] Demo seeders were **not** run
- [ ] `.env` is `chmod 600`
- [ ] All demo accounts changed or removed — see below
- [ ] `MAIL_MAILER` sends real mail
- [ ] Scheduled backups of the SQLite file are running (see below)

### Deal with the demo accounts

If demo data was ever seeded on the live database, the fourteen `*@school.local`
accounts are real, loginable and — for four of them — privileged. Either reset
them or deactivate them:

```sql
-- via phpMyAdmin / SQLite browser on the production database file
UPDATE users SET is_active = 0 WHERE email LIKE '%@school.local';
```

Then sign in as your own real admin and change every password. Roles are
enforced server-side on all 14, so an active `super_admin` login is a full
platform takeover.

---

## 10. Backups

The entire system state is one file, so back up:

```bash
cp /home/CPANELUSER/school/data/database.sqlite ~/backups/db-$(date +%F).sqlite
```

SQLite runs in `delete` journal mode here, so a plain copy is consistent as
long as it is not taken mid-write. Use `sqlite3 db.sqlite ".backup 'out.sqlite'"`
for a hot, always-consistent copy. Take the copy from **outside** `public/`.

Also back up `.env` (it holds the `APP_KEY`; without it, sessions and encrypted
MFA secrets become unreadable) and `storage/` (compiled views and logs).

cPanel usually offers Cron Jobs → run the copy daily and keep 7–14 days.

---

## 11. Updating later

```bash
cd /home/CPANELUSER/school
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:clear && php artisan config:cache
php artisan route:clear && php artisan route:cache
php artisan view:clear  && php artisan view:cache
```

Back up the database file **before** migrating. Migrations are not
automatically reversible for data changes.

---

## Troubleshooting

**Blank page, no error shown** — display_errors is off in production, which is
correct. Read the actual error:

```bash
tail -50 storage/logs/laravel.log
```

**`Unable to open database file`** — `DB_DATABASE` is empty or wrong, or the
path is not writable by PHP. Confirm the absolute path exists.

**`database is locked`** — concurrent writes. Raise `DB_BUSY_TIMEOUT`
(5000 → 10000). If it persists, the SQLite file is likely on a network mount
that does not support file locking; move it to local disk.

**Every page redirects to `/login`** — the `sessions` table is missing.
`SESSION_DRIVER=database` requires it: run `php artisan migrate`.

**Deactivated users still have access** — `SESSION_DRIVER` is set to `file`.
See the warning in step 5.

**Password reset emails never arrive** — `MAIL_MAILER=log` discards them.
Configure SMTP, and confirm `MAIL_FROM_ADDRESS` is on a domain your host is
allowed to send from.

**Generated URLs use `http://` on an https site** — the host terminates TLS, so
set `TRUSTED_PROXIES`.

**Style is broken / 404 on CSS** — the docroot is pointed at the application
root instead of `public/`, or `assets/` was not uploaded.

---

## Alternative layout: if you cannot change the document root

Some shared plans hard-code `public_html`. Keep the application **above** it
and point the docroot via a symlink:

```bash
mv /home/CPANELUSER/public_html /home/CPANELUSER/school_public_backup
mkdir -p /home/CPANELUSER/school
ln -s /home/CPANELUSER/school/public /home/CPANELUSER/public_html
```

Never leave the real application files inside `public_html` — `.env`, `vendor/`
and the SQLite database would all be downloadable.