# Coding Conventions **Analysis Date:** 2026-03-12 --- ## 1. Documented Rules (AGENTS.md / CLAUDE.md) These rules are canonical and enforced across the project: - Code must be readable by a stranger — clear names, no magic - No logic in views, no copy-paste, no random helpers without coherent design - Each function/class has **one responsibility**, typically 30–50 lines; split if longer - Max **3 levels of nesting** (if/foreach); move deeper logic to separate methods - Comments only where they explain **why**, never **what** - DB: Medoo is mentioned in docs but the codebase uses **raw PDO** with prepared statements — no string-concatenated SQL for user input - XSS: escape all output in views with the `e()` helper - CSRF token required on all POST forms, validated via `Csrf::validate()` - All styles live in `resources/scss/`, never inline in view files - Never use native `alert()` / `confirm()` — use `window.OrderProAlerts.confirm()` - Reusable UI blocks go in `resources/views/components/` --- ## 2. Naming Conventions ### PHP | Element | Convention | Example | |---|---|---| | Classes | PascalCase | `OrdersRepository`, `ShipmentProviderRegistry` | | Methods | camelCase | `findDetails()`, `updateOrderStatus()` | | Variables | camelCase | `$orderId`, `$statusLabelMap` | | Constants | UPPER_SNAKE_CASE | `SESSION_KEY` (in `Csrf`) | | Namespaces | PascalCase, PSR-4 | `App\Modules\Orders`, `App\Core\Http` | | Interfaces | PascalCase + `Interface` suffix | `ShipmentProviderInterface` | | Test classes | PascalCase + `Test` suffix | `CronJobTypeTest` | No abbreviations in names. `$orderId` not `$oid`. Loop variables in short 2–3 line loops are the only exception. ### Files - One class per file, filename matches class name exactly - `PascalCase.php` for all PHP class files - `kebab-case` or `snake_case` not used in PHP ### CSS / SCSS - BEM-style class naming: `block__element--modifier` - Examples: `.sidebar__brand`, `.orders-ref__meta`, `.btn--primary`, `.is-active` - State modifier classes: `.is-active`, `.is-collapsed`, `.is-open`, `.is-disabled` - JavaScript hook classes: `.js-` prefix (e.g. `.js-sidebar-collapse`, `.js-filter-toggle-btn`) --- ## 3. PHP Code Style ### Strict Types Every PHP file starts with: ```php >, total:int, page:int, per_page:int, error:string} */ public function paginate(array $filters): array ``` ### Defensive Casting All values from external sources (request input, DB rows) are cast explicitly before use: ```php $orderId = max(0, (int) $request->input('id', 0)); $search = trim((string) ($filters['search'] ?? '')); $totalPaid = $row['total_paid'] !== null ? (float) $row['total_paid'] : null; ``` --- ## 4. Design Patterns ### Service Layer Pattern Business logic lives in dedicated `*Service` classes, not controllers or repositories: - `src/Modules/Settings/AllegroOrderImportService.php` — order import logic - `src/Modules/Settings/ShopproStatusSyncService.php` — status sync logic - `src/Modules/Settings/ShopproPaymentStatusSyncService.php` Controllers delegate to services, which delegate to repositories. ### Repository Pattern All database access goes through `*Repository` classes. No direct PDO calls in controllers or services beyond what repositories expose: - `src/Modules/Orders/OrdersRepository.php` - `src/Modules/Settings/AllegroIntegrationRepository.php` - `src/Modules/Shipments/ShipmentPackageRepository.php` ### Interface + Registry Pattern Provider integrations use an interface + registry: ```php // src/Modules/Shipments/ShipmentProviderInterface.php interface ShipmentProviderInterface { public function code(): string; public function createShipment(int $orderId, array $formData): array; // ... } // src/Modules/Shipments/ShipmentProviderRegistry.php final class ShipmentProviderRegistry { public function get(string $code): ?ShipmentProviderInterface { ... } public function all(): array { ... } } ``` Registered at boot time in `routes/web.php`: ```php $shipmentProviderRegistry = new ShipmentProviderRegistry([ $shipmentService, // AllegroShipmentService $apaczkaShipmentService, ]); ``` ### Cron Handler Pattern Cron jobs follow a handler contract. Each job type has a dedicated handler class with a `handle(array $payload): array` method: - `src/Modules/Cron/AllegroOrdersImportHandler.php` - `src/Modules/Cron/ShopproStatusSyncHandler.php` - `src/Modules/Cron/AllegroTokenRefreshHandler.php` Handlers are registered as a string-keyed array in `CronRunner`: ```php $runner = new CronRunner($repository, $logger, [ 'allegro_orders_import' => new AllegroOrdersImportHandler($ordersSyncService), 'shoppro_order_status_sync' => new ShopproStatusSyncHandler($shopproStatusSyncService), ]); ``` ### Manual Dependency Injection (No Container) There is no DI container. All dependencies are wired manually in `routes/web.php` and `Application.php`. The Application class acts as a **Service Locator** for top-level objects (`$app->db()`, `$app->orders()`, `$app->logger()`), but controllers and services receive dependencies through constructor injection. ### Middleware Pipeline Middleware uses a `handle(Request $request, callable $next): Response` or `__invoke` signature. The router builds a pipeline with `array_reduce`: ```php // src/Core/Routing/Router.php $pipeline = array_reduce( array_reverse($middlewares), fn (callable $next, callable|object $middleware): callable => $this->wrapMiddleware($middleware, $next), fn (Request $req): mixed => $handler($req) ); ``` --- ## 5. Database Access ### Driver Raw **PDO** with MySQL (`src/Core/Database/ConnectionFactory.php`). **No ORM**, no query builder (Medoo is referenced in docs but not in actual code). PDO is configured with: ```php PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ``` ### Prepared Statements — Mandatory **All** user-supplied data goes through prepared statements with named parameters: ```php $stmt = $this->pdo->prepare( 'UPDATE orders SET external_status_id = :status, updated_at = NOW() WHERE id = :id' ); $stmt->execute(['status' => $newStatusCode, 'id' => $orderId]); ``` For `IN (...)` clauses with a dynamic list, positional `?` placeholders are used and values are bound with `bindValue`: ```php $placeholders = implode(',', array_fill(0, count($cleanIds), '?')); $stmt = $this->pdo->prepare('SELECT ... WHERE oi.order_id IN (' . $placeholders . ')'); foreach ($cleanIds as $index => $orderId) { $stmt->bindValue($index + 1, $orderId, PDO::PARAM_INT); } ``` Dynamic `ORDER BY` columns are resolved with `match` expressions against a hard-coded allowlist — never interpolated from user input: ```php $sortColumn = match ($sort) { 'source_order_id' => 'o.source_order_id', 'total_with_tax' => 'o.total_with_tax', default => $effectiveOrderedAtSql, }; ``` ### SQL Style Multi-line SQL strings use string concatenation only for safe, code-generated SQL fragments (not user data). Named SQL helper methods extract reusable SQL snippets: ```php private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string { return 'CASE WHEN ' . $orderAlias . '.source = "allegro" AND ...'; } private function effectiveOrderedAtSql(string $orderAlias): string { return 'COALESCE(' . $orderAlias . '.ordered_at, ' . $orderAlias . '.source_created_at, ...)'; } ``` ### Error Handling in DB Layer Repository methods catch `Throwable` and return safe defaults (empty array, `null`, or `false`) rather than letting exceptions propagate to the controller: ```php try { $rows = $this->pdo->query('SELECT ...')->fetchAll(PDO::FETCH_COLUMN); } catch (Throwable) { return []; } ``` --- ## 6. Frontend Patterns ### View System Plain PHP views rendered via `src/Core/View/Template.php`. The `render()` method: 1. Renders the view file with `extract($data)` — data keys become local variables 2. Wraps it in a layout via a second `renderFile()` call, injecting `$content` ```php $html = $this->template->render('orders/list', $data, 'layouts/app'); ``` Two helpers are injected into every view scope: ```php $e = static fn (mixed $value): string => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); $t = static fn (string $key, array $replace = []): string => $translator->get($key, $replace); ``` **Always use `$e()` for output.** Use `` (raw echo) only when the value is pre-escaped HTML built in the controller. ### View Structure ``` resources/views/ ├── layouts/ │ ├── app.php # Main authenticated layout (sidebar + topbar) │ └── auth.php # Login layout ├── components/ │ ├── table-list.php # Generic paginated table with filters, sorting, column toggle │ └── order-status-panel.php ├── orders/ │ ├── list.php │ └── show.php ├── settings/ │ └── *.php # One view per settings section └── shipments/ └── prepare.php ``` Views are **data receivers**, not logic writers. All HTML fragment generation for complex cells (status badges, product previews) happens in controller methods and is passed as pre-built HTML strings with `raw: true` in the table config. ### Reusable Components The `resources/views/components/table-list.php` component accepts a `$tableList` config array and renders a full paginated table with filters, column toggles, and action forms. Include it with: ```php resolve('components/table-list') ?> // or via the view data key 'tableList' ``` The component reads its own config defensively: ```php $config = is_array($tableList ?? null) ? $tableList : []; $rows = is_array($config['rows'] ?? null) ? $config['rows'] : []; ``` ### SCSS / CSS Source: `resources/scss/` Compiled output: `public/assets/css/` Build command: `npm run build:assets` (uses Dart Sass) ``` resources/scss/ ├── app.scss # Main panel stylesheet ├── login.scss # Auth page stylesheet └── shared/ └── _ui-components.scss # Shared UI primitives (imported with @use) ``` **Never put styles in view files.** All styles go in SCSS. Cache-busting on the compiled CSS is done via `filemtime()` in the layout: ```php ``` ### JavaScript No bundler, no TypeScript. Vanilla JavaScript only (ES5 style with `var` in components, ES6 `const/let` allowed in module files). **Module pattern:** All JS in views is wrapped in an IIFE to avoid global scope pollution: ```js (function() { // component code })(); ``` **UI Alerts and Confirmations:** The custom `window.OrderProAlerts` module (built from `resources/modules/jquery-alerts/`) is used for all confirmations. Never use `window.confirm()` directly. The pattern in table-list: ```js if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') { window.OrderProAlerts.confirm({ title, message, ... }).then(function(accepted) { if (!accepted) return; form.setAttribute('data-confirmed', '1'); form.submit(); }); } ``` **DOM targeting:** Use `data-` attributes and `js-` prefixed class names for JS hooks. Never use presentation class names (`.btn--primary`) as JS selectors. **localStorage:** Filter state, column visibility, and sidebar collapsed state are persisted in `localStorage` with namespaced keys (e.g. `tableList_/orders/list_orders_filters_open`). --- ## 7. Security ### CSRF All POST forms include a hidden token field: ```html ``` Controllers validate it before processing: ```php $csrfToken = (string) $request->input('_csrf_token', ''); if (!Csrf::validate($csrfToken)) { $_SESSION['order_flash_error'] = $this->translator->get('auth.errors.csrf_expired'); return Response::redirect('/orders/' . $orderId); } ``` `Csrf::validate()` uses `hash_equals()` to prevent timing attacks (`src/Core/Security/Csrf.php`). ### XSS Every dynamic value echoed in a view must go through `$e()`: ```php ``` Pre-built HTML fragments (e.g. status badge HTML assembled in controller) are output raw and must already be escaped internally using `htmlspecialchars(..., ENT_QUOTES, 'UTF-8')`. ### Secrets Encryption Integration credentials (API keys, tokens) are encrypted at rest using AES-256-CBC with HMAC-SHA256 authentication (`src/Modules/Settings/IntegrationSecretCipher.php`). The cipher uses a key derived from `INTEGRATIONS_SECRET` env var. Encrypted values are prefixed with `v1:`. --- ## 8. Error Handling ### Global Handler Unhandled exceptions are caught in `Application::registerErrorHandlers()`: ```php set_exception_handler(function (Throwable $exception) use ($debug): void { $this->logger->error('Unhandled exception', [ 'message' => $exception->getMessage(), 'file' => $exception->getFile(), 'line' => $exception->getLine(), ]); $message = $debug ? $exception->getMessage() : 'Internal server error'; Response::html($message, 500)->send(); }); ``` ### Repository Layer Repositories return safe fallback values on error and never expose raw exception messages to callers: ```php try { // ... } catch (Throwable $exception) { return [ 'items' => [], 'total' => 0, 'error' => $exception->getMessage(), // returned in the data, not thrown ]; } ``` For methods returning `bool`, failure is signaled by returning `false`: ```php public function updateOrderStatus(...): bool { try { /* ... */ return true; } catch (Throwable) { return false; } } ``` For methods returning `?array`, failure returns `null`. ### Cron Layer The `CronRunner` catches per-job exceptions, logs them, marks the job as failed, and continues processing the next job: ```php } catch (Throwable $exception) { $this->repository->markJobFailed($jobId, $exception->getMessage(), ...); $this->logger->error('Cron job failed', [ 'job_id' => $jobId, 'job_type' => $jobType, 'error' => $exception->getMessage(), ]); $failed++; } ``` ### Logger `src/Core/Support/Logger.php` — custom file logger writing to `storage/logs/`. Format: ``` [2026-03-12 14:22:01] ERROR Cron job failed {"job_id":42,"job_type":"allegro_orders_import","error":"..."} ``` Methods: `$logger->error(string $message, array $context = [])` and `$logger->info(...)`. Context is encoded as JSON on the same line. Log path is configured via `app.log_path` in `config/app.php`. --- ## 9. Testing ### Framework PHPUnit 11.x. Config: `phpunit.xml` at project root. ```bash composer test # vendor/bin/phpunit -c phpunit.xml --testdox ``` Settings: - `failOnWarning="true"` — warnings are test failures - `failOnRisky="true"` — risky tests fail - `executionOrder="depends,defects"` — dependency-aware ordering ### Test File Location Tests live in `tests/` (PSR-4 namespace `Tests\`), mirroring the `src/` structure: ``` tests/ ├── bootstrap.php └── Unit/ ├── Cron/ │ └── CronJobTypeTest.php └── Settings/ └── OrderStatusMappingRepositoryTest.php ``` **Note:** The active `tests/` directory currently contains only `bootstrap.php`. The working test files exist in `archive/2026-03-02_users-only-reset/tests/` — these are the reference patterns to follow when adding new tests. ### Test Class Pattern ```php pdo = new PDO('sqlite::memory:'); $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->pdo->exec('CREATE TABLE ...'); // schema DDL $this->repository = new OrderStatusMappingRepository($this->pdo); } public function testReplaceAndReadMappingsForIntegration(): void { $this->repository->replaceForIntegration(10, [...]); $rows = $this->repository->listByIntegration(10); self::assertArrayHasKey('new', $rows); self::assertSame('completed', $rows['paid']['orderpro_status_code']); } } ``` Key patterns: - Use `#[CoversClass(ClassName::class)]` PHP 8 attribute on every test class - SQLite in-memory database for repository tests (no real DB connection needed) - Test method names start with `test` and describe behavior: `testReplaceAndReadMappingsForIntegration` - Use `self::assertSame()` (strict) not `assertEquals()` - Classes and methods are `final` - No mocking framework observed — tests use real classes with SQLite in-memory ### What to Test Repository tests test read/write behavior against an in-memory SQLite DB. Value object / enum tests verify constants and helper methods. No integration tests against the real MySQL database. --- ## 10. Code Quality Tools ### SonarQube Configured via `sonar-project.properties`: ``` sonar.sources=src,resources/views,routes sonar.tests=tests sonar.exclusions=archive/**,node_modules/**,vendor/**,public/assets/**,storage/** sonar.php.version=8.1 ``` Runs against an external SonarQube server at `https://sonar.project-pro.pl`. ### PHPUnit (Coverage Source) `phpunit.xml` configures `src/` as the source for coverage analysis. ### No Local Static Analysis Config No `phpstan.neon`, `.phpcs.xml`, or `psalm.xml` are present. SonarQube is the sole static analysis tool. PHP_CS_Fixer and PHPStan are not in `composer.json`. --- ## 11. Import / Use Statement Organization Use statements are grouped and ordered: 1. Internal `App\Core\*` namespaces 2. Internal `App\Modules\*` namespaces 3. PHP built-in classes (`PDO`, `DateTimeImmutable`, `RuntimeException`, `Throwable`) Each `use` on its own line. No `use` grouping with `{}`. --- ## 12. Configuration App config is split by concern in `config/`: - `config/app.php` — application settings (paths, locale, session, logging, cron) - `config/database.php` — DB connection parameters The `Application::config(string $key, mixed $default)` method resolves dot-notation keys: ```php $app->config('app.integrations.secret', '') $app->config('database.host') ``` Environment variables are loaded via `src/Core/Support/Env.php` and read in config files. --- *Convention analysis: 2026-03-12*