> ## Documentation Index
> Fetch the complete documentation index at: https://theteam-13fc7f49.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Plesk Deployment Guide

> Take a Plesk server from the 09/10/2026 version to the current one: PHP, code, Composer, every .env key and how to generate it, migrations, caches, the nginx /storage/ and /app/ blocks, the scheduler, Laravel Reverb, the local storage inspector, the Flutter web deploy and the checks.

This page takes a backend and a frontend hosted on **Plesk** from the version deployed on **09/10/2026** to the current one, with every command. It also works on a brand-new server: the steps that differ are marked **new server**.

| | Version this page deploys |
| :- | :- |
| Backend | `LMS_Backend` `main` at `40b6709` (06/11/2026): Laravel `12.70.2`, a patched PHP (`8.2.35`, `8.3.36`, `8.4.27`, `8.5.12` or newer in the same branch), `laravel/reverb` `1.12.0` |
| Frontend | `LMS_Frontend` `main` at `690279e` (08/11/2026): Flutter `3.44` stable, CanvasKit web build |
| Servers | DEV, TEST and PROD. Each one is a Plesk subscription with a **backend** domain (Laravel) and a **frontend** domain (the Flutter web build) |

Deploy **TEST first**, check it (section 15), then do PROD the same way.

> [!WARNING]
>
> **No real value appears on this page.** Every key, secret, password and token below is a placeholder (`<generated>`, `<token>`, `<db user>`, …). Generate your own values, keep them in that server's `.env` only, and never commit a `.env` file. If a real `.env` was ever pasted into a chat, a ticket or a screenshot, rotate the keys it holds (section 7).

***

## 1. What changed since 09/10/2026

| Date | Change | What the server needs |
| :- | :- | :- |
| 09/10 | Local storage inspector page (`/storage-inspector`) | an optional `.env` token (sections 6 and 13) |
| 16–19/10 | Lesson types (document / video), several quizzes per chapter, quiz slugs generated on save | 2 migrations |
| 19/10 | Faster login: one request returns the whole boot payload, slim `GET /auth/user` | 1 index migration. **Deploy the backend and the frontend in the same session** (section 14) |
| 19/10 | Turnstile settings read from `config/services.php`, CORS preflights cached for 2 hours | `TURNSTILE_BYPASS=false` written in the production `.env` |
| 20/10 | Classroom chatrooms with live messages (Laravel Reverb) | 2 migrations, the `.env` Reverb block, upload limits, the scheduler, the Reverb process, the nginx `/app/` block |
| 21/10 | Cached marketplace counts | nothing (file cache) |
| 24/10 | Admin dashboard PDF on dompdf, wkhtmltopdf removed | delete 2 `.env` lines; uninstall the binaries (optional, section 16) |
| 25/10 | Laravel 10 → 12, patched PHP required | PHP `8.2.35`+ (section 4), `composer install`, 1 migration, the old cache files removed |
| 30/10 | Cart and checkout messages translated | nothing |
| 01/11 | One category per training, exam and quiz | 1 migration that removes the extra categories (it writes a backup first) |
| 01/11 | R2 uploads organized by owner | nothing: the old files stay where they are and keep working |
| 01/11 | Laravel `12.70.2` security update | `composer install` |
| 04/11 | Admins can delete a chatroom | nothing |
| 05/11 | Progress fix for trainings with quizzes | nothing |
| 19/10–08/11 | Frontend: everything above on the app side; the web app is no longer a PWA | web deploy, the `/storage/` CORS block and the `/certificate/` proxy if they are missing |

***

## 2. Before you start

### 2.1 Values per environment

| | DEV | TEST | PROD |
| :- | :- | :- | :- |
| Backend domain | `backend-dev.the-team.tn` | `backend-test.theteam.com.tn` | `service.elearning.theteam.com.tn` |
| Frontend domain | `elearning-dev.the-team.tn` | `elearning-test.theteam.com.tn` | `elearning.theteam.com.tn` |
| Database | `theteamdb_dev` | `theteamdb_test` | `theteamdb_prod` |
| PHP branch (`<X.Y>` on this page) | Plesk → the domain → **PHP Settings** | Plesk → the domain → **PHP Settings** | `8.4` (09/11/2026) |
| Reverb supervisor program | `reverb-dev` | `reverb-test` | `reverb-prod` |
| Reverb port | `8080`, or `8081` if it shares a server with TEST | `8080` | `8080` |
| `APP_ENV` / `APP_DEBUG` | `production` / `false` (or `true` while debugging) | `production` / `false` | `production` / `false` |

### 2.2 What you need

* **SSH as the subscription's system user** (Plesk → Websites & Domains → the domain → **Hosting settings** shows it). The SSH shell must be `/bin/bash`, not `/bin/bash (chrooted)`: a chrooted shell cannot see Plesk's PHP. Without SSH, run each `artisan` line through Plesk → **Scheduled Tasks** → *Run a command* → **Run Now** (section 5.6). Logged in as **root** instead? Switch to that user first (section 2.4): files root creates in `storage/`, `vendor/` or `bootstrap/cache/` can't be written by the site afterwards, which ends in `500` errors.
* **Root once**, to install supervisor for Reverb (section 12).
* A **Plesk administrator**, if PHP has to be updated (section 4).
* About **30 minutes** per environment, plus the frontend build (≈10 minutes on your computer).
* On your computer: **Flutter 3.44** stable and the frontend repository (section 14).

### 2.3 The order

1. Back up (section 3)
2. Patch and set up PHP (section 4)
3. Get the code and install the packages (section 5)
4. Update `.env` (sections 6 and 7)
5. Run the migrations (section 8)
6. Clear the caches and restart PHP (section 9)
7. nginx directives (section 10)
8. Scheduler (section 11)
9. Reverb (section 12)
10. Storage inspector (section 13)
11. Frontend (section 14)
12. Checks (section 15)

### 2.4 Start every SSH session like this

Every command below runs **on the server, in the backend folder, in the same SSH session, as the subscription's system user**.

**1. Logged in as root?** (`whoami` prints `root`, the prompt ends with `#`.) Switch to the user that owns the site, on a line of its own:

```bash theme={null}
su -l -s /bin/bash "$(stat -c %U /var/www/vhosts/<backend-domain>/httpdocs)"
whoami                                             # the subscription's user now, and the prompt ends with $
```

Skip this step when you logged in as the subscription's user. Root is only needed for supervisor (section 12) and `systemctl`: `exit` takes you back to root.

**2. Open the backend folder and pick Plesk's PHP:**

```bash theme={null}
cd /var/www/vhosts/<backend-domain>/httpdocs     # the folder that holds `artisan`
PHP=/opt/plesk/php/<X.Y>/bin/php                   # <X.Y> = the domain's PHP branch, e.g. /opt/plesk/php/8.4/bin/php
$PHP -v                                            # must print "PHP <X.Y>.…": nothing below works until it does
```

`<X.Y>` is the branch shown in Plesk → the domain → **PHP Settings** (section 2.1). `ls /opt/plesk/php/` lists the branches installed on the server, and the `.php-version` file next to `httpdocs`, when there is one, holds the domain's. Use the same branch everywhere this page shows `<X.Y>` (Scheduled Tasks, cron, supervisor), and `plesk-php<XY>-fpm` without the dot (`plesk-php84-fpm` for 8.4).

`PHP=…` only lives in the current shell: a new SSH session, or a new `su`, forgets it. Run step 2 again.

