# Symfony 8.0 / PHP 8.4 Migration Guide

This document outlines the steps required to migrate Academia from Symfony 6.4/PHP 8.2 to Symfony 8.0/PHP 8.4.

## Pre-Migration Checklist

- [ ] Backup production database
- [ ] Backup production files
- [ ] Test migration in staging environment
- [ ] Schedule maintenance window

---

## 1. Deploy Code Changes

Deploy the upgraded codebase which includes:

- All entity files converted from `@ORM\` annotations to `#[ORM\...]` PHP 8 attributes
- Column type `array` changed to `json` (DBAL 4 requirement)
- DQL queries updated from bundle shorthand to FQCN
- `FETCH_EAGER` constants replaced with `'EAGER'` strings

---

## 2. Database Data Migration (CRITICAL)

### What Changed

Doctrine DBAL 4 removed the `array` column type. Columns that previously stored PHP serialized arrays now use `json` type and must store JSON-encoded data.

### Affected Tables and Columns

| Table | Column |
|-------|--------|
| `fos_user` | `roles` |
| `fos_user` | `languages` |
| `fos_user` | `last_read_research` |
| `academy_research` | `research_references` |

### Convert Data Using Console Command

A Symfony console command is available to convert the data:

```bash
# Preview changes (dry run)
php bin/console app:convert-serialized-to-json --dry-run

# Apply conversion
php bin/console app:convert-serialized-to-json

# Convert only a specific table
php bin/console app:convert-serialized-to-json --table=fos_user

# Convert only a specific column
php bin/console app:convert-serialized-to-json --table=fos_user --column=roles
```

### Manual SQL Alternative

If needed, you can also run raw SQL queries:

```sql
-- Check for serialized data (starts with 'a:' for arrays)
SELECT id, roles FROM fos_user WHERE roles LIKE 'a:%' LIMIT 10;

-- Note: Manual conversion requires a PHP script or application logic
-- The console command handles the conversion automatically
```

---

## 3. Set File Permissions (IMPORTANT)

After deploying, set proper ownership and permissions for the `var/` directory:

```bash
# Set ownership to web server user
sudo chown -R www-data:www-data var/

# Set secure permissions (owner read/write/execute, group read/execute)
sudo chmod -R 775 var/

# Ensure log directories exist
mkdir -p var/log/playground_debug
```

⚠️ **Security Warning**: Never use `chmod 777` in production. This allows any user to read, write, and execute files, which is a security risk.

---

## 4. Clear Caches

After deploying code and migrating data:

```bash
# Clear Symfony cache
php bin/console cache:clear --env=prod

# Clear opcache (if needed)
# Restart PHP-FPM or Apache
sudo systemctl restart php8.4-fpm
```

---

## 5. Run Doctrine Migrations

If there are pending migrations:

```bash
php bin/console doctrine:migrations:migrate --env=prod
```

---

## 6. Verify Installation

```bash
# Check Symfony version
php bin/console --version

# Check for deprecations
php bin/console debug:container --deprecations

# Test homepage
curl -sI https://your-domain.com/
```

---

## Issues Encountered During Migration

This section documents real problems found during the dev server deployment and how they were resolved.

### 1. Symfony Runtime Plugin Disabled

**Symptom:** After `composer install`, running `php bin/console` failed with:
```
PHP Fatal error: Uncaught LogicException: Symfony Runtime is missing.
Try running "composer require symfony/runtime".
```

**Cause:** `composer.json` had `"symfony/flex": false` and `"symfony/runtime": false` in the `allow-plugins` section. The runtime plugin must be enabled for `bin/console` and the front controller to work.

**Fix:** Set both to `true` in `composer.json`:
```json
"allow-plugins": {
    "symfony/flex": true,
    "symfony/runtime": true
}
```

---

### 2. Deprecated `auto_generate_proxy_classes` in Doctrine ORM 3

**Symptom:** `php bin/console cache:clear --env=prod` failed with:
```
Unrecognized option "auto_generate_proxy_classes" under "doctrine.orm.entity_managers.default"
```

