Skip to content
Article Architecture
☀️ Light Clean and bright
📜 Sepia Warm and vintage
🌤️ Light Gray Subtle and neutral
🔵 Light Blue Calm and serene
🌙 Dark Easy on the eyes
🌑 Dark Gray Deep and modern
🔷 Dark Blue Professional and sleek
🌿 Forest Calm and natural

Architecture

SepoDesk is one PHP application serving two kinds of surface. Understanding which surface you are on explains almost every "why does it work like that" question.

The boot path

Every request enters through index.php, which hands off to the Kernel (SepoEngine\Setup). The kernel boots configuration, the session, the service container and the route table, in that order.

index.php
  └─ Kernel (boot: config → session → services → routes)
       └─ Router
            └─ Middleware
                 └─ Controller@action
                      └─ DomBuilder / view / JSON

Services. All dependency-injection bindings live in one place, serviceRegister.php. The Router resolves controller and action arguments by reflection against that container, so a controller declares what it needs in its constructor and receives it.

Middleware. AuthMiddleware and MiddlewareHelper between them hold the public-route list, the wp_only category gate, and role-prefix RBAC keyed on user_type. A route that needs no session goes on the public list; everything else is authenticated by default.

Database. SpEngineConnector::connectNow is the binding for PDO. It loads the environment, opens the auth connection through PdoFactory, then resolves the caller's API key to a role, the role to a target database, and the role to credentials — which are stored encrypted and decrypted with the AES key. Role-to-database lookups are cached to storage/cache/.

PdoFactory::get() converts PDOException into a plain \Exception. An upstream catch (\PDOException $e) will never fire. Catch \Exception at that boundary or unwrap it inside the factory.

Two rendering modes

Server-rendered

Documentation, authentication screens and the mobile dashboard are built on the server with the PHP DomBuilder and sent as finished HTML.

Use this mode when the page is content, when it must survive with JavaScript disabled, when search engines need to see it, or when the device is a phone and you would rather not ship a shell it will use one window of.

Client-rendered

The desktop is a single server-rendered shell. After that first response the browser owns the UI: AppShell mounts apps, DomBuilder (the JS one, mirroring the PHP API) builds nodes, WidgetEngine and Widgets supply the components.

Use this mode when interaction is continuous — dragging, resizing, editing, several apps open against the same data.

Both DomBuilder implementations present the same building API on purpose. A component's markup reads the same whether it was produced in PHP or in the browser, which keeps the two surfaces from drifting apart.

Routing

Routes are regex-matched, support groups and per-group middleware, and dispatch to Controller@action.

Alongside the table there is file-based routing for content. A request to /docs/<path> resolves against content/pages/ in three steps:

  1. The exact slug, when it already carries an allowed extension.
  2. The slug plus each allowed extension, in configured order.
  3. An index file inside a directory of that name.
RequestFile on disk
/docscontent/pages/index.md
/docs/indexcontent/pages/index.md
/docs/api/tokenscontent/pages/api/tokens.md

The page store only serves extensions on its allow-list, which is why images belong under public/ and not next to the Markdown.

URLs and base paths

RouterSettings (SepoEngine\Core) is the single source of truth for URLs. It is WORDPRESS_MODE-aware, detects host and scheme through TRUSTED_HOSTS and TRUST_PROXY, and falls back to a static configuration when detection fails.

It exposes app_url, app_base_path, api_base, assets_url and plugin_url. In plugin mode the routing root and the asset root are genuinely different directories — resolve them separately, always from RouterSettings, never by string-building from $_SERVER.

Views hand the resolved values to the browser as window.SepoDeskConfig, together with the CSRF token.

The data layer

Application data moves over a single POST endpoint, /api/v2/data, served by CoreDataApiController. Requests are routed to the owning app through AppsConfig.php and manifest.json discovery, then instantiated by ControllerFactory inside an AppShell.

API authentication is an api-public-key header, validated by ApiKeyModel and rate-limited per user.

On the browser side the stack is:

your app code
  └─ ApiLogic          (core/sp-api-logic.js)
       └─ EcmaScriptController
            └─ POST /api/v2/data

EcmaScriptController handles caching, request de-duplication, batched and chunked multi-table CRUD, a circuit breaker, and JWT attachment and refresh. Write app code against ApiLogic, not against fetch.

Authentication

Browser sessions and API tokens are separate paths that meet at the user record.

  • Browser. AuthWebController renders login, logout and password recovery. AuthSessionController handles the session itself, including fingerprinting on IP and user agent, the refresh-token cookie, and the env-driven cookie flags.
  • API. AuthTokenController issues HS256 access and refresh tokens. Refresh tokens rotate on use; presenting a rotated token revokes the whole family, on the assumption that a replay means a leak.

Password recovery uses security questions and runs through a StageExecutor pipeline, with NotificationService sending the stage notifications.

Formulas and saved queries

DataMathEngine (SepoEngine\Core) evaluates spreadsheet-style formulas over row arrays — SUM, AVG, MIN, MAX, COUNT, IF, SAFE_DIVIDE, and the conditional variants.

QueryExecutionWorker consumes it to run saved query definitions: tables and joins in definition_json, aggregates, grouping, calculations and filters in math_config. Every query is scoped by ownership and sharing through sp_owner_uid and sp_shared_with, and updates and deletes resolve the set of authorised primary keys before writing anything.