# cPanel deployment runbook

> **STAGING CANDIDATE — pending coverage verification.** Follow this runbook
> only in a disposable/staging cPanel account until acceptance coverage is
> approved. It does not claim complete feature parity.

This runbook deploys the Laravel application from
`migration-laravel/app` with its locked Laravel 13 / Symfony 8 Composer
dependencies. The runtime target is PHP 8.4, MySQL 8 or MariaDB 10.11, and a
web server capable of serving the `public` directory with Apache rewrites.
The Composer manifest's broad `^8.3` constraint does not lower this target:
the installer and packaging script require PHP 8.4+, and PHP 8.2 is not
supported.

## Package on a trusted build machine

Do not run Composer or npm on the hosting account. From the repository root,
the builder creates an isolated production stage:

```bash
php migration-laravel/scripts/build-package.php --check-only
# Main release owner verifies the stage and acceptance coverage first.
php migration-laravel/scripts/build-package.php --force
```

Composer is run with `--no-dev`, and npm/Vite runs only in the temporary build
tree. The development `app/vendor` is not changed. The resulting ZIP includes
compiled `public/build` files and production `vendor`, but no `node_modules`,
tests, source maps, development packages, environment file, installer
secrets, logs, sessions, cached config, or database records. The output is
`migration-laravel/build/repair-shop-laravel-package.zip`.

Before handing it to the host, inspect the archive:

```bash
unzip -l migration-laravel/build/repair-shop-laravel-package.zip
```

Reject an archive containing `.env`, `.env.example`, `*.map`, `node_modules`,
`tests/`, `.git/`, `storage/installed.lock`, `setup-code`, `app-key`, session
files, log files, or generated `bootstrap/cache` files. Do not distribute
until the main acceptance owner verifies the package.

## cPanel preparation

### Select PHP

In MultiPHP Manager or Select PHP Version, select PHP 8.4 for the domain.
Enable the installer checks: `pdo`, `pdo_mysql`, `openssl`, `mbstring`,
`tokenizer`, `xml`, `ctype`, `json`, `fileinfo`, `bcmath`, and `curl`.
The locked framework also needs the normal `filter`, `hash`, and `session`
capabilities. The installer repeats these checks at `/install/`; a failed
check must be fixed before continuing.

### Create an empty database

In MySQL Databases:

1. Create a dedicated MySQL 8 or MariaDB 10.11 database.
2. Create a dedicated user and strong password.
3. Add the user to the database with the required privileges.
4. Keep the database empty and record its cPanel-prefixed name.

The installer is not a cPanel provisioning API. It cannot create a database or
user and will not drop one. It only tests the supplied connection, runs
migrations, runs required seeders, creates the submitted administrator, and
records the installation lock. Stop if the target is not empty.

### Enable HTTPS and make a backup

Install/verify the domain SSL certificate. Take a dated backup of the current
`public_html`, any private application directory, `.env`, storage, and the
database before touching the site. Preserve the old document root and files;
never overwrite existing hosting files without a recoverable backup.

## Recommended private layout

Upload with cPanel File Manager (or another approved file transfer method) to
the account's home directory, not the web root:

```text
/home/CPANEL_USER/
├── releases/
│   └── repair-shop-laravel-package.zip
├── repair-shop-laravel/            # extracted private tree
│   ├── app/
│   ├── bootstrap/
│   ├── config/
│   ├── database/
│   ├── resources/
│   ├── routes/
│   ├── storage/
│   ├── vendor/
│   └── public/
├── public_html/                    # optional public-only bundle
└── public_html.backup-YYYYMMDD/   # preserve current site before copying
```

On a fresh home directory, extracting the archive into `$HOME` produces the
`repair-shop-laravel` private directory and a separate optional `public_html`
public bundle. On an existing account, an archive extractor may merge or
overwrite matching `public_html` files. Back up the current directory first
and never blindly extract the optional tree over a live site; use a staging
directory or extract only the private tree, then make the public copy
deliberately.
Set the domain document root to:

```text
/home/CPANEL_USER/repair-shop-laravel/public
```

This is the preferred arrangement because only `public` is addressable over
HTTP. `.env`, Composer packages, Laravel source, migrations, views, routes,
storage, and installer secrets remain outside the web root.

If the cPanel plan fixes the primary document root at `public_html`, use the
optional public-only bundle instead of editing PHP:

1. Back up the existing `public_html` under a dated name. Do not delete it.
2. Confirm that `$HOME/repair-shop-laravel` remains outside the document root.
3. After reviewing the backup, intentionally copy the contents of the ZIP's
   `public_html/` directory into `$HOME/public_html`. This deliberately
   overwrites only the selected public entry/assets; it is not an automatic
   deployment operation. Preserve unrelated hosting files and merge them
   explicitly.
4. The bundled `public_html/index.php` checks for
   `$HOME/repair-shop-laravel/vendor/autoload.php` and its public entry first.
   If the private directory is missing, it returns a friendly HTTP 503 rather
   than exposing a PHP include warning. When present it requires:

   ```php
   require dirname(__DIR__).'/repair-shop-laravel/public/index.php';
   ```

   The ZIP uses `PACKAGE_DIRECTORY` (`repair-shop-laravel`) to generate this
   wrapper. No user PHP edit is required, and the private application's
   `public/index.php` is not changed.