**Cause:** Doctrine ORM 3 removed the `auto_generate_proxy_classes` option (proxies are now always lazy-generated). The setting was still present in `config/packages/prod/doctrine.yaml`.

**Fix:** Remove the line from `config/packages/prod/doctrine.yaml`:
```yaml
# Before
doctrine:
    orm:
        auto_generate_proxy_classes: false  # ← REMOVE THIS LINE

# After
doctrine:
    orm:
        metadata_cache_driver:
            ...
```

---

### 3. PHP Serialized Data Not Converted to JSON

**Symptom:** Homepage returned 500 with:
```
Doctrine\DBAL\Types\Exception\ValueNotConvertible:
"Could not convert database value to 'json' as an error was triggered by the unserialization: Syntax error"
```

**Cause:** The migration changed column types from `array` (PHP serialize) to `json` in entity mappings, but some database rows still contained PHP-serialized data instead of JSON. The Doctrine migrations (Version20260209110000) converted the known tables (`fos_user.roles`, `fos_user.languages`, `fos_user.last_read_research`, `academy_research.research_references`), but **missed two cases**:

| Table | Column | Issue | Rows Affected |
|-------|--------|-------|---------------|
| `fos_user` | `languages` | Contained `N;` (PHP serialized null) instead of SQL `NULL` | 8,122 |
| `fos_group` | `roles` | Still had `a:{...}` serialized arrays | 24 |
| `related_research_prompt` | `citations` | Still had `a:{...}` serialized arrays | 6 |

**Fix:** Run these SQL statements on the database:
```sql
-- Fix fos_user.languages: PHP serialized null → SQL NULL
UPDATE fos_user SET languages = NULL WHERE languages = 'N;';

-- Fix fos_group.roles: convert with PHP script (cannot do pure SQL for serialized→JSON)
-- Fix related_research_prompt.citations: same approach
```

For `fos_group.roles` and `related_research_prompt.citations`, use a PHP script:
```php
<?php
$pdo = new PDO('mysql:host=127.0.0.1;dbname=YOUR_DB', 'user', 'pass');

// Fix fos_group.roles
$rows = $pdo->query("SELECT id, roles FROM fos_group WHERE roles LIKE 'a:%'")->fetchAll(PDO::FETCH_ASSOC);
$stmt = $pdo->prepare('UPDATE fos_group SET roles = ? WHERE id = ?');
foreach ($rows as $row) {
    $json = json_encode(@unserialize($row['roles']) ?: []);
    $stmt->execute([$json, $row['id']]);
}

// Fix related_research_prompt.citations
$rows = $pdo->query("SELECT id, citations FROM related_research_prompt WHERE citations LIKE 'a:%'")->fetchAll(PDO::FETCH_ASSOC);
$stmt = $pdo->prepare('UPDATE related_research_prompt SET citations = ? WHERE id = ?');
foreach ($rows as $row) {
    $json = json_encode(@unserialize($row['citations']) ?: []);
    $stmt->execute([$json, $row['id']]);
}
```

> **Lesson learned:** When migrating from `type: 'array'` to `type: 'json'`, scan **ALL** text/longtext columns for serialized PHP data — not just the ones explicitly changed. Use this query to find them:
> ```sql
> SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS
> WHERE TABLE_SCHEMA='your_db'
> AND DATA_TYPE IN ('longtext','mediumtext','text')
> -- then for each column:
> SELECT COUNT(*) FROM table WHERE column LIKE 'a:%' OR column LIKE 'N;' OR column LIKE 'O:%';
> ```

---

### 4. Git History Contained Large Files (GitHub Push Rejected)

**Symptom:** `git push` to GitHub failed:
```
remote: error: File db_dev.sql is 96.00 MB; exceeds GitHub's 100 MB limit
remote: error: File arabic_research_backup.json.gz is 283.09 MB
```

**Cause:** Large files were previously committed to git history even though they were no longer in the working tree.

