# Deploying MITH Studios to cPanel

Written for Namecheap shared hosting, which is where this will live. It assumes
no root access and, on some plans, no SSH — every step has a cPanel-only path.

Do this on a demo subdomain first. Everything below applies again, unchanged,
when the client's real domain arrives; only the values in [Going live](#going-live-on-the-real-domain)
differ.

---

## Before you start

| | |
|---|---|
| PHP | **8.3 or 8.4** — set per-domain in cPanel → *MultiPHP Manager* |
| Extensions | `bcmath`, `mbstring`, `openssl`, `pdo_mysql`, `fileinfo`, `curl`, `zip`, `gd` |
| Database | MySQL or MariaDB |
| SSL | AutoSSL / Let's Encrypt on the subdomain |
| Bucket | An S3-compatible bucket for audio uploads — see [Storage](#5-storage-for-audio-uploads) |

`bcmath` is not optional. Every price in the system is a decimal string handled
by `bcadd`/`bccomp` rather than a float, because floats lose pennies. It is
declared in `composer.json`, so `composer install` will refuse to run on a host
that lacks it — which is the intended behaviour. Enable it in cPanel →
*Select PHP Version* → *Extensions*.

---

## 1. Subdomain and document root

Create the subdomain in cPanel → *Domains*, then set its document root to the
app's `public/` directory — **not** the project root.

```
Subdomain:      mith.yourdomain.com
Document root:  /home/USER/apps/mith/public
```

Put the project **outside** `public_html`. If the project root is web-served,
`.env` is fetchable over HTTP and your keys are public.

Use a subdomain rather than a subfolder. Laravel in a subdirectory means
rewriting asset paths and fighting `.htaccess` for no benefit.

---

## 2. Upload the code

Build the front-end assets **locally** — Node is not needed on the server, but
the compiled output is:

```bash
npm ci && npm run build
```

That writes `public/build/`. Upload the whole project, including `vendor/` and
`public/build/`, but excluding:

```
node_modules/          not used at runtime
.git/                  never web-accessible
tests/                 not needed in production
storage/logs/*         start clean
.env                   write a fresh one on the server, see §4
```

Zip locally, upload the single archive via *File Manager*, and extract there.
Uploading thousands of `vendor/` files over FTP individually will take hours and
usually fails partway.

If SSH is available, `composer install --no-dev --optimize-autoloader` on the
server is cleaner than shipping `vendor/`.

---

## 3. Database

cPanel → *MySQL Databases*:

1. Create a database.
2. Create a user with a strong password.
3. Add the user to the database with **All Privileges**.

cPanel prefixes both with your account name, so the real values look like
`myacct_mith` and `myacct_mithuser`. Use the prefixed names in `.env`.

---

## 4. Write `.env`

Create it on the server via *File Manager* (enable "Show hidden files"). Start
from `.env.example` and change these:

```dotenv
APP_NAME="MITH Studios"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://mith.yourdomain.com
APP_KEY=

# Leave UTC. The studio's local time is a separate setting, applied at display
# time — see config/studio.php. Changing APP_TIMEZONE will corrupt bookings.
APP_TIMEZONE=UTC
STUDIO_TIMEZONE=Europe/London

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=myacct_mith
DB_USERNAME=myacct_mithuser
DB_PASSWORD=...

SESSION_DRIVER=database
CACHE_STORE=database
QUEUE_CONNECTION=database

MAIL_MAILER=smtp
MAIL_HOST=mail.yourdomain.com
MAIL_PORT=587
MAIL_USERNAME=noreply@yourdomain.com
MAIL_PASSWORD=...
MAIL_SCHEME=tls
MAIL_FROM_ADDRESS="noreply@yourdomain.com"
MAIL_FROM_NAME="MITH Studios"

REVOLUT_MODE=sandbox
REVOLUT_SECRET_KEY=sk_...
REVOLUT_WEBHOOK_SECRET=          # filled in at §9 — leave empty for now

UPLOADS_DISK=s3                  # see §5
```

**`APP_DEBUG=false` matters more than it looks.** With it on, any unhandled
error renders a stack trace that includes your environment variables — the
Revolut secret key among them — to whoever triggered it.

`APP_KEY` is generated in the next step. Never reuse the local one, and never
change it after go-live: it decrypts session and cookie data.

---

## 5. Storage for audio uploads

Mix and master orders accept files up to 600 MB. These go **browser → bucket**
directly, never through PHP, because `upload_max_filesize`, `memory_limit` and
`max_execution_time` on shared hosting would each stop a file that size.

That requires an S3-compatible bucket. **Cloudflare R2** is the recommendation:
the free tier covers this workload and, unlike S3, it charges nothing for
egress — which matters when customers download finished masters.

```dotenv
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_BUCKET=mith-uploads
AWS_DEFAULT_REGION=auto
AWS_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
AWS_USE_PATH_STYLE_ENDPOINT=true
```

Keep the bucket **private**. Nothing is publicly addressable; reads go through
short-lived signed URLs (10 minutes by default, see `config/uploads.php`).

### The CORS step everyone forgets

The browser PUTs straight to the bucket, so the bucket must allow it. Without
this, uploads fail with an opaque network error and nothing appears in your
Laravel logs — because the request never reached your server.

```json
[
  {
    "AllowedOrigins": ["https://mith.yourdomain.com"],
    "AllowedMethods": ["PUT", "GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]
```

Add the real domain to `AllowedOrigins` too when you get there.

> **Skipping this for now?** Everything else — bookings, packages, payments —
> works without a bucket. Only the mix/master file upload breaks, and it breaks
> loudly. That is a reasonable trade for a first demo deploy.

---

## 6. Run the setup commands

cPanel → *Terminal* if your plan has it, otherwise SSH. From the project root:

```bash
php artisan key:generate --force
php artisan migrate --force
php artisan db:seed --class=StudioSeeder --force
php artisan storage:link
php artisan optimize
```

`StudioSeeder` creates the rooms, packages, services and experiences. Without
it the site renders with nothing bookable.

`--force` is required because `APP_ENV=production` makes these commands prompt
for confirmation, and there is nobody to answer in a non-interactive shell.

Then create your admin login:

```bash
php artisan studio:make-admin
```

### No terminal and no SSH?

Some Namecheap plans have neither. Add a temporary route to `routes/web.php`,
hit it once in a browser, then **delete it and re-upload the file**:

```php
Route::get('/__setup/'.env('SETUP_TOKEN'), function () {
    Artisan::call('key:generate --force');
    Artisan::call('migrate --force');
    Artisan::call('db:seed --class=StudioSeeder --force');
    Artisan::call('optimize');

    return '<pre>'.Artisan::output().'</pre>';
});
```

Set `SETUP_TOKEN` to a long random string in `.env` first. An unguessable path
is what stops a passer-by from running your migrations. Delete the route the
moment it has served its purpose.

---

## 7. Permissions

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

Via File Manager: select `storage` and `bootstrap/cache`, *Permissions*, tick
through to `775`, and check "recurse into subdirectories".

A white screen straight after deploy is almost always this.

---

## 8. Cron

cPanel → *Cron Jobs*, every minute:

```
* * * * * cd /home/USER/apps/mith && /usr/local/bin/ea-php83 artisan schedule:run >/dev/null 2>&1
```

Use the **full path** to the PHP binary. Cron's `PATH` is minimal and a bare
`php` usually resolves to the host's default version — often PHP 7.4, which
cannot run this app. Confirm the path with `which ea-php83`, or check cPanel →
*Select PHP Version*.

This one entry drives three things:

| Task | Frequency | What breaks without it |
|---|---|---|
| `booking:expire-holds` | every minute | Nothing immediately — expiry is also evaluated at read time, so abandoned slots free themselves. This just tidies up. |
| `payments:reconcile` | every 5 min | A customer whose webhook was lost never gets confirmed, and their slot expires despite having paid. |
| `queue:work` | every minute | **Confirmation emails never send.** They queue to the database and sit there. |

That last one is why the cron entry is not optional. Booking confirmation and
release mails are queued, and shared hosting cannot keep a worker resident —
`queue:work` started over SSH dies with the session. The scheduled run uses
`--stop-when-empty`, so it exits immediately on an idle minute and costs
nothing.

**If your plan caps cron at 5 or 15 minutes**, everything still works, but
confirmation emails are delayed by up to that interval. Check the cap before
promising the client instant emails.

---

## 9. SSL, then the webhook

Order matters. Run AutoSSL **first** — Revolut will not deliver to a certificate
it cannot verify, and a self-signed cert fails silently.

cPanel → *SSL/TLS Status* → select the subdomain → *Run AutoSSL*. Confirm
`https://mith.yourdomain.com` loads with a valid padlock before continuing.

Then register the webhook:

```bash
php artisan revolut:webhook https://mith.yourdomain.com/webhooks/revolut
```

It prints the signing secret **once**. Revolut never shows it again — it is not
listed anywhere in the dashboard. Paste it into `.env` immediately:

```dotenv
REVOLUT_WEBHOOK_SECRET=wsk_...
```

Then clear the cached config, or the app keeps reading the old empty value:

```bash
php artisan config:clear
```

Lost it? `php artisan revolut:webhook --rotate=<id>` issues a new one. Check
what is registered with `--list`.

### If you password-protect the demo

This is the trap that costs an afternoon. cPanel's *Directory Privacy*, or any
"coming soon" page, returns `401` to Revolut. Revolut gives up. Payments never
confirm — and **nothing appears in your logs**, because the request never
reached PHP.

Exempt the webhook path in `public/.htaccess`:

```apache
<If "%{REQUEST_URI} =~ m#^/webhooks/#">
    Require all granted
    Satisfy any
</If>
```

---

## 10. Keep the demo out of Google

The client's brand should not get indexed attached to placeholder copy. In
`public/.htaccess`:

```apache
<IfModule mod_headers.c>
    Header set X-Robots-Tag "noindex, nofollow"
</IfModule>
```

Remove this on the real domain. Leaving it in place is a quiet way to be
invisible on Google after launch.

---

## 11. Smoke test

In this order — each step depends on the one before.

- [ ] Home page loads over HTTPS, fonts and CSS present *(if unstyled, `public/build/` did not upload)*
- [ ] `/admin` login works with the account from §6
- [ ] Rooms, packages and services all appear in the admin
- [ ] Booking flow shows available slots
- [ ] Register and confirm an account — **proves queued mail is draining**
- [ ] Take a booking to checkout; the Revolut page opens
- [ ] Pay with a [sandbox test card](https://developer.revolut.com/docs/guides/accept-payments/get-started/test-in-the-sandbox)
- [ ] Booking flips to **confirmed** within seconds — **proves the webhook arrives and its signature verifies**
- [ ] Confirmation email arrives
- [ ] Upload a file to a mix order *(only if §5 is configured)*

The webhook step is the one that cannot be verified any other way. If the
booking stays *pending*, wait 5 minutes: if `payments:reconcile` then rescues
it, the payment is fine and the **webhook** is the broken part — check §9.

---

## Going live on the real domain

Repeat every step above, with these differences:

| | Demo | Real domain |
|---|---|---|
| `REVOLUT_MODE` | `sandbox` | `production` |
| `REVOLUT_SECRET_KEY` | sandbox `sk_` | production `sk_` |
| `REVOLUT_WEBHOOK_SECRET` | from the sandbox webhook | **new** — register again against the live URL |
| `X-Robots-Tag` | `noindex` | removed |
| CORS origin | demo subdomain | real domain |

Sandbox and production are entirely separate Revolut environments — different
hosts, different keys, different webhook lists. They cannot collide, so the demo
keeps working as a test bed after launch.

**Rotate the production API keys before you use them.** The current pair was
pasted into a chat transcript, which is enough to treat them as compromised.
Revolut Business → *Merchant API* → revoke and reissue.

### Still open before launch

Not deployment issues, but they should not ship as they are:

- Copy still says **MIF** in places; the studio is **MITH**
- Birthday terms mention a 25% deposit and 60-day validity that no longer apply
- Pro Artist "10% discount" and Young Creators "discount code" lines need removing
- Start Strong says "any room" but is limited to Studios A, B and C

---

## When something breaks

| Symptom | Cause |
|---|---|
| White screen, no error | `storage/` permissions (§7), or `APP_KEY` unset |
| 500 after a code change | Stale cache — `php artisan optimize:clear` |
| Site loads unstyled | `public/build/` missing; run `npm run build` locally and upload |
| `.env` changes do nothing | Config is cached — `php artisan config:clear` |
| Emails never arrive | Cron not running (§8). Check the `jobs` table: rows piling up confirms it |
| Payment succeeds, booking stays pending | Webhook not reaching you — password protection (§9), wrong URL, or `REVOLUT_WEBHOOK_SECRET` unset |
| `SQLSTATE[HY000] [1045]` | DB credentials — remember the `myacct_` prefix |
| Uploads fail instantly | Bucket CORS (§5) |
| Times are hours out | `APP_TIMEZONE` was changed from `UTC`. It must stay UTC |

To read the log without SSH: *File Manager* → `storage/logs/laravel.log` → *View*.
