Architecture
Introduction
This page explains how DoFramework handles a request from start to finish. Read it before you build your first app. It covers the parts of an app, the three ways the framework runs, what happens inside each API request, how the browser starts the single-page app, and the response format every endpoint returns.
An app has up to three parts:
| Part | Folder | Runs |
|---|---|---|
| Backend | api/apps/<app>/ | PHP: the controller, business classes and the manifest <app>.xml |
| Frontend | apps/<app>/ | Vue 3 components in the main single-page app (SPA), plus app-config.json, route.js and services.js |
| Admin pages (optional) | api/admin/apps/<app>/ | Vue 3 components in the admin panel, a second SPA under /api/admin/ |
The frontend never touches the database. It calls the backend over HTTP at /api/<controller>/<action> and gets JSON back. Building Apps walks through each file, and the Todo App tutorial builds one end to end.
The overall flow:
Browser Apache (.htaccess) PHP
------- ------------------ ---
GET /myapp/orders ───────────► not a file: /index.php ───────► server.php + index.php
render the SPA shell,
◄──────────── HTML: import map, bundles, XP_CONFIG_PUBLIC ───── rebuild assets/*-scripts.js
(Vue Router shows the page, components call the API)
GET /api/myapp/get_items ─────► api/.htaccess: api/index.php ──► core/init.php
ConfigTemplates::sync()
Framework::run()
init, autoloaders,
dev links, health check,
SDKs, boot_apps()
dispatch()
myappController
authenticate()
Render()
◄──────────── JSON: { "response": {...}, "data": ... } ────────
cron / terminal
php api/shell.php myapp rebuild_index ─────────────────────────► Framework::run_shell()
php api/heartbeat.php ─────────────────────────────────────────► Framework::run_heartbeat()
php api/migrate.php status myapp ──────────────────────────────► Framework::run_migrate()
How the framework runs
The backend has four entry points. All four first call ConfigTemplates::sync(), which creates or updates the live config files (api/config.xml, xp-config.json and api/admin/xp-config.json) from their .dist templates. See Configuration Files.
| Mode | Entry point | Boots with | Used for |
|---|---|---|---|
| HTTP | api/index.php → api/core/init.php | Framework::run() | Every API call from the browser |
| Shell | api/shell.php → api/core/shell_init.php | Framework::run_shell() | Running a controller action from a terminal or cron job |
| Heartbeat | api/heartbeat.php → api/core/heartbeat_init.php | Framework::run_heartbeat() | The scheduler, called by cron once a minute |
| Migrate | api/migrate.php → api/core/migrate_init.php | Framework::run_migrate() | App database migrations. See Database Migrations |
The shell and heartbeat entry points run only when is_shell() is true, which means the command line (or a CGI SAPI). Called through a normal web server module they stop with "Not Authorized to call via ...". The migrate entry point is stricter: it also refuses a CGI request, and exits with 1.
HTTP
The root .htaccess redirects plain HTTP to HTTPS and sends every path that is not a real file or folder to /index.php, the SPA shell. Requests under /api/ are handled by api/.htaccess instead, which sends them to api/index.php. Requests under /api/admin/ go to api/admin/index.php, the admin panel's shell.
api/index.php sets the PHP error log to api/logs/debug.log and loads api/core/init.php. That file syncs the config templates, loads the config, constants and helper functions, and calls Framework::run() inside a try block. An uncaught Exception becomes a 500 response whose message is Exception: <message> and whose data holds the file and line.
Shell
Run a controller action from a terminal, from inside the api/ folder:
cd api
php shell.php <controller> <action> [key=value,key=value]
The first two arguments are required. The optional third argument is one argument holding comma-separated key=value pairs. It becomes the request data, which your action reads with $this->getRequest()->getData():
REQUEST_METHOD=GET php shell.php myapp rebuild_index from=2026-01-01,limit=500
A shell run builds a Request from the arguments, calls the controller's authenticate() and then Render(), exactly as HTTP does. The differences are listed under CLI gotchas at the end of this page. Dev Workspace shows how to use this while you develop.
Heartbeat
* * * * * php /path/to/api/heartbeat.php
With no arguments, the heartbeat runs every scheduled task that is due. With an argument it manages tasks instead: add, edit, remove, view, functions, config or healthcheck. See Scheduler (Heartbeat).
What happens in an API request
Framework::run() runs these steps in this order, for every request to /api/...:
init(): sends the CORS headers and answers anOPTIONSpreflight with204and no body. Then it sets the session lifetime from<session_expire_seconds>(secure, HTTP-only cookie) and starts the PHP session.core_autoload(): registers the autoloader forapi/core/classes/(Controller,Request,Response,Appand so on).load_logging_module(): loads the logging module, which every later step may use.setup_dev_links(): when<system_environment>inapi/config.xmlisdevelopmentand adev/folder exists, links eachdev/<app>/intoapps/,api/apps/andapi/admin/apps/. Otherwise it does nothing. See Dev Workspace.health_check(): see The health check.controller_autoload(): registers the autoloader forapi/controllers/andapi/classes/.app_autoload(): registers the app class autoloader (<autoload>maps, the defaultDoCloud\Api\Apps\namespace, then a filename scan ofapi/apps/), when<system_app_structure>is1. See App Manager.module_autoload(): registers the autoloader forapi/core/Modules/.app_module_autoload(): registers the autoloader forapi/core/AppModules/.check_Controller_Action(): stops with400when the URL has no controller or no action.Load_SDKs(): includesautoloader.phpof every SDK listed in<sdks>.heartbeat_autoload(): registers the autoloader forapi/core/heartbeat/.boot_apps(): callsregister()and thenboot()on every active app'sAppclass, dependencies first. See App Manager.dispatch(): checks maintenance mode, builds theRequest, and hands it to your controller.
run_shell() skips init(), load_logging_module(), check_Controller_Action() and heartbeat_autoload(), and ends with shell_dispatch(). run_heartbeat() skips init(), load_logging_module(), controller_autoload() and check_Controller_Action(), and ends by running the scheduler.
URL routing
The controller and action come from the second and third path segments:
/api/myapp/get_items?status=open
│ │
│ └─ ACTION = "get_items"
└─────── CONTROLLER = "myapp" → class myappController
Segments after the action are ignored. Pass data as a query string or in the request body.
dispatch() then:
- Lower-cases the controller name and appends
Controller, so/api/MyApp/...also loadsmyappController. - Reads the request data. For
GETit uses$_GET. ForPOSTit uses$_POST, or the decoded JSON body whenContent-Typeisapplication/json, and passes$_FILESalong. Other HTTP methods get no usable data. - Builds a
Requestwith the controller, action, data and files. TheRequestconstructor stops with497when the request did not arrive over HTTPS, and with413when its body was larger than PHP'spost_max_size. PHP throws such a body away, so the action would otherwise see no fields and no files. - Returns
501when nomyappControllerclass exists. - Calls
authenticate(). When it returnsfalse, the response is401 Not Authorized. When it returnstrue, it callsRender().
Render() is yours: it reads $this->getRequest()->getType() (GET or POST) and getAction(), and calls the method for that action. The helloworld controller is the reference pattern. See Core Classes for the Controller, Request and Response classes and Authentication for authenticate().
The health check
The health check runs on every HTTP, shell and heartbeat run, before any app class is loaded. It stops the request with 500 Health Check Failed when:
<db_access>istrueand the database connection fails.dataholds the connection errors.- An SDK listed in
<sdks>has noapi/sdks/<sdk>/autoloader.php.dataisSDK Not Found : <sdk>. - A module listed in
<modules>has noapi/core/Modules/<module>/<module>.class.php.dataisModule Not Found : <module>. - An app module listed in
<app_modules>has no class file.dataisAPP Module Not Found : <module>.
It also creates the local SQLite log database (by default api/db/do.db) when its tables are missing. That step never fails the request.
Maintenance and down mode
<system_status> in api/config.xml is up, maintenance or down. An admin changes it in the admin panel under Settings > System > System Status.
In maintenance or down, dispatch() answers every controller except system_admin_app with 503 and does not call the controller:
| Status | statusMsg |
|---|---|
maintenance | The system is currently under maintenance. Please try again later. |
down | The system is temporarily unavailable. Please check back later. |
system_admin_app stays reachable so an admin can still log in and switch the system back to up. The SPA shell itself still loads. Only its API calls fail. A missing or unknown value is logged and treated as up. Shell and heartbeat runs ignore the status.
Admin activity recording
dispatch() records state-changing admin-panel requests in the admin activity log, which admins read in the admin panel's Log Viewer. A request is recorded when it is a POST and either:
- its controller is
system_admin_app, apart from a few read-only actions, or - it carries the header
X-DC-Panel: adminand a valid system admin session.
The admin panel's copy of xp.js adds that header to every same-origin fetch() to /api/. So the POST requests your admin pages make with fetch() are recorded automatically. Requests made with XMLHttpRequest or jQuery don't get the header and aren't recorded.
Each entry holds the admin, controller, action, outcome (success, failed, denied or unknown, read from the response's success flag) and the request fields. Values whose key looks secret are stored as ***: keys with a part named pass, password, passwd, pwd, secret or token, and keys such as api_key or apiKey that end in a key part. Uploaded files are stored by name only. To add a line of context, call this in your action:
AdminActivity::describe('Archived order ' . $order_id);
See Admin Pages.
How the frontend starts
The SPA has no build step. Every page load is rendered by index.php, which includes server.php:
server.phpsyncs the config templates. When the URL is a versioned module URL (/_v/...), it serves that file and stops. Otherwise it fetches the public app options from its own API (/api/system_admin_app/get_public_options, a server-to-server request), readsxp-config.jsonand reads everyapps/*/app-config.jsonwhosestatusisactive.index.phpprints the page head: the web app manifest link, the favicon and title (from thexp_systemapp options), the import map, and the style bundles.- The body holds
#appwith the loader and arouter-view. An inline script defines two constants:XP_CONFIG_PUBLIC: the keys listed in_publicofxp-config.json, plus anappsarray with, for each active app, itsapp_typeand the keys listed in its own_public(menus, widgets, settings and so on).XP_LANGUAGE: the strings of every app'slanguages/lang.jsonfor the current language (thelanguagecookie, defaultEN), keyed by app folder name.
- The script bundles load.
lib-scripts.jsis a classic script holding Vue, Vue Router, jQuery, the other libraries,xp.jsand the UI kit.xp.jscreates the Vue app (App), the router (Router) and the globalXPobject.app-scripts.jsis a module holding every active app'sresources.scripts, which is where each app'sroute.jscallsRouter.addRoute(). - A final inline module calls
XP.mountApp(), savesXP_LANGUAGEto local storage, applies the theme classes and starts or removes the service worker.
XP.mountApp() installs the router, mounts Vue on #app, fetches the public app options into local storage and starts device detection. See Frontend Runtime (XP).
The admin panel at /api/admin/ works the same way, with its own index.php, server.php, xp-config.json and xp.js, and api/admin/apps/ in place of apps/.
How resources are bundled
There is no build or compile step. On every page render, server.php concatenates the resource files and writes four bundles to assets/:
| Bundle | Built from | Loaded as |
|---|---|---|
assets/lib-styles.css | resources.styles in xp-config.json | stylesheet |
assets/lib-scripts.js | resources.scripts in xp-config.json | classic script |
assets/app-styles.css | resources.styles of every active app's app-config.json | stylesheet |
assets/app-scripts.js | resources.scripts of every active app's app-config.json | type="module" |
Apps are bundled in folder-name order. Each bundle URL carries ?v=<content hash>, so the browser fetches a bundle again exactly when its content changes. Edit a file, reload, and the change is live.
Because every app's scripts end up in one module file:
- A syntax error in any app's bundled script stops the routes of every app. Check the browser console first when routes disappear.
- Top-level names share one scope. Two apps that both declare
const Ordersinroute.jsbreak the bundle. Prefix top-level names with your app name. - Relative imports in a bundled file resolve against
/assets/. Use absolute paths such as/apps/myapp/components/orders.js. - The web server user must be able to write to
assets/.
Only list route.js and plain setup scripts in resources.scripts. Components and services.js are ES modules that load on demand through import. See App Frontend Config.
Cache busting with the import map
Since 0.0.38, module URLs are versioned by an import map instead of query strings. index.php prints this before any module runs:
{ "imports": { "/apps/": "/_v/3f9c2a7b1e/apps/" } }
The token is a hash of the newest modification time and the file count under apps/. Adding, removing or editing any file there changes it. The browser then loads /apps/myapp/components/orders.js from /_v/3f9c2a7b1e/apps/myapp/components/orders.js. Relative imports resolve against that versioned URL, so the whole module graph moves to new URLs together.
The root .htaccess maps /_v/<token>/<path> back to /<path> and marks .js and .css responses as immutable. Where that rule doesn't apply (AllowOverride None, or nginx), server.php serves the file itself, for static file types only. The admin panel maps /api/admin/apps/ the same way.
So your imports stay bare. Never add ?v= or ?ver= to an import: the same module loaded under two URLs becomes two instances with separate state.
The API response
Every framework and app endpoint answers with the Response class:
$res = new Response();
$res->setData($items);
echo $res->create(200, 'Items loaded.', true);
The body is pretty-printed JSON:
{
"response": {
"statusCode": 200,
"statusMsg": "Items loaded.",
"success": true
},
"data": [
{ "id": 1, "name": "First item" }
]
}
create()also sets the HTTP status line from the code. A code the framework doesn't know is sent as999 API Error.datais left out when the data is empty or falsy:[],"",0,falseornull. Check for the key, not just its value.errorsis added when the request logged messages and<log_db>istrue. It lists those log entries.- The frontend checks
response.success. Many actions return200withsuccess: falsefor a handled failure, ashelloworlddoes.
Status codes the framework sends
| Code | statusMsg | When |
|---|---|---|
204 | (no body) | CORS preflight (OPTIONS) |
400 | Arigato! | The URL has no controller or no action |
400 | Bad request | Response::badRequest(), the usual answer for an unsupported HTTP method |
401 | Not Authorized | authenticate() returned false |
413 | The upload is larger than this server accepts (<limit>). Raise post_max_size and upload_max_filesize in php.ini. | The request body was over post_max_size |
497 | Please Use HTTPS! | The request did not arrive over HTTPS |
500 | Health Check Failed | The health check failed |
500 | Exception: <message> | An uncaught exception |
501 | Not Supported yet | No controller class for the URL |
503 | see above | Maintenance or down mode |
Your actions choose their own codes, usually 200 and 501 for an unknown action.
CLI gotchas
-
Run from the
api/folder. The framework loads controllers by absolute path, but some lookups (for exampleAppManager::getAppsInfo()andgetAppUserPermission()) read the apps folder as a path relative to the working directory. Run from the project root, those find no apps and no permissions. -
Set
REQUEST_METHOD. On the command line,getRequest()->getType()returnscli, notGETorPOST. A controller that switches on the HTTP method, ashelloworlddoes, answers400 Bad request. Prefix the command withREQUEST_METHOD=GETorREQUEST_METHOD=POST. -
Pick the environment with
APP_CONFIG_ENV. Database and site settings come fromapi/config.<environment>.xml. On the command line,APP_CONFIG_ENV=staging php shell.php ...selectsconfig.staging.xmlinstead of the one named by<system_environment>. On Apache, the same variable can be set per virtual host withSetEnv. -
There is no session.
run_shell()never starts one, so a session-basedauthenticate()returnsfalseand the shell printsNot Authorized(with code500, not401). Let shell runs through explicitly, and keep HTTP checks for everything else:public function authenticate(){if (php_sapi_name() === 'cli') {return true;}return auth::appUserPermission($this->getRequest()->getAction(),$this->getRequest()->getController(),[]);}Test
php_sapi_name() === 'cli'here, not the framework'sis_shell().is_shell()also returnstruefor thecgiandcgi-fcgiSAPIs, so on a host that runs PHP as CGI it would let every web request through. -
One data argument, simple values. The data argument is split on
,and then on=. A value can't contain a comma, anything after a second=is dropped, and a pair without=raises a PHP warning. Read the data withgetData():$_GETand$_POSTare empty. -
No maintenance check, HTTPS check or activity log. Shell runs skip all three. A missing controller answers
500 Not Supported yet. -
Dev links are not created from the CLI.
setup_dev_links()needs the web server's document root, which is empty on the command line. Load any page once after creating a newdev/<app>/folder, so the links exist before you run the shell.