**Fix:** Used `git-filter-repo` to purge them from history:
```bash
pip3 install git-filter-repo
git filter-repo --path db_dev.sql --invert-paths --force
git filter-repo --path arabic_research_backup_20260203_005702.json.gz --invert-paths --force
```
Then added `*.sql` to `.gitignore` to prevent future commits.

> **Warning:** `git filter-repo` rewrites all commit hashes. All team members must re-clone or `git reset --hard` after this.

---

### 5. File Permissions on Server

**Symptom:** `composer install` failed with:
```
file_put_contents(vendor/composer/installed.json): Failed to open stream: Permission denied
```

**Cause:** Previous deployment steps (run as root via `sudo`) left `.git/objects`, `vendor/`, and `var/` owned by `root` instead of the deploy user (`ubuntu`).

**Fix:**
```bash
sudo chown -R ubuntu:ubuntu /var/www/html/academia_test/.git
sudo chown -R ubuntu:ubuntu /var/www/html/academia_test/vendor
sudo chown -R ubuntu:ubuntu /var/www/html/academia_test/var
# After deploy, set web server ownership for var/ and uploads/
sudo chown -R www-data:www-data /var/www/html/academia_test/var
sudo chown -R www-data:www-data /var/www/html/academia_test/public/uploads
sudo chmod -R 775 /var/www/html/academia_test/var
```

---

### 6. SSH Deploy Key Not Available to Non-Root User

**Symptom:** `git pull` on the server failed with:
```
git@github.com: Permission denied (publickey).
```

**Cause:** The GitHub deploy key was generated under `/root/.ssh/github_deploy` with a matching SSH config, but the deploy user (`ubuntu`) had no access to it.

**Fix:** Copy the key to the deploy user:
```bash
sudo cp /root/.ssh/github_deploy ~/.ssh/github_deploy
sudo chown ubuntu:ubuntu ~/.ssh/github_deploy
chmod 600 ~/.ssh/github_deploy
echo -e 'Host github.com\n  IdentityFile ~/.ssh/github_deploy\n  StrictHostKeyChecking no' >> ~/.ssh/config
```

---

### 7. Apache Default VHost Matching Before App VHost

**Symptom:** `curl http://localhost/` returned an old static HTML page instead of the Symfony app.

**Cause:** `000-default.conf` (DocumentRoot `/var/www/html`) matches before `academia-dev.conf` because the request lacks the `Host: 20.236.64.82` header that the app vhost requires via `ServerName`.

**Fix:** This is expected — the app vhost only responds to `Host: 20.236.64.82`. External requests work correctly. For local testing, use:
```bash
curl -H 'Host: 20.236.64.82' http://localhost/
```

---

### 8. `$this->_em` Removed in Doctrine ORM 3

**Symptom:** Admin page (`/jim19ud83/users`) returned 500:
```
Undefined property: App\Repository\UserRepository::$_em
Call to a member function createQueryBuilder() on null
```

**Cause:** Doctrine ORM 3 removed the `$_em` protected property from `ServiceEntityRepository`. Code using `$this->_em->createQueryBuilder()`, `$this->_em->persist()`, etc. broke at runtime.

**Affected files (25 occurrences across 8 repositories):**
| Repository | Occurrences |
|-----------|:-----------:|
| `UserRepository` | 5 |
| `CommunityQuestionDiscussionRepository` | 4 |
| `CommunityQuestionRepository` | 4 |
| `CommunityMemberRepository` | 4 |
| `CommunityRepository` | 4 |
| `AcademicSubscriptionRepository` (in Repository/) | 2 |
| `CourseRepository` | 1 |
| `AcademicSubscriptionRepository` (in Entity/) | 1 |

**Fix:** Replace all `$this->_em` with `$this->getEntityManager()`:
```php
// Before
$this->_em->createQueryBuilder() ...
$this->_em->persist($entity);
$this->_em->flush();

// After
$this->getEntityManager()->createQueryBuilder() ...
$this->getEntityManager()->persist($entity);
$this->getEntityManager()->flush();
```