> [accordion] The first commands fail
>
> | What you see | Why | Fix |
> | :- | :- | :- |
> | `-r: command not found`, `-v: command not found`, `artisan: command not found` | `$PHP` is empty: this shell never ran the `PHP=` line, so bash takes `-r` for the command | run step 2 in this shell |
> | `/opt/plesk/php/…/bin/php: No such file or directory` | that branch is not installed (`ls /opt/plesk/php/`), or the SSH shell is chrooted | use an installed branch. For a chrooted shell, a Plesk administrator sets "Access to the server over SSH" to `/bin/bash` in **Hosting settings** (root's `su -s /bin/bash` of step 1 is never chrooted) |
> | `cd: /bin/bash: Not a directory` | `/bin/bash` is the shell program, not a folder: "SSH shell `/bin/bash`" is a Plesk setting | nothing to do: the prompt you have is already bash |
> | `fatal: detected dubious ownership in repository` | `git` runs as root in a folder another user owns | step 1. Don't add a `safe.directory` |
> | `su` exits at once, or prints `This account is currently not available` | the user's login shell is turned off (SSH access forbidden) | keep `-s /bin/bash` in the `su` line |
>
> Already ran `git`, `composer` or `artisan` as root? As root, list the files root owns in the site, then give them back to the site's user:
>
> ```bash theme={null}
> find /var/www/vhosts/<backend-domain>/httpdocs -mindepth 1 -user root          # nothing printed = nothing to fix
> find /var/www/vhosts/<backend-domain>/httpdocs -mindepth 1 -user root \
>   -exec chown -h "$(stat -c %U /var/www/vhosts/<backend-domain>/httpdocs)":psacln {} +
> ```

***

## 3. Back up

**1. Write down the running version**

```bash theme={null}
git log --oneline -1 | tee ~/deploy-previous-commit.txt
```

The rollback (section 18) goes back to this commit. `~` is the subscription's home (`/var/www/vhosts/<backend-domain>`), outside the public web folder.

**2. Copy the .env**

```bash theme={null}
cp .env ~/env-backup-$(date +%Y%m%d-%H%M).env
chmod 600 ~/env-backup-*.env
```

**3. Dump the database**

The production database still holds ≈6.6 GB of old Laravel Telescope tables (see [Monitoring & Logs](https://theteam-13fc7f49.mintlify.site/deployment/monitoring-and-logs.md)). Leave them out, or the dump takes minutes and gigabytes for nothing:

```bash theme={null}
DB=theteamdb_prod                                 # the environment's database (section 2.1)
mysqldump --single-transaction --no-tablespaces \
  --ignore-table=$DB.telescope_entries \
  --ignore-table=$DB.telescope_entries_tags \
  --ignore-table=$DB.telescope_monitoring \
  -u <db user> -p $DB | gzip > ~/db-$DB-$(date +%Y%m%d-%H%M).sql.gz
ls -lh ~/db-$DB-*.sql.gz                          # a few MB to a few tens of MB
```

`-p` asks for the `DB_PASSWORD` of `.env`. `--no-tablespaces` avoids the "PROCESS privilege" error a Plesk database user gets on MySQL 8. Without SSH: Plesk → **Databases** → the database → **Export Dump** (drop the Telescope tables first, or that dump includes them).

**4. Optional: archive the local files**

```bash theme={null}
du -sh storage/app                                 # how big it is
tar -czf ~/storage-app-$(date +%Y%m%d).tar.gz storage/app
```

Nothing in this deploy moves or deletes uploaded files. The archive is only for peace of mind.

***

## 4. Patch PHP and check its settings

### 4.1 The version

Since 25/10/2026 `composer.json` accepts only PHP builds that fix **CVE-2026-109618 / CVE-2026-109619** ("PHP2Shell", fixed upstream on 21/10/2026):

| Branch | Lowest accepted build |
| :- | :- |
| 8.2 (end of life 31/12/2026) | `8.2.35` |
| 8.3 | `8.3.36` |
| 8.4 | `8.4.27` |
| 8.5 | `8.5.12` |

On an older build, `composer install` refuses to run and the site answers `500 "Composer detected issues in your platform"`. Check both the command line and the PHP that really serves the domain:

```bash theme={null}
$PHP -v                                                        # PHP <X.Y>.… (cli), at least the build in the table
curl -sI https://<backend-domain>/ | grep -i x-powered-by      # x-powered-by: PHP/<X.Y>.…, the same branch and build
```

If either one is older:

1. Plesk → **Tools & Settings** → **Updates**: update Plesk and its PHP components (`/opt/plesk/php/<X.Y>` is Plesk's own package).
2. Plesk → **Websites & Domains** → the backend domain → **PHP Settings** shows the handler the domain uses. An "OS vendor" handler is updated with `apt` / `dnf`, not by Plesk.
3. Restart that PHP-FPM: Plesk → **Tools & Settings** → **Services Management**, or as root `systemctl restart plesk-php<XY>-fpm` (`plesk-php84-fpm` for 8.4).

The domain and `$PHP` must run the **same branch**. A domain left on an older PHP fails on the new code while the command line works.

### 4.2 The extensions

```bash theme={null}
$PHP -m | grep -i -x -E "ctype|curl|dom|fileinfo|gd|imagick|intl|mbstring|openssl|pcntl|pdo_mysql|sockets|tokenizer|xml|zip"
```

| Extension | Used for | Needed? |
| :- | :- | :- |
| `ctype`, `curl`, `dom`, `fileinfo`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml` | Laravel 12 itself | ✅ |
| `zip` | `composer.json` asks for it (exports and imports) | ✅ |
| `gd` | the certificate share image (`og:image`) and images inside the PDFs | ✅ |
| `imagick` | the QR code of the 2FA enrolment | ✅ (already needed before) |
| `pcntl`, `sockets` | the Reverb process | recommended |
| `intl` | number and date formatting helpers | recommended |

A missing extension: Plesk → **Tools & Settings** → **PHP Settings** → the PHP version → enable it.

### 4.3 The limits

Plesk → the backend domain → **PHP Settings**:

| Setting | Value | Why |
| :- | :- | :- |
| `upload_max_filesize` | `300M` | chat images need 25 MB; the exam / quiz JSON imports need up to 300 MB |
| `post_max_size` | `300M` | same as above, never below `upload_max_filesize` |
| `memory_limit` | `512M` | imports, exports, PDFs |
| `max_execution_time` | `600` | long imports |
| `max_input_time` | `600` | long uploads |

If the server already has higher values, keep them. nginx needs the same upload size (`client_max_body_size`, section 10).

***

## 5. Get the code and install the packages

**1. Optional: put the API in maintenance mode**

Recommended on PROD: this deploy upgrades Laravel and runs migrations, so for a few minutes the API answers `503` instead of half-working.

```bash theme={null}
$PHP artisan down --retry=60
```

The apps show a connection error until section 9 runs `php artisan up`.

**2. Pull main**

```bash theme={null}
git status --short             # must print nothing
git fetch origin
git checkout main
git pull --ff-only origin main
git log --oneline -1           # 40b6709 Merge pull request #7 … (or newer)
```

`git status` prints files? Someone edited the code on the server. Look at the change (`git diff`), keep a copy if it matters, then `git stash` before pulling.

**Plesk Git instead of SSH:** Websites & Domains → the backend domain → **Git** → **Pull Updates**. Then do the next steps over SSH or through Scheduled Tasks.

**3. Delete the Laravel 10 cache files**

```bash theme={null}
rm -f bootstrap/cache/config.php bootstrap/cache/routes-v7.php bootstrap/cache/events.php \
      bootstrap/cache/services.php bootstrap/cache/packages.php
```

These files list the Laravel 10 packages (Snappy and the others that are gone). Between the pull and `composer install`, `artisan` cannot start to clear them itself, and Composer's last step runs `artisan`. Laravel rebuilds them by itself.

**4. Install the packages**

Run Composer **with Plesk's PHP**. The plain `composer` command often runs with another PHP version and refuses the lock file:

```bash theme={null}
COMPOSER_CMD="$PHP /usr/lib/plesk-9.0/composer.phar"      # the Composer Plesk ships
$COMPOSER_CMD --version
$COMPOSER_CMD install --no-dev --optimize-autoloader --no-interaction
$PHP artisan --version                                     # Laravel Framework 12.70.2
```

No `/usr/lib/plesk-9.0/composer.phar` on this server? Download Composer into the home folder once:

```bash theme={null}
$PHP -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
$PHP composer-setup.php --install-dir="$HOME" && rm composer-setup.php
COMPOSER_CMD="$PHP $HOME/composer.phar"
```

Or Plesk → the backend domain → **PHP Composer** → **Install** (it uses the domain's PHP).

`composer install` also removes `laravel-snappy` from `vendor/`. An error naming `php ~8.2.35 || ~8.3.36 …` means the PHP is not patched (section 4.1).

**5. New server only: the storage link and the folder rights**

```bash theme={null}
$PHP artisan storage:link        # public/storage → storage/app/public, serves /storage/files/...
chmod -R ug+rwX storage bootstrap/cache
```

An existing server already has the link: the status page (`https://<backend-domain>/`) shows it as OK.

**6. Without SSH: run artisan from Scheduled Tasks**

Plesk → **Scheduled Tasks** → **Add Task** → *Run a command*, paste the line, **Run Now**, read the output, then delete the task. If your Plesk has the **Laravel Toolkit**, its **Artisan** tab does the same thing.

```text theme={null}
/opt/plesk/php/<X.Y>/bin/php /var/www/vhosts/<backend-domain>/httpdocs/artisan migrate --force
```

***

## 6. Update the `.env`

Edit it over SSH (`nano .env`) or in Plesk → **Files** → the backend folder → `.env` → **Edit in Text Editor**.

### 6.1 Delete

```env theme={null}
WKHTML_PDF_BINARY=...
WKHTML_IMG_BINARY=...
```

Nothing reads them anymore (PDFs are rendered by dompdf). Also delete any `TELESCOPE_*` line once Telescope is removed ([Monitoring & Logs](https://theteam-13fc7f49.mintlify.site/deployment/monitoring-and-logs.md)).

### 6.2 Add

Add this block at the end. If the file already has a `BROADCAST_DRIVER` line, delete it and keep the one in this block (section 6.4 explains why).

```env theme={null}
####### Chatrooms real time (Laravel Reverb) #######
# log = no WebSocket, the apps poll every 5 s. reverb = live, once Reverb runs (section 12).
BROADCAST_DRIVER=log
REVERB_APP_ID=<generated>
REVERB_APP_KEY=<generated>
REVERB_APP_SECRET=<generated>
# where the Reverb process listens (local only, nginx proxies to it)
REVERB_SERVER_HOST=127.0.0.1
REVERB_SERVER_PORT=8080
# where Laravel posts the events (the same local process)
REVERB_HOST=127.0.0.1
REVERB_PORT=8080
REVERB_SCHEME=http
# what the apps open (filled in section 12)
REVERB_CLIENT_HOST=
REVERB_CLIENT_PORT=
REVERB_CLIENT_SCHEME=
# several Reverb processes behind a load balancer only (needs Redis). Keep false.
REVERB_SCALING_ENABLED=false

####### Local storage inspector (section 13) #######
STORAGE_INSPECTOR_TOKEN=<generated>
STORAGE_INSPECTOR_CACHE_SECONDS=60
```

The `<generated>` values come from section 7.

### 6.3 Check the values that already exist

| Key | Production value | Note |
| :- | :- | :- |
| `APP_ENV` | `production` | `local` opens the storage inspector to anyone without a token |
| `APP_DEBUG` | `false` | `true` shows error details in API answers |
| `APP_KEY` | keep the current value | never change it on a running server (section 7.1) |
| `APP_URL` | `https://<backend-domain>` | signed storage-inspector links are bound to it |
| `FRONTEND_URL` | `https://<frontend-domain>` | password-reset links, e-mail links, payment result pages |
| `CERTIFICATE_PUBLIC_URL` | `https://<frontend-domain>` once the frontend proxies `/certificate/` (section 14.4), empty otherwise | public certificate links |
| `BACKEND_VERSION` | `"#<short commit>"`, with the quotes | shown in the app; command in section 7.2 |
| `LOG_LEVEL` | `warning` or `error` | `debug` fills `storage/logs/laravel.log` fast |
| `TURNSTILE_BYPASS` | `false` | **must be written**: when the line is missing, the captcha check is skipped |
| `CLOUDFLARE_TURNSTILE_SITE_KEY` / `_SECRET_KEY` | the environment's widget keys | section 7.3 |
| `CACHE_DRIVER` | `file` | |
| `QUEUE_CONNECTION` | `sync` | no queue worker is needed |
| `FILESYSTEM_DISK` | `local` | |
| `DB_*` | the environment's database | |
| `R2_*` | the Cloudflare R2 settings | chat images and admin backups go to R2 |
| `MAIL_*` | the SMTP mailbox | |
| `KONNECT_URL`, `KONNECT_INIT_URL`, `KONNECT_API_KEY`, `KONNECT_WALLET_ID` | the Konnect account | all four are needed for payments; `KONNECT_INIT_URL` and `KONNECT_WALLET_ID` are not in `.env.example`, so a server that takes payments today already has them: keep them |
| `JWT_SECRET` | keep the current value | |

The project keeps its Laravel 10 configuration files, so the key names stay `BROADCAST_DRIVER` and `CACHE_DRIVER`. Don't rename them to Laravel 11's `BROADCAST_CONNECTION` / `CACHE_STORE`: those names are not read.

### 6.4 One line per key

Laravel reads `.env` from top to bottom and **the last line wins**: `BROADCAST_DRIVER=reverb` near the top and `BROADCAST_DRIVER=log` further down runs with `log`. List the keys that appear twice:

```bash theme={null}
grep -E '^[A-Z_]+=' .env | cut -d= -f1 | sort | uniq -d       # must print nothing
```

A value with a space, a `#` or a `$` goes between double quotes (`BACKEND_VERSION="#40b6709"`): an unquoted `#` starts a comment.

***

## 7. Generate the `.env` secrets

### 7.1 Every secret at a glance

| Key | How to get it | Public? | On a server that already runs |
| :- | :- | :- | :- |
| `APP_KEY` | `artisan key:generate --show` | ❌ | **keep it** (see the warning below) |
| `JWT_SECRET` | `artisan jwt:secret --show` | ❌ | keep it |
| `REVERB_APP_ID` | 6 random digits | – | new, once per environment |
| `REVERB_APP_KEY` | 20 random characters | ✅ the apps receive it | new, once per environment |
| `REVERB_APP_SECRET` | 20 random characters | ❌ | new, once per environment |
| `STORAGE_INSPECTOR_TOKEN` | 64 random characters | ❌ | new |
| `DB_PASSWORD` | Plesk → Databases → the user | ❌ | keep it |
| `BACKEND_VERSION` | `git rev-parse --short HEAD` | ✅ | update on every deploy |
| `CLOUDFLARE_TURNSTILE_SITE_KEY` / `_SECRET_KEY` | Cloudflare dashboard | site key ✅ / secret ❌ | keep them |
| `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` | Cloudflare dashboard | ❌ | keep them |
| `MAIL_PASSWORD` | OVHcloud mailbox | ❌ | keep it |
| `KONNECT_API_KEY` / `KONNECT_WALLET_ID` | Konnect dashboard | ❌ | keep them |

> [!WARNING]
>
> **Never generate a new `APP_KEY` on a server that already runs.** It encrypts the platform settings stored encrypted in the database, the signed links (storage inspector, e-mail and impersonation links already sent) and the sessions. With a new key, every encrypted setting fails to decrypt (`500`). Generate it only on a **new server**, once. If it ever leaked, plan the rotation: re-enter the encrypted settings after the change.

### 7.2 The values you generate yourself

They are random strings, so any machine can make them. The first tab needs only `openssl`: it runs the same on your computer (macOS or Linux terminal) and in the server's SSH shell, with no PHP and no `$PHP`. The second tab needs the session set up as in section 2.4.

> [tab] With openssl (computer or server)
>
> ```bash theme={null}
> # Reverb: one set per environment (TEST and PROD must differ)
> printf 'REVERB_APP_ID=%s\nREVERB_APP_KEY=%s\nREVERB_APP_SECRET=%s\n' \
>   "$(( 0x$(openssl rand -hex 3) % 900000 + 100000 ))" "$(openssl rand -hex 10)" "$(openssl rand -hex 10)"
>
> # STORAGE_INSPECTOR_TOKEN
> echo "STORAGE_INSPECTOR_TOKEN=$(openssl rand -hex 32)"
>
> # APP_KEY: new server only
> echo "APP_KEY=base64:$(openssl rand -base64 32)"
>
> # JWT_SECRET: new server only
> echo "JWT_SECRET=$(openssl rand -hex 32)"
>
> # A database password with no character .env would need to quote
> openssl rand -hex 24
> ```

> [tab] With PHP (server)
>
> ```bash theme={null}
> # Once per SSH session (section 2.4): as the subscription's user, in the backend folder
> cd /var/www/vhosts/<backend-domain>/httpdocs
> PHP=/opt/plesk/php/<X.Y>/bin/php          # e.g. /opt/plesk/php/8.4/bin/php
>
> # Reverb: one set per environment
> $PHP -r 'echo "REVERB_APP_ID=", random_int(100000, 999999), PHP_EOL;'
> $PHP -r 'echo "REVERB_APP_KEY=", bin2hex(random_bytes(10)), PHP_EOL;'
> $PHP -r 'echo "REVERB_APP_SECRET=", bin2hex(random_bytes(10)), PHP_EOL;'
>
> # STORAGE_INSPECTOR_TOKEN
> echo "STORAGE_INSPECTOR_TOKEN=$(openssl rand -hex 32)"
>
> # APP_KEY: new server only. --show prints it and changes nothing
> $PHP artisan key:generate --show
>
> # JWT_SECRET: new server only. --show prints it and changes nothing
> $PHP artisan jwt:secret --show
>
> # BACKEND_VERSION: after the pull, written straight into .env
> sed -i "s/^BACKEND_VERSION=.*/BACKEND_VERSION=\"#$(git rev-parse --short HEAD)\"/" .env
> grep ^BACKEND_VERSION .env
> ```

Paste each printed line into `.env`, replacing the empty or placeholder line. Don't save the output anywhere else, and don't paste it into a chat or a ticket. Only lowercase letters and digits come out of these commands, which is what Reverb and `.env` need.

> [accordion] The PHP command fails on the server
>
> **`-r: command not found`**: `$PHP` is empty in this shell, so bash takes `-r` for the command. Run the `cd` and `PHP=` lines at the top of the tab first, in the same shell. Any other error: the table in section 2.4. Meanwhile, the first tab gives the same kind of values without PHP.

### 7.3 The values a dashboard gives you

| Key | Where | Steps |
| :- | :- | :- |
| `CLOUDFLARE_TURNSTILE_SITE_KEY`, `CLOUDFLARE_TURNSTILE_SECRET_KEY` | Cloudflare dashboard → **Turnstile** | open the environment's widget → **Settings**. The widget's hostnames must list the frontend domain. **Rotate secret key** gives a new secret |
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Cloudflare dashboard → **R2 Object Storage** → **Manage API tokens** | **Create API token**, permission *Object Read & Write*, limited to the bucket of `R2_BUCKET`. The secret is shown **once**: paste it into `.env` straight away. Delete the old token after the switch |
| `MAIL_PASSWORD` | OVHcloud Control Panel → **Web Cloud** → **Emails** → the domain | the sender mailbox → **Change password** |
| `KONNECT_API_KEY`, `KONNECT_WALLET_ID` | Konnect merchant dashboard | the API key and the wallet id of the account that receives the payments; use the account that matches the environment |
| `DB_PASSWORD` | Plesk → **Databases** → the database → **User Management** | the user → **Generate** (or paste a value from 7.2) → **OK**, then the same value in `.env` |

After any change to `.env`: section 9 (clear the caches, restart PHP), and `$PHP artisan reverb:restart` once Reverb runs.

***

## 8. Run the migrations

```bash theme={null}
$PHP artisan migrate:status | grep -i pending      # what will run
$PHP artisan migrate --force
$PHP artisan migrate:status | grep -i pending      # must print nothing
```

Run them **on the server**: run from a laptop, `migrate` changes the laptop's database. A server deployed on 09/10/2026 runs these (the first one only if it is still pending):

| Migration | What it does | Safe on a live database? |
| :- | :- | :- |
| `2026_10_02_000000_add_views_count_to_quizzes_table` | exam view counter | ✅ new column |
| `2026_10_16_000000_add_type_to_lessons_table` | `lessons.type`, every existing lesson becomes `document` | ✅ new column with a default |
| `2026_10_19_000000_backfill_missing_quiz_slugs` | gives a slug to quizzes and exams that have none | ✅ data only, other rows untouched |
| `2026_10_19_100000_add_progress_indexes_to_student_tables` | 6 indexes for the login payload queries | ✅ MySQL 8 adds indexes without blocking writes; seconds on our tables |
| `2026_10_20_000000_create_chatrooms_tables` | `chatrooms`, `chatroom_members`, `chatroom_messages`, `chatroom_attachments` | ✅ new tables |
| `2026_10_20_100000_backfill_missing_classroom_slugs` | gives a slug to classrooms that have none | ✅ data only |
| `2026_10_25_120000_add_new_features_to_authentication_log_table` | 6 nullable columns for the Laravel 12 login log | ✅ new columns |
| `2026_11_01_000000_keep_one_category_per_item` | keeps the **oldest** category of each training / quiz / exam, deletes the others, adds a unique index | ⚠️ deletes rows, after writing them to a backup file |

**Before the category migration**, see which items will lose categories (phpMyAdmin → SQL):

```sql theme={null}
SELECT training_id, COUNT(*) AS categories FROM category_training GROUP BY training_id HAVING COUNT(*) > 1;
SELECT quiz_id,     COUNT(*) AS categories FROM category_quiz     GROUP BY quiz_id     HAVING COUNT(*) > 1;
```

The rows it deletes are saved in `storage/app/backups/category-pivots/<date-time>.json` (nothing is written when every item already has one category). Keep that file: it is the only way to put a category back by hand. The storage inspector lists it as `orphan`; don't delete it there.

Rollback, if ever needed: `$PHP artisan migrate:rollback --step=N` undoes the last `N` migrations, but the category rows only come back from the backup file, and rolling back the chatroom tables deletes their messages. Prefer the database dump of section 3.

***

## 9. Clear the caches and restart PHP

> [!WARNING]
>
> **Don't run `php artisan optimize` or `php artisan config:cache` on this backend.** Several controllers read `.env` directly with `env()`: the Konnect keys, `FRONTEND_URL`, the mail sender, the R2 custom domain, `BACKEND_VERSION`. Once the configuration is cached, Laravel stops loading `.env` and those calls return `null`. Konnect payments then fail, and password-reset links lose their domain. This was checked on Laravel 12.70 on 09/11/2026. Cache the routes and the views only, until those calls move into `config/` files.

```bash theme={null}
$PHP artisan optimize:clear            # removes every cache, including a config cache left by an older deploy
$PHP artisan route:cache
$PHP artisan view:cache
$PHP artisan about --only=cache        # Config: NOT CACHED · Routes: CACHED · Views: CACHED
```

Then restart PHP so OPcache drops the Laravel 10 files: Plesk → the backend domain → **PHP Settings** → **Apply** (saving restarts the handler), or **Tools & Settings** → **Services Management** → restart PHP-FPM, or as root `systemctl restart plesk-php<XY>-fpm` (`plesk-php84-fpm` for 8.4). Do this after **every** pull and every `.env` change.

Leave maintenance mode (if section 5 turned it on):

```bash theme={null}
$PHP artisan up
curl -s -o /dev/null -w "%{http_code}\n" https://<backend-domain>/api/health     # 200
```

***

## 10. nginx: the backend's additional directives

Plesk → **Websites & Domains** → the **backend** domain → **Apache & nginx Settings** → **Additional nginx directives**. That box is one field for the whole site (HTTP and HTTPS). Add only what is missing; the full box reads:

```nginx theme={null}
# Uploads: the same size as PHP's post_max_size (chat images need 25 MB, JSON imports up to 300 MB)
client_max_body_size 300m;

# Local-storage images readable by the Flutter web app (CanvasKit needs CORS on them)
location ^~ /storage/ {
    add_header Access-Control-Allow-Origin "*" always;
    try_files $uri =404;
}

# Chatrooms: wss:// on the site's certificate, forwarded to the local Reverb process
location ^~ /app/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_read_timeout 120s;
    proxy_send_timeout 120s;
}
```

Press **OK**: Plesk checks the syntax and reloads nginx. If it refuses, the message names the line. The two `location` blocks don't conflict: each one handles its own path.

### 10.1 `client_max_body_size`

nginx refuses a request body bigger than this with `413 Request Entity Too Large`, before PHP sees it. Writing the line **replaces** Plesk's default for the site, so give it the same value as `post_max_size` (section 4.3). `25m` is enough for chat images but would break the JSON imports and any upload above 25 MB.

### 10.2 The `/storage/` block: images on the web app

**Why it is needed.** The Flutter web app draws with **CanvasKit**. To show a network image it downloads the image's bytes with a cross-origin request and uploads them to the GPU, and the browser allows that only when the image's answer carries an `Access-Control-Allow-Origin` header. On Plesk, nginx answers `/storage/...` images straight from the disk ("Serve static files directly by nginx"), so the request never reaches Apache or Laravel and Laravel's CORS settings never run on it.

**Without the block:** images stored on the server's disk (training covers, logos, avatars, landing-page images uploaded to local storage) show as broken tiles **on the web app only**. They open fine in a browser tab, and the browser console says `No 'Access-Control-Allow-Origin' header` and `EncodingError: The source image cannot be decoded`. R2 images are not affected (the R2 bucket has its own CORS rule in Cloudflare), and neither are the phone apps (no CORS outside browsers).

| Line | What it does |
| :- | :- |
| `location ^~ /storage/` | handles every URL that starts with `/storage/`. `^~` makes nginx stop at this prefix, so Plesk's pattern-based static-file location doesn't take the request first |
| `add_header Access-Control-Allow-Origin "*" always;` | adds the CORS header that CanvasKit needs. `always` sends it on every status, including the `304 Not Modified` answers browsers often get for cached images |
| `try_files $uri =404;` | serves the same file as before, from the site's document root (`public/storage` → `storage/app/public`); a missing file answers `404` |

* `*` is right here: images are fetched without cookies, and these are the same files anyone can already open by URL. Keep the header **inside** `/storage/`: the API answers with the caller's origin and allows credentials, and a global `*` would break it.
* It needs the `public/storage` link (`php artisan storage:link`, section 5).
* `/storage-inspector` is not affected: it does not start with `/storage/` (no slash after `storage`), so it still reaches Laravel.

### 10.3 The `/app/` block: live chat messages

**Why it is needed.** Live chat uses **Laravel Reverb**, a WebSocket server that runs as its own process next to Laravel and listens on `127.0.0.1:8080`, which only the server itself can reach. The apps can't reach that port, and they need an encrypted `wss://` connection on the site's existing certificate. So they open:

```text theme={null}
wss://<backend-domain>/app/<REVERB_APP_KEY>?protocol=7
```

`/app/<key>` is the fixed path every Pusher-protocol client opens. This block catches it and forwards it to Reverb:

```text theme={null}
Flutter app ── wss://<backend-domain>/app/<key> ──▶ nginx :443 (site certificate) ──▶ Reverb 127.0.0.1:8080
Laravel API ── http://127.0.0.1:8080/apps/<id>/events (posts each event) ─────────▶ Reverb
```

No new domain, no new certificate and no open port. Laravel's own event posts go straight to `127.0.0.1:8080` and never cross nginx, so they need no block.

| Line | What it does |
| :- | :- |
| `location ^~ /app/` | catches the Pusher path. `^~` stops nginx at this prefix, so none of Plesk's pattern-based locations takes it first |
| `proxy_pass http://127.0.0.1:8080;` | forwards to Reverb. `8080` is `REVERB_SERVER_PORT`; use the environment's own port when several share a server. No path after the port: the full original URL is forwarded unchanged |
| `proxy_http_version 1.1;` | WebSocket needs HTTP/1.1 between nginx and Reverb |
| `proxy_set_header Upgrade $http_upgrade;` and `Connection "Upgrade"` | pass the browser's "switch to WebSocket" request on. Without them nginx sends a plain HTTP request and the connection never upgrades |
| `proxy_set_header Host $host;` | Reverb sees the public host name |
| `proxy_set_header X-Forwarded-For …` | Reverb sees the visitor's address instead of `127.0.0.1` |
| `proxy_read_timeout 120s;` and `proxy_send_timeout 120s;` | nginx's default of 60 s would close quiet sockets. Reverb pings every 60 s (`REVERB_APP_PING_INTERVAL`), so 120 s keeps idle connections open |

**Without the block** the chat still works, just not live: `wss://…/app/…` reaches Laravel and gets a `404`, the app falls back to **polling every 5 seconds**, and messages arrive with that delay. Live mode needs all three: `BROADCAST_DRIVER=reverb`, the Reverb process kept running (section 12), and this block.

Keep port `8080` closed in the firewall: only nginx and Laravel, on the same machine, use it.

### 10.4 Check both blocks

From your computer:

```bash theme={null}
# /storage/: expect "access-control-allow-origin: *"
curl -sI -H "Origin: https://<frontend-domain>" \
  "https://<backend-domain>/storage/files/<any local file name>" | grep -i access-control

# /app/: run it once Reverb runs (section 12)
curl -s -i -N --http1.1 --max-time 5 \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
  "https://<backend-domain>/app/<REVERB_APP_KEY>?protocol=7"
```

| `/app/` answer | Meaning |
| :- | :- |
| `101 Switching Protocols` + a `pusher:connection_established` line | nginx and Reverb both work |
| Laravel's `404` page | the block is missing or not applied |
| `502 Bad Gateway` | the block works, Reverb is not running |
| `500 "Internal server error."` (for a `curl -i` without the upgrade headers) | expected: Reverb only speaks WebSocket there |

A local file name to test with: any image from the storage inspector (section 13), or the `url` of a training cover uploaded to local storage.

***

## 11. The scheduler (cron)

Plesk → **Scheduled Tasks** → look for an existing `schedule:run` task first. If there is none: **Add Task** → *Run a command*, every minute (`* * * * *`), as the site's system user:

```text theme={null}
/opt/plesk/php/<X.Y>/bin/php /var/www/vhosts/<backend-domain>/httpdocs/artisan schedule:run >> /dev/null 2>&1
```

```bash theme={null}
$PHP artisan schedule:list
# 0 0 * * *  php artisan notifications:delete-expired
# 0 0 * * *  php artisan chatrooms:prune-attachments
```

`chatrooms:prune-attachments` deletes, every night, chat images that were uploaded but never sent (older than 24 hours). Without the cron they pile up in R2.

***

## 12. Laravel Reverb: live chat messages

Without this section the chat works by polling every 5 seconds. With it, a message reaches the other side in milliseconds. No Redis and no queue worker are needed (one Reverb process holds thousands of connections). Full details: [Chatrooms Setup Guide](https://theteam-13fc7f49.mintlify.site/deployment/chatrooms-setup.md).

**1. Try Reverb by hand**

```bash theme={null}
$PHP artisan reverb:start --debug
#   INFO  Starting server on 127.0.0.1:8080.
```

In a second SSH shell, send a test event (the prefix forces Reverb while `.env` still says `log`):

```bash theme={null}
cd /var/www/vhosts/<backend-domain>/httpdocs && PHP=/opt/plesk/php/<X.Y>/bin/php
BROADCAST_DRIVER=reverb $PHP artisan tinker --execute='event(new App\Events\ChatroomUpdated(0, "ping", true)); echo "sent";'
```

The first shell logs a block for `private-chatroom.0`. Stop it with `Ctrl+C`. Never use `--debug` under supervisor (it prints every frame).

**2. Keep it running (root, once)**

Plesk's Scheduled Tasks cannot keep a process alive. Pick one:

> [tab] supervisor
>
> ```bash theme={null}
> apt install supervisor            # Debian / Ubuntu (dnf install supervisor on AlmaLinux)
> nano /etc/supervisor/conf.d/reverb-prod.conf
> ```
>
> ```ini theme={null}
> [program:reverb-prod]
> command=/opt/plesk/php/<X.Y>/bin/php /var/www/vhosts/<backend-domain>/httpdocs/artisan reverb:start --host=127.0.0.1 --port=8080
> directory=/var/www/vhosts/<backend-domain>/httpdocs
> user=<site system user>
> autostart=true
> autorestart=true
> startretries=10
> stopwaitsecs=10
> stdout_logfile=/var/www/vhosts/<backend-domain>/logs/reverb.log
> stderr_logfile=/var/www/vhosts/<backend-domain>/logs/reverb-error.log
> ```
>
> ```bash theme={null}
> supervisorctl reread
> supervisorctl update
> supervisorctl status reverb-prod        # RUNNING
> ```

> [tab] systemd
>
> ```ini theme={null}
> # /etc/systemd/system/reverb-prod.service
> [Unit]
> Description=Laravel Reverb (PROD)
> After=network.target
>
> [Service]
> User=<site system user>
> WorkingDirectory=/var/www/vhosts/<backend-domain>/httpdocs
> ExecStart=/opt/plesk/php/<X.Y>/bin/php artisan reverb:start --host=127.0.0.1 --port=8080
> Restart=always
> RestartSec=3
>
> [Install]
> WantedBy=multi-user.target
> ```
>
> ```bash theme={null}
> systemctl daemon-reload
> systemctl enable --now reverb-prod
> systemctl status reverb-prod
> ```

Run Reverb as the **site's system user**: `reverb:restart` signals the process through the file cache, which only works for the same user. One program per environment; two environments on one server get two ports (`8080`, `8081`), used in their `REVERB_SERVER_PORT`, `REVERB_PORT` and nginx block.

**3. Check the nginx block**

Run the `/app/` `curl` of section 10.4: it must answer `101 Switching Protocols`.

**4. Switch the app to live**

In `.env`:

```env theme={null}
BROADCAST_DRIVER=reverb
REVERB_CLIENT_HOST=<backend-domain>      # without https://
REVERB_CLIENT_PORT=443
REVERB_CLIENT_SCHEME=https
```

```bash theme={null}
$PHP artisan optimize:clear && $PHP artisan route:cache && $PHP artisan view:cache
$PHP artisan reverb:restart              # Reverb re-reads .env; supervisor starts it again
```

From your computer, with the token of a test account (from `POST /api/auth/login`):

```bash theme={null}
curl -s -H "Accept: application/json" -H "Authorization: Bearer <token>" https://<backend-domain>/api/realtime/config
# {"enabled":true,"driver":"reverb","key":"<REVERB_APP_KEY>","host":"<backend-domain>","port":443,"scheme":"https","auth_endpoint":"/api/broadcasting/auth"}
```

Open the same chatroom in two browsers (a learner and the instructor): a message shows up on the other side at once. The apps need no rebuild: they read `GET /api/realtime/config` when a conversation opens.

**Back to polling** at any time: `BROADCAST_DRIVER=log`, then section 9. The apps poll again within 5 seconds; Reverb can keep running.

***

## 13. The local storage inspector (`/storage-inspector`)

A **read-only web page** on the backend that answers one question: *what is stored on the server's disk, and who uses it?* It lists every file under `storage/app` (the "local" disk, not Cloudflare R2), finds the database rows that point at each file, and labels it. It never deletes, moves or changes anything.

### 13.1 Why use it

* **Before cleaning the disk**: see which files nothing uses anymore, and which are still referenced.
* **When an image is broken** in the app: find the file's row and status (`missing` = the file is gone from the disk).
* **To know what a file is for**: which training, exam, user, company or landing page uses it, with ids and slugs.
* Disk usage numbers live elsewhere: the status page (`https://<backend-domain>/`) and `GET /api/local_storage_stats`. R2 files: `GET /api/r2_storage_stats` and the R2 file endpoints ([File Management & Storage](https://theteam-13fc7f49.mintlify.site/api-reference/file-management-and-storage-api.md)).

### 13.2 Open it

The page answers `404` to anyone else. Three ways in:

| Way | How |
| :- | :- |
| **Token** (recommended) | `STORAGE_INSPECTOR_TOKEN=<64 random characters>` in `.env` (section 7.2), then section 9. Open `https://<backend-domain>/storage-inspector?token=<token>`. The header `X-Storage-Inspector-Token` works too |
| **Temporary signed link** | `$PHP artisan storage:inspector-url --minutes=60` prints a link valid 60 minutes. It is bound to `APP_URL`: open it on exactly that host. Works with an empty token |
| **Local machine** | `APP_ENV=local` opens it without anything. That's why servers must say `APP_ENV=production` |

The route allows 30 requests per minute per address and tells search engines not to index it. Paging and filter links keep the token or signature. Treat the token like a password: whoever has it sees every file name and who uses it. To change it, write a new value and run section 9 again.

### 13.3 What each row shows

| Column | Content |
| :- | :- |
| **File** | path, size, last change, an **open** link for public files, a thumbnail for images |
| **Registry** | the matching row(s) of the `files` table: `File #id`, type, company, uploading user, URL, and whether the row is active, archived or soft-deleted |
| **Used by** | every database row pointing at the file: model, **id**, **slug**, label and state, plus the parent for nested content (the training of a lesson, the quiz of a question) and the column it came through |
| **Status** | one of the five below |

| Status | Meaning | What to do |
| :- | :- | :- |
| `used` | at least one active row points at it | keep |
| `inactive` | only archived or deleted rows point at it | keep while those rows can still be restored |
| `unused` | it has a `files` row, but nothing points at it | can go: `DELETE /api/delete_file/{id}` removes the row and the file |
| `orphan` | on the disk, no `files` row, no reference | leftovers (failed uploads, old 2FA QR codes, files copied by hand, backups): look before deleting by hand |
| `missing` | a `files` row whose file is gone from the disk | the model pointing at it shows a broken image: upload it again or clear the reference |

It finds references through the foreign keys to `files.id` (training images, videos and badges, quiz and question images, user avatars, company and certificate logos, landing-page images, theme logo), the URL columns (theme logo, auth page image, default badge, partner logos), the rich text that embeds `/storage/...` links (lessons, classrooms, training and quiz descriptions, question labels, landing page, public profiles), and two naming rules (`certificate-images/<id>-<fingerprint>.png`, the cached certificate share images, rebuilt on demand; `public/files/2fa-<user>-<time>.png`, the 2FA QR codes).

**Trust it in one direction only.** A file marked `used` is used. A file marked `orphan` could still be used by something the page cannot see: a URL hard-coded in the frontend, a lesson link without `/storage/`, or a backup such as `backups/category-pivots/*.json` (section 8). Look at it before deleting, and keep the backup of section 3.

### 13.4 Filters

Add them to the URL (the page's form does it for you): `q` (path, URL, model, slug, label or `#id`), `status`, `area` (folder), `sort` (`modified`, `size`, `name`, `status`, `area`), `dir` (`asc`, `desc`), `per_page` (10 to 200, default 50), `page`. Example:

```text theme={null}
https://<backend-domain>/storage-inspector?token=<token>&status=orphan&sort=size&dir=desc
```

The disk scan and the database index are cached for `STORAGE_INSPECTOR_CACHE_SECONDS` (default 60). The **rescan now** link (`refresh=1`) rebuilds them at once.

***

## 14. Deploy the frontend (Flutter web)

Deploy the frontend **right after the backend, in the same session**. Since 19/10/2026 the backend sends the slim login payload by default, and a web build from before that date no longer understands it.

**1. Prepare your computer (once)**

```bash theme={null}
flutter --version                   # Flutter 3.44.x, channel stable
cd LMS_Frontend
git checkout main && git pull
```

Create `.github/deployment/.env.deploy` (git-ignored) with the SSH accounts of the frontend domains:

```env theme={null}
PROD_SFTP_HOST=<server host>
PROD_SFTP_USERNAME=<frontend domain system user>
PROD_SFTP_PASSWORD=<password, or leave empty and use an SSH key>
PROD_SSH_PORT=22
# same four lines with TEST_ and DEV_
```

With `sshpass` installed (`brew install sshpass`) the upload uses the password without asking; otherwise SSH asks for it.

**2. Point the build at the right backend**

The API address is fixed at build time in `lib/core/constants/constants.dart`. The script's `test` / `dev` / `prod` argument only chooses **which server receives the files**, not which backend the app calls. For PROD:

```dart theme={null}
static const Environment currentEnvironment = Environment.prod;
static const baseUrl = 'https://service.elearning.theteam.com.tn/api';   // uncomment the prod pair
static const appBaseUrl = 'https://elearning.theteam.com.tn';
```

and comment out the TEST pair. A PROD deploy with `Environment.test` publishes the PROD site talking to the TEST backend.

**3. Build and upload**

```bash theme={null}
./.github/deployment/deploy_web_canvaskit_344.sh prod          # or test / dev
#   --build-only : build into build/web/ without uploading
#   --no-build   : upload the existing build/web/
```

The script runs `flutter clean`, `pub get`, both translation generators, `build_runner`, `flutter build web --release --pwa-strategy=none` (CanvasKit; `prod` adds the commit hash), trims the font manifest, then mirrors `build/web/` into the domain's `httpdocs/` with `rsync --delete`.

> [!WARNING]
>
> The upload is a **1:1 mirror**: any file in the frontend's `httpdocs/` that is not in the new build is deleted. Check the environment name before pressing Enter, and keep nothing else in that folder.

Then put `constants.dart` back to TEST so day-to-day work doesn't call production:

```bash theme={null}
git checkout lib/core/constants/constants.dart
```

**4. The frontend domain's nginx: certificate links**

Public certificate pages are rendered by the backend but shared on the app's domain (`https://<frontend-domain>/certificate/ID/...`). Plesk → the **frontend** domain → **Apache & nginx Settings** → **Additional nginx directives**:

```nginx theme={null}
location ^~ /certificate/ {
    proxy_pass            https://<backend-domain>;
    proxy_set_header      Host <backend-domain>;
    proxy_set_header      X-Forwarded-Host $host;
    proxy_set_header      X-Forwarded-Proto $scheme;
    proxy_set_header      X-Real-IP $remote_addr;
    proxy_ssl_server_name on;
}
```

* `^~` wins over the Flutter fallback to `index.html`, so `/certificate/...` never reaches the web app.
* `Host <backend-domain>` is required: Plesk picks the backend site by host name. `proxy_ssl_server_name on` sends that name during the TLS handshake so the certificate matches.
* No path after the domain in `proxy_pass`: the full URL is forwarded unchanged.

Then, on the **backend**, `CERTIFICATE_PUBLIC_URL=https://<frontend-domain>` and section 9. Without the proxy, leave that key empty: the links stay on the backend domain and still work.

**5. Phone apps**

Build the Android and iOS apps from the same commit, with the same `constants.dart` change, and publish them: an app built before 19/10/2026 does not understand the new login payload either.

The web app is no longer a PWA (since 19/10/2026): the new `index.html` removes the old service worker, so a visitor gets the new version after one reload.

***

## 15. Check everything

On the server:

```bash theme={null}
$PHP artisan --version                              # Laravel Framework 12.70.2
$PHP artisan about --only=cache                     # Config NOT CACHED, Routes CACHED, Views CACHED
$PHP artisan migrate:status | grep -i pending       # nothing
$PHP artisan route:list --path=chatrooms | tail -1  # Showing [11] routes
$PHP artisan route:list --path=realtime             # GET api/realtime/config
$PHP artisan schedule:list                          # the two daily commands of section 11
grep -E '^[A-Z_]+=' .env | cut -d= -f1 | sort | uniq -d   # nothing
supervisorctl status reverb-prod                    # RUNNING (once section 12 is done)
tail -n 50 storage/logs/laravel.log                 # no new errors
```

From your computer:

```bash theme={null}
curl -sI https://<backend-domain>/ | grep -i x-powered-by                          # PHP/<X.Y>.…, at least the build of section 4.1
curl -s -o /dev/null -w "%{http_code}\n" https://<backend-domain>/api/health        # 200
curl -s -o /dev/null -w "%{http_code}\n" https://<backend-domain>/storage-inspector # 404 (no token)
curl -s -o /dev/null -w "%{http_code}\n" "https://<backend-domain>/storage-inspector?token=<token>"   # 200
curl -s -o /dev/null -w "%{http_code}\n" https://<frontend-domain>/certificate/verify   # 200 (with the proxy)
# + the /storage/ and /app/ checks of section 10.4, and /api/realtime/config of section 12
```

In the apps:

* [ ] The status page `https://<backend-domain>/` is green (database, storage link, R2, mail, Turnstile).
* [ ] Log in as a student: the dashboard opens, training covers show (web too), a lesson and a quiz open.
* [ ] Log in as an admin: download the dashboard PDF (dompdf), open the platform settings.
* [ ] Create a classroom, create its chatroom, send a message from the instructor: the learner sees it (at once with Reverb, within 5 s without).
* [ ] Send a chat image of a few MB: no `413`.
* [ ] Open a certificate link and the verify page on the frontend domain.
* [ ] On TEST: start a Konnect payment from the cart; it must open Konnect's payment page (a cancelled payment comes back to the app as failed).
* [ ] "Forgot password" sends an e-mail whose link opens the frontend domain.

***

## 16. Optional clean-ups

| What | Why | How |
| :- | :- | :- |
| Uninstall wkhtmltopdf | not used since 24/10/2026 | [wkhtmltopdf removal](https://theteam-13fc7f49.mintlify.site/clean-up/wkhtmltopdf-removal.md), "Uninstall the binaries" (needs root, after the new backend runs) |
| Drop the Telescope tables | ≈6.6 GB of old debug logs, ≈98 % of the production database | [Monitoring & Logs](https://theteam-13fc7f49.mintlify.site/deployment/monitoring-and-logs.md), "Removing Telescope safely" |
| Faster API answers | CORS preflights answered by nginx, OPcache settings, compressed fonts on the frontend | [Auth User Super Fast](https://theteam-13fc7f49.mintlify.site/clean-up/auth-user-super-fast.md), steps 1b, 5 and 6b. Where it says `php artisan optimize`, use section 9 of this page instead |

***

## 17. Every later deploy

Once a server has gone through this page, a normal deploy is:

```bash theme={null}
cd /var/www/vhosts/<backend-domain>/httpdocs && PHP=/opt/plesk/php/<X.Y>/bin/php
git log --oneline -1 | tee ~/deploy-previous-commit.txt
git pull --ff-only origin main
$PHP /usr/lib/plesk-9.0/composer.phar install --no-dev --optimize-autoloader --no-interaction
$PHP artisan migrate --force
sed -i "s/^BACKEND_VERSION=.*/BACKEND_VERSION=\"#$(git rev-parse --short HEAD)\"/" .env
$PHP artisan optimize:clear && $PHP artisan route:cache && $PHP artisan view:cache
$PHP artisan reverb:restart          # Reverb runs the code it started with
# then restart the domain's PHP: Plesk → PHP Settings → Apply
```

Then the frontend (section 14, steps 2 and 3) when it changed.

***

## 18. Roll back

The fastest safe rollback is the **previous code with the database dump of section 3**. It loses what users did after the deploy, so decide quickly.

```bash theme={null}
$PHP artisan down --retry=60
git checkout <commit from ~/deploy-previous-commit.txt>
rm -f bootstrap/cache/*.php
$PHP /usr/lib/plesk-9.0/composer.phar install --no-dev --optimize-autoloader --no-interaction
gunzip < ~/db-<database>-<date>.sql.gz | mysql -u <db user> -p <database>
cp ~/env-backup-<date>.env .env
$PHP artisan optimize:clear
$PHP artisan up
# restart the domain's PHP; stop Reverb (supervisorctl stop reverb-prod), the old code has no chatrooms
```

Then redeploy the previous frontend build (check out its commit and run section 14). Go back to `main` with `git checkout main` for the next attempt.

***

## 19. Troubleshooting

| Symptom | Cause | Fix |
| :- | :- | :- |
| `500 "Composer detected issues in your platform"` | the domain's PHP is older than the lowest build of its branch | section 4.1 |
| `composer install` refuses: `php ~8.2.35 …` | Composer runs with an old or unpatched PHP | run it as `$PHP /usr/lib/plesk-9.0/composer.phar` (section 5) |
| `Class "Barryvdh\Snappy\…" not found` or another class from Laravel 10 | old files in `bootstrap/cache/` | section 5, "Delete the Laravel 10 cache files", then `composer install` |
| Konnect payments fail, reset links lack the domain, the app shows the version as `prod` | the configuration is cached (`optimize` or `config:cache` ran) | `$PHP artisan config:clear`, then section 9 |
| A `.env` change has no effect | cached configuration, PHP not restarted, or the key appears twice | section 9; section 6.4 |
| Training covers and logos broken **on the web app only** | `/storage/` block missing | section 10.2 |
| Upload answers `413` | `client_max_body_size` too small | section 10.1 |
| Upload answers `422` "failed to upload" | PHP `upload_max_filesize` / `post_max_size` too small | section 4.3 |
| The chat keeps polling | Reverb not running, `/app/` block missing, `REVERB_CLIENT_*` wrong or `BROADCAST_DRIVER` still `log` | sections 10.4 and 12 |
| The WebSocket closes with code `4001` | the key the API sends is not the one Reverb started with | same `.env` for both, then `$PHP artisan reverb:restart` |
| `cURL error 7 … 127.0.0.1:8080` in the log on each chat message | Reverb is down (the apps poll meanwhile) | `supervisorctl status`; or `BROADCAST_DRIVER=log` until it runs |
| `/storage-inspector` answers `404` with the token | token not set, configuration cached, or a typo | section 13.2 and section 9 |
| The signed inspector link answers `404` | opened on another host than `APP_URL`, or expired | open it on the `APP_URL` host; print a new one |
| The login asks for a captcha on a local machine, or never on PROD | `TURNSTILE_BYPASS` | `true` locally, `false` on servers |
| Certificate links still show the backend domain | `CERTIFICATE_PUBLIC_URL` empty, or no frontend proxy | section 14, step 4 |
| The PROD site calls the TEST backend | `constants.dart` still on `Environment.test` at build time | section 14, step 2, and deploy again |
| `curl -X POST` gets an HTML **403 Forbidden**, then every request times out | the Plesk firewall bans a body-less `POST` | send `-d '{}'`; unban in Plesk → Tools & Settings → **IP Address Banning** |

Where to look: `storage/logs/laravel.log` (the API), the supervisor `stdout_logfile` (Reverb), and Plesk → the domain → **Logs** (nginx and Apache).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.