This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Club/sports facility ERP system built on a **custom PHP 8.1+ framework** (NOT Laravel, NOT Symfony). Arabic-first, RTL, multi-branch. All views are in Arabic. The framework is hand-built with plain PDO, custom autoloader, custom template engine, and modular MVC architecture.
## Commands
```bash
php cli.php migrate # Run pending database migrations
php cli.php migrate:rollback # Undo last batch
php cli.php migrate:status # Show migration status
php cli.php seed # Run pending seeds
php cli.php seed:run <Name> # Run specific seed
php cli.php cron # Run cron jobs
```
No composer, no npm, no build step. The app uses a custom PSR-4-style autoloader (`App\Core\Autoloader`). No test framework is configured.
-`query($sql, $params)` returns PDOStatement; `raw($sql)` executes without params
**No ORM relations.** Relationships are loaded via manual SQL joins or separate queries.
**IMPORTANT**: MySQL `SHOW TABLES LIKE ?` does NOT work with prepared statement placeholders. Use `information_schema.tables` query or hardcoded string instead.
Routes specify a permission string. `Controller::authorize($permission)` throws RuntimeException(403) if denied. Employee permissions come from `role_permissions` + `employee_permissions` tables. Super admin check: query `employee_roles` joined with `roles` where `role_code = 'super_admin'`.
## Key Conventions
### Migration Naming
`database/migrations/Phase_NN_NNN_description.php` — returns `['up' => $sql, 'down' => $sql]` or a closure receiving `Database`. Multiple SQL statements in `up` are split by `;\n` (semicolon + newline).
Format: `module.action` (e.g., `member.view`, `accounting.journal.create`). Registered in each module's `bootstrap.php` via `PermissionRegistry::register()`.
### Global Helpers
`e()` for HTML escaping, `money()` for currency formatting, `csrf_field()` for form tokens, `old()` for repopulating forms after validation failure, `arabic_date()` for localized dates, `now()` / `today()` for current datetime/date.
### Controller Patterns
-`$this->view('Module.Views.path', $data)` to render
-`$this->json($data)` for API responses
-`$this->redirect('/path')` for redirects
-`$this->validate($_POST, $rules)` validates and flashes errors on failure
`return $this->redirect('/path')->withSuccess('message')` — flash + redirect in one call.
### Database Config
Connection params from `config/database.php` which reads from `.env`: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS`. Constructor: `new Database(string $host, int $port, string $dbName, string $user, string $pass, string $charset)`.
## Common Pitfalls
-**CLI context**: `App::getInstance()->db()` is null in CLI. Seeds receive `Database` as parameter; use `App::getInstance()->setDb($db)` if services need the App singleton.
-**Table existence checks**: Never use `SHOW TABLES LIKE ?` with params. Use `SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?`.
-**Column names vary by table**: Not all tables have `is_archived` — check the migration before assuming. `fines` has no `is_archived` or `imposed_date` (use `created_at`). `hr_payroll_runs` uses `paid_at` not `payment_date`. `installment_plans` has no `is_archived`.
-**`declare(strict_types=1)`**: Used in all PHP files. Type mismatches (e.g., passing array to string param) cause fatal errors.
-**ExceptionHandler**: Respects exception code for HTTP status (401, 403, 404 return proper pages; everything else is 500).
@@ -107,9 +107,10 @@ class JournalEntry extends Model
...
@@ -107,9 +107,10 @@ class JournalEntry extends Model
$params[]=$filters['fiscal_year_id'];
$params[]=$filters['fiscal_year_id'];
}
}
if(!empty($filters['search'])){
if(!empty($filters['search'])){
$where[]='(je.entry_number LIKE ? OR je.description_ar LIKE ? OR je.description_en LIKE ? OR je.reference_number LIKE ?)';
$where[]='(je.entry_number LIKE ? OR je.description_ar LIKE ? OR je.description_en LIKE ? OR je.reference_number LIKE ?'
.' OR EXISTS (SELECT 1 FROM journal_entry_lines jl JOIN chart_of_accounts ca ON ca.id = jl.account_id WHERE jl.journal_entry_id = je.id AND (ca.account_code LIKE ? OR ca.name_ar LIKE ?)))';
<inputtype="text"name="account_search"class="form-input"value="<?=e($account_search??'')?>"placeholder="مثال: 1101 أو النقدية بالخزينة"list="account-list">
<inputtype="text"name="member_search"class="form-input"value="<?=e($member_search??($member?$member['form_number'].' — '.$member['full_name_ar']:''))?>"placeholder="مثال: 1234 أو أحمد محمد">
<buttontype="submit"class="btn btn-primary"style="width:100%;padding:15px;font-size:18px;background:#D97706;border-color:#D97706;"onclick="return confirm('إرسال طلب دفع <?=money($formFee)?> للخزينة؟')">💰 إرسال للخزينة</button>
<buttontype="submit"class="btn btn-primary"style="width:100%;padding:15px;font-size:18px;background:#059669;border-color:#059669;"onclick="return confirm('تأكيد دفع <?=money($formFee)?>؟')">💰 دفع رسوم الاستمارة</button>
<buttontype="submit"class="btn btn-primary"style="width:100%;background:#D97706;border-color:#D97706;"onclick="return confirm('إرسال طلب دفع <?=money($bill['total_pending'])?> للخزينة؟')">📤 إرسال للخزينة</button>
<buttontype="submit"class="btn btn-primary"style="width:100%;background:#D97706;border-color:#D97706;"onclick="return confirm('إرسال طلب تقسيط للخزينة؟')">📤 إرسال للخزينة</button>