> **How to find all occurrences:**
> ```bash
> grep -rn '\$this->_em' src/*Repository.php src/syndex/*/Repository/*.php src/syndex/*/Entity/*Repository.php
> ```

---

### 9. Broken PHP 8 Attribute Syntax in Entity Files

**Symptom:** `doctrine:schema:update --dump-sql` crashed with various parse errors, preventing schema validation.

**Cause:** Several entity files had incorrect PHP 8 attribute syntax — likely from incomplete or incorrectly automated annotation-to-attribute conversion.

**Issues found and fixed:**

| File | Line | Problem | Fix |
|------|------|---------|-----|
| `ResearchDownload.php` | 32 | `targetEntity=App\...\Research::class` (annotation `=` syntax in attribute) | `targetEntity: Research::class` |
| `Comment.php` | 53 | `name = "last_update_date"` (space around `=`) | `name: "last_update_date"` |
| `Vote.php` | 24-26 | `#[ORM\Table(]` — unclosed parenthesis, broken attributes | Rewrote with proper `name:` and `columns:` params |
| `UserWebNotification.php` | 25-27 | Duplicate `#[ORM\Entity]` attribute (bare + with repositoryClass) | Removed the bare `#[ORM\Entity]` |
| `AcademicProfileViews.php` | 21 | `#[ORM\id]` — lowercase `id` | `#[ORM\Id]` (case-sensitive) |
| `Comment.php` | 26,29 | `fetch: \Doctrine\ORM\Mapping\ClassMetadata::FETCH_EXTRA_LAZY` | `fetch: 'EXTRA_LAZY'` |
| `Thread.php` | 35 | Same `FETCH_EXTRA_LAZY` constant | `fetch: 'EXTRA_LAZY'` |

> **Key rule:** In PHP 8 attributes, named arguments use `:` not `=`. Constants like `ClassMetadata::FETCH_*` are not valid in ORM 3 attributes — use string values instead.

> **How to scan for remaining issues:**
> ```bash
> # Find = used instead of : in attributes
> grep -rn 'targetEntity=' src/ --include='*.php'
> # Find lowercase ORM\id
> grep -rn 'ORM\\id\b' src/ --include='*.php'
> # Find ClassMetadata:: constants in attributes
> grep -rn 'ClassMetadata::FETCH_' src/ --include='*.php'
> # Run PHP syntax check on all entity files
> find src/ -name '*.php' -exec php -l {} \; 2>&1 | grep -v 'No syntax errors'
> ```

---

### 10. VARCHAR Columns Require Explicit Length in DBAL 4

**Symptom:** `doctrine:schema:update` failed with:
```
Doctrine\DBAL\Platforms\MySQL80Platform requires the length of a VARCHAR column to be specified
```

**Cause:** Doctrine DBAL 3 defaulted `type: 'string'` to VARCHAR(255). DBAL 4 removed this default — every `string` column must explicitly declare `length`.

**Affected:** 37 columns across 15 entity files, including `EnglishTag`, `EnglishResearch`, `Research`, `Tag`, `Field`, `Publisher`, `Contributer`, `Course`, `User`, and others.

**Fix:** Add `length: 255` to every `#[ORM\Column(type: 'string')]` that doesn't have it:
```php
// Before
#[ORM\Column(type: 'string')]
#[ORM\Column(type: 'string', nullable: true)]
#[ORM\Column(name: 'slug', type: 'string', nullable: true)]

// After
#[ORM\Column(type: 'string', length: 255)]
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[ORM\Column(name: 'slug', type: 'string', length: 255, nullable: true)]
```

> **How to find all occurrences:**
> ```bash
> grep -rn "type: 'string'" src/ --include='*.php' | grep -v 'length:'
> ```

---

### 11. Missing `use` Imports for `targetEntity` References

**Symptom:** `doctrine:schema:update` failed with:
```
The target-entity App\syndex\CommentBundle\Entity\User cannot be found
```

