Files
orderPRO/.paul/codebase/CONVENTIONS.md
Jacek Pyziak 4c3daf69b7 docs: add codebase map to .paul/codebase/
Auto-generated by paul:map-codebase — 4 parallel analysis agents.
Covers stack, architecture, conventions, and concerns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 21:42:24 +01:00

20 KiB
Raw Permalink Blame History

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 3050 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 23 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
declare(strict_types=1);

No exceptions observed across the entire src/ tree.

Class Declaration

All classes are final unless specifically designed for extension (none in this codebase are open for extension):

final class OrdersRepository
{
    public function __construct(private readonly PDO $pdo)
    {
    }
}

Constructor property promotion is used throughout:

public function __construct(
    private readonly Template $template,
    private readonly Translator $translator,
    private readonly AuthService $auth,
    private readonly OrdersRepository $orders,
    private readonly ?ShipmentPackageRepository $shipmentPackages = null
) {
}

Return Types and Type Hints

All method signatures carry full PHP 8.x type declarations. Return types always declared:

public function findDetails(int $orderId): ?array
public function updateOrderStatus(int $orderId, string $newStatusCode, ...): bool
public function run(int $limit): array

PHPDoc @param and @return annotations are added for complex array shapes:

/**
 * @return array{items:array<int, array<string, mixed>>, 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:

$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:

// 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:

$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:

$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:

// 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:

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:

$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:

$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:

$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:

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:

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
$html = $this->template->render('orders/list', $data, 'layouts/app');

Two helpers are injected into every view scope:

$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 <?= (string) $rawHtml ?> (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 include $this->resolve('components/table-list') ?>
// or via the view data key 'tableList'

The component reads its own config defensively:

$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:

<link rel="stylesheet" href="/assets/css/app.css?ver=<?= filemtime(...) ?: 0 ?>">

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:

(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:

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:

<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">

Controllers validate it before processing:

$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():

<?= $e($buyerName) ?>
<?= $e($t('navigation.orders')) ?>

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():

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:

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:

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:

} 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.

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
declare(strict_types=1);

namespace Tests\Unit\Settings;

use App\Modules\Settings\OrderStatusMappingRepository;
use PDO;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(OrderStatusMappingRepository::class)]
final class OrderStatusMappingRepositoryTest extends TestCase
{
    private PDO $pdo;
    private OrderStatusMappingRepository $repository;

    protected function setUp(): void
    {
        $this->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:

$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