5. Keep the bundled `.htaccess`, `preflight.php`, and compiled `build/`
   assets together with the wrapper. Its deny rules are defense in depth; the
   real `.env` protection is the private directory layout.

If the host later allows a custom document root, stop using the optional
bundle and point directly at
`/home/CPANEL_USER/repair-shop-laravel/public`. The normal
`repair-shop-laravel/public/index.php` remains the unchanged Laravel
front-controller for that arrangement.

Do not put a symlink to `repair-shop-laravel` itself in the public tree. A symlink for
the separate public storage disk is optional and must never target
`storage/app/private`.

## Permissions and writable paths

The cPanel account owner should own the private tree and the PHP handler
should run with the account's permissions. Start with directories `0750` and
files `0640`; use `0700` for `storage/app/installation` and `0600` for its
secret files and `.env` where supported. The installer needs these writable
paths:

```text
repair-shop-laravel/storage
repair-shop-laravel/storage/app
repair-shop-laravel/storage/framework
repair-shop-laravel/storage/framework/cache
repair-shop-laravel/storage/framework/sessions
repair-shop-laravel/storage/logs
repair-shop-laravel/bootstrap/cache
```

Never make the entire application or `public_html` world-writable and never
use `777`. If a host insists on a different PHP process user, grant the
narrowest write permission to only the paths above and remove excess
permissions after setup.

## First request and ownership proof

Visit `https://your-domain.example/install/` after the private tree and
writable paths are ready. A request to the wizard creates a private bootstrap
key and setup code. Read the one-time ownership proof with cPanel File
Manager at:

```text
/home/CPANEL_USER/repair-shop-laravel/storage/app/installation/setup-code
```

This is not a guessed path: `app/config/installation.php` maps
`storage_path('app/installation/setup-code')` to it. Enter the value in the
wizard; never display it in a page, commit it, or put it in a support log.

Complete requirements, connection test, HTTPS application URL, and first
administrator steps. The wizard then:

1. connects to the pre-created empty MySQL/MariaDB database;
2. runs migrations (it does not create the database and does not delete
   tables);
3. runs the required seeders, which contain no demo records;
4. creates only the administrator submitted in this run;
5. writes the managed production `.env` and persistent `APP_KEY`;
6. verifies storage, URL, tables, and administrator; and
7. creates `repair-shop-laravel/storage/installed.lock`.

After it succeeds, `/install/` is locked. Keep `installed.lock`; do not expose
any reset route or remove it as a way to reinstall.

Back up the final `.env`/`APP_KEY` in an encrypted, access-controlled location
outside the server. The `APP_KEY` must persist across deployments and must not
be casually rotated. A changed key invalidates encrypted cookies/sessions and
can make encrypted application values unreadable.

## Files, storage, and queues

Private customer/repair documents must remain on the local private disk at
`storage/app/private` and be sent only through authorization-checked
controller responses. `storage:link` is optional for files intentionally
placed on `storage/app/public`; it is not required for the installer and must
not expose the private disk.

The installer configures `QUEUE_CONNECTION=sync`; do not create a cron entry
for a worker in that default state. If a later, deliberate configuration
selects a real database/Redis queue, confirm migrations, workload, and
failure handling first. Then use the host's actual PHP path with bounded
limits, for example:

```cron
* * * * * /usr/bin/flock -n /home/CPANEL_USER/.repair-shop-queue.lock /usr/local/bin/php /home/CPANEL_USER/repair-shop-laravel/artisan queue:work database --stop-when-empty --max-jobs=25 --max-time=50 --tries=3 --timeout=45 >/dev/null 2>&1
```

Replace paths after checking the cPanel documentation. Do not add a
nonexistent or unbounded daemon; do not claim queue processing is enabled
while the environment remains `sync`.

## Fresh install versus import

This runbook initializes a native MySQL/MariaDB installation only. It does not
import the existing PostgreSQL database, preserve external identifiers, or
import Clerk users automatically. An import is a separate migration with
field mapping, identity-linking decisions, backups, dry runs, and feature
coverage approval. Never enter PostgreSQL/Clerk credentials into the fresh
installer or assume the resulting local administrator represents imported
users.

## Post-deployment checks and rollback

Use a staging hostname first. Verify:

- HTTPS redirects and cookies;
- the homepage, login, installer lock, and administrator authorization;
- a migration status and an empty-install record count appropriate to the
  required defaults;
- uploads stay private and public assets load from `public/build`;
- no private path is reachable from the document root; and
- PHP error handling is configured by the host without copying logs into the
  release ZIP.

For an update, create a new sibling release, back up `.env`, `storage`, and
the database, install the new production ZIP there, and switch the document
root only after verification. Preserve the previous release for rollback.
Never overwrite the active private app or existing hosting files in place
without a backup.