**Cause:** Entity attributes like `#[ORM\ManyToOne(targetEntity: User::class)]` resolve `User::class` relative to the current namespace. Without `use App\Entity\User`, it resolves to `App\syndex\CommentBundle\Entity\User` which doesn't exist.

**Affected files found so far:**
- `Comment.php` (CommentBundle) — missing `use App\Entity\User`
- `Vote.php` (CommentBundle) — missing `use App\Entity\User`
- `ResearchDownload.php` — missing `use ...\ResearchDownloadRepository`
- 14 other entity files with broken `repositoryClass` references (fixed earlier)

> **How to find all:** After deploying code, run `php bin/console doctrine:schema:update --dump-sql` — any unresolvable class reference will cause an immediate error with the file name.

---

### 12. Missing Database Columns (New Features Not in Prod DB)

**Symptom:** Pages returned 500 with:
```
Column not found: 1054 Unknown column 'u0_.is_indexed' in 'field list'
```

**Cause:** The `UserReference` entity defines columns (`is_indexed`, `summary`, `summary_status`, `summary_generated_at`) that were added during development but don't exist in the prod database. Doctrine migrations may have already been marked as executed on prod (copied to dev), so `doctrine:migrations:migrate` reports "Already at latest version."

**Fix:** Add missing columns manually:
```sql
ALTER TABLE user_reference
  ADD COLUMN is_indexed TINYINT(1) NOT NULL DEFAULT 0,
  ADD COLUMN summary LONGTEXT DEFAULT NULL,
  ADD COLUMN summary_status VARCHAR(20) DEFAULT NULL,
  ADD COLUMN summary_generated_at DATETIME DEFAULT NULL;
```

> **For prod deployment:** Run `doctrine:schema:update --dump-sql` to see ALL schema differences, then selectively apply the safe `ALTER TABLE ... ADD COLUMN` statements. **Never** run `--force` blindly — review each SQL statement first.

---

### 13. Missing Playground Tables (New Feature Not in Prod DB)

**Symptom:** `/playground` returned 500 with:
```
Table 'academia_v2_dev.playground_project' doesn't exist
```

**Cause:** The playground feature (projects, notebooks, chats, translations) was added during development. The prod database (copied to dev) doesn't have these tables. Only supporting tables (subscription, usage_log, budget_alert, daily_stats, operation_cost, tier_config) existed.

**Fix:** Create the 4 missing tables and their foreign keys:
```sql
CREATE TABLE playground_project (...);
CREATE TABLE playground_notebook (...);
CREATE TABLE playground_chat (...);
CREATE TABLE playground_translation (...);
-- Plus 4 ALTER TABLE statements for foreign keys
```
> Use `doctrine:schema:update --dump-sql` to generate the exact SQL, then run only the `CREATE TABLE` and related `ALTER TABLE ... ADD CONSTRAINT` statements.

---

### 14. Entity Argument Resolution Requires `#[MapEntity]` in Symfony 8

**Symptom:** Routes with entity type-hinted parameters returned 500 with:
```
Controller requires the "$shamra_ocr_file" argument that could not be resolved.
Cannot find mapping for "App\Entity\ShamraOcrFiles": declare one using either the #[MapEntity] attribute or mapped route parameters.
```

**Cause:** In Symfony 8, the automatic param converter no longer resolves entities when the route parameter name (`{slug}`, `{request_id}`) differs from the PHP method parameter name (`$shamra_ocr_file`, `$prompt`, `$partnerContent`). A `#[MapEntity]` attribute is now required.

**Fix:** Add `#[MapEntity(mapping: ['route_param' => 'entity_property'])]` to each affected parameter. Example:
```php
use Symfony\Bridge\Doctrine\Attribute\MapEntity;

// Before (broken):
#[Route('/ocr/delete/{slug}', name: 'app_shamra_ocr_delete')]
public function shamraOcrDelete(ShamraOcrFiles $shamra_ocr_file, ...)

// After (fixed):
#[Route('/ocr/delete/{slug}', name: 'app_shamra_ocr_delete')]
public function shamraOcrDelete(#[MapEntity(mapping: ['slug' => 'slug'])] ShamraOcrFiles $shamra_ocr_file, ...)
```

**Files changed (13 methods across 3 controllers):**
- `ShamraOcrController.php` — 2 methods (`shamraOcrDelete`, `ocrFileDownloadAction`)
- `AISearchController.php` — 6 methods (`reviewReferenceStudy`, `checkRequestStatus`, `changeStudyTitle`, `changeStudyContent`, `changeStudyPublic`, `deleteStudy`)
- `PartnerContentController.php` — 5 methods (`showDetail`, `deletePartnerContent`, `updatePartnerContent`, `edit_content_image`, `proccessStripePayment`)

### 15. Form Type `buildForm()` Missing `: void` Return Type

**Error:** `Declaration of App\...\buildForm() must be compatible with Symfony\Component\Form\AbstractType::buildForm(): void`

**Cause:** Symfony 8 enforces strict return types on all `AbstractType` methods. The `buildForm()` method must declare `: void`.

**Fix:** Add `: void` return type to all `buildForm()` methods.

**Files changed (11 form types):**
- `EnquiryType.php`, `ReCaptchaType.php` (×2), `ResearchesContributersRolesType.php`, `PublisherAdditionalInfoType.php`, `EnglishResearchType.php`, `FieldType.php`, `QuestionType.php`, `PublisherType.php`, `ResearchType.php`, `TagType.php`

### 16. Form Type `getBlockPrefix()` Missing `: string` Return Type

**Error:** `Declaration of App\...\getBlockPrefix() must be compatible with Symfony\Component\Form\AbstractType::getBlockPrefix(): string`

**Cause:** Same Symfony 8 strict return type enforcement — `getBlockPrefix()` must return `: string`.

**Fix:** Add `: string` return type to all `getBlockPrefix()` methods.

**Files changed (7 form types):**
- `ResearchType.php`, `PublisherType.php`, `TagType.php`, `PublisherAdditionalInfoType.php`, `ResearchesContributersRolesType.php`, `FieldType.php`, `QuestionType.php`

### 17. `$this->get()` Container Access Removed in Symfony 8

**Error:** `Call to undefined method App\Controller\PartnerContentController::get()`

**Cause:** Symfony 8 removed `$this->get('service_id')` from `AbstractController`. Services must be injected via constructor or method parameters.

**Fix:** Replace all `$this->get('security.token_storage')` with constructor-injected `TokenStorageInterface`. Replace `$this->get('security.authorization_checker')->isGranted(...)` with `$this->isGranted(...)`. Replace `$this->get('syndex.track.metadata')` with injected `TrackService`.

**Files changed (3 controllers):**
- `PartnerContentController.php` — 2 occurrences of `$this->get('security.token_storage')` → constructor injection
- `TrackController.php` — 2 occurrences → constructor injection
- `LessonController.php` — 2 tokenStorage, 2 authorization_checker, 1 track.metadata → constructor injection + `$this->isGranted()`

**Note:** `TagController.php` also uses `$this->get()` for custom services (`syndex.academia.process_parameters`, `syndex.academia.paginator.service`, `syndex_business.search`) — these routes appear unused in current UI but will need the same treatment if ever accessed.

---

## Pre-Production Deployment Checklist

Based on all issues encountered during dev deployment, here is the **ordered checklist** for deploying to production:

### Before Deployment
- [ ] Backup production database (`mysqldump`)
- [ ] Backup production files (`/var/www/html/academia/`)
- [ ] Schedule maintenance window
- [ ] Verify PHP 8.4 is installed on prod server
- [ ] Verify Composer 2.x is installed

### Deploy Code
- [ ] Pull latest code from GitHub
- [ ] Run `composer install --no-dev --optimize-autoloader`
- [ ] Verify `composer.json` has `"symfony/runtime": true` in `allow-plugins`

### Fix Database Schema
- [ ] Run `php bin/console doctrine:schema:update --dump-sql --env=prod` to see all differences
- [ ] Review the SQL output — apply only `ADD COLUMN` and safe `ALTER` statements
- [ ] **Do NOT auto-apply** `DROP` or `MODIFY` statements without review
- [ ] Add missing `user_reference` columns (see Issue #12 above)
- [ ] Convert any remaining serialized data to JSON (see Issue #3 above)

### Fix Doctrine Config
- [ ] Remove `auto_generate_proxy_classes` from `config/packages/prod/doctrine.yaml` (Issue #2)

### Post-Deploy
- [ ] Clear cache: `sudo php bin/console cache:clear --env=prod`
- [ ] Fix permissions: `sudo chown -R www-data:www-data var/`
- [ ] Fix upload permissions: `sudo chown -R www-data:www-data public/uploads/`
- [ ] Run `php bin/console doctrine:schema:update --dump-sql --env=prod` again to verify clean
- [ ] Test homepage, admin panel, myreferences, and research pages
- [ ] Check `var/log/prod.log` for any remaining errors

---

## Important Notes — Dev Server (20.236.64.82)

### Elasticsearch Indices — Complete

All ES indices have been fully copied from prod. ES data is stored on the Azure temp disk at `/mnt/elasticsearch` (32GB).

| Index | Prod Docs | Dev Docs | Status |
|-------|-----------|----------|--------|
| `ai_study` | 13 | 13 | **Complete** |
| `arabic_research` | 12,840 | 12,840 | **Complete** |
| `arabic_research_lmj` | 12,649 | 12,649 | **Complete** |
| `arabic_research_v1.0` | 12,839 | 12,839 | **Complete** |
| `arabic_research_lmd` | 12,649 | 12,649 | **Complete** |
| `english_research` | 97,258 | 97,258 | **Complete** |
| `user_reference_chunks` | 27 | 27 | **Complete** |

> **Important**: ES data lives on `/mnt` (Azure ephemeral/temp disk). This data survives reboots but is **lost on VM deallocation** (stop+deallocate from Azure portal). If that happens, re-reindex all indices from prod using the reindex API with `reindex.remote.whitelist: ["20.106.250.185:9200"]` in `elasticsearch.yml`.

> **ES config change**: `path.data` in `/etc/elasticsearch/elasticsearch.yml` was changed from `/var/lib/elasticsearch` to `/mnt/elasticsearch` to use the larger temp disk (32GB vs 29GB root).

---

## Rollback Plan

If issues occur:

1. Restore database from backup
2. Revert code to previous commit
3. Clear caches
4. Restart PHP-FPM

---

## Key Changes Summary

### Entity Conversions

| Before (Annotations) | After (PHP 8 Attributes) |
|---------------------|--------------------------|
| `@ORM\Entity` | `#[ORM\Entity]` |
| `@ORM\Table(name="...")` | `#[ORM\Table(name: '...')]` |
| `@ORM\Column(type="string")` | `#[ORM\Column(type: 'string')]` |
| `@ORM\ManyToOne(targetEntity="Research")` | `#[ORM\ManyToOne(targetEntity: Research::class)]` |
| `fetch=\Doctrine\ORM\Mapping\ClassMetadata::FETCH_EAGER` | `fetch: 'EAGER'` |
| `type="array"` | `type: 'json'` |

### DQL Queries

| Before | After |
|--------|-------|
| `syndexAcademicBundle:Research` | `App\syndex\AcademicBundle\Entity\Research` |
| `syndexAcademicBundle:Tag` | `App\syndex\AcademicBundle\Entity\Tag` |

---

## Files Modified

- **89+ entity files** in `src/Entity/` and `src/syndex/*/Entity/`
- **8 repository files** with DQL query updates
- **4 files** with `array` → `json` column type changes
- `config/packages/doctrine.yaml` - updated configuration

---

## Contact

For questions about this migration, contact the development team.
