This is an old revision of the document!
Table of Contents
Architecture Overview
This page describes how Scriptlog is put together: the order in which the application boots, how a request travels through it, and where the boundaries between layers are supposed to be. Every statement here is traceable to the generated reference pages linked throughout.
Related: Non-functionality | Layer Boundaries | Limits | Requirements | Negative Space | Contents
1. Shape of the code
Scriptlog is a PHP 7.4–8.5 blog application with no framework. It has its own PSR-4 autoloader, its own service registry, its own router and its own template renderer. Dependencies are wired by hand in `lib/core/Bootstrap.php` and stored in a global `Scriptlog\Core\Registry`.
The code is organised into the following layers.
| Layer | Namespace / location | Role |
|---|---|---|
| Controller | `Scriptlog\Controller\` in `lib/controller/` | HTTP endpoints. Read the request, call one service, render a view. |
| Service | `Scriptlog\Service\` in `lib/service/` | Business logic. Owns the rules that decide what happens. |
| DAO | `Scriptlog\Dao\` in `lib/dao/` | SQL. Prepared statements in, associative arrays out. |
| Model | `Scriptlog\Model\` in `lib/model/` | Domain value objects that express a rule the rest of the app must respect. |
| DTO | `Scriptlog\Dto\` in `lib/dto/` | Small data carriers passed between layers. |
| Handler | `Scriptlog\Handler\` in `lib/handler/` | Framework handlers: bootstrap, front controller, routing, dispatch, error handling. |
| Validator | `Scriptlog\Validator\` in `lib/validator/` | Input validation. Returns errors a form can display. |
| Core | `Scriptlog\Core\` in `lib/core/` | Shared infrastructure: base `Dao`, `BaseModel`, `Database`, `Sanitizer`, `Logger`, `AppConfig`, `ApiRouter`, `Bootstrap`, `Registry`, exceptions. |
| Utility | `lib/utility/*.php`, global namespace | Flat procedural helper files. No classes, no namespace. |
| Admin | `admin/` | The admin UI, mostly as plugin directories. |
`lib/utility/` is the one area with no namespace at all. Its 224 files define global functions guarded by `defined('SCRIPTLOG') || die(…)`, which is why they cannot be autoloaded and must be required by `lib/utility-loader.php`.
Two areas sit outside that stack:
- Theme functions (`lib/themes/<theme>/*.php`) are global, procedural and called directly by templates, not through a handler interface.
- Bootstrap files (`lib/*.php`, project root `*.php`) are the runtime itself: the autoloader, the alias map, options and the front controller.
2. Boot order
The order matters, because each step depends on the previous one having finished. Read it as a strict pipeline, top to bottom — the diagram first, then the nine numbered steps underneath it (DokuWiki has no numbered-list syntax, so the steps carry explicit numbers):
index.php ---require---> lib/main.php ---require---> lib/core/Bootstrap.php | ob_start() | options.php + common.php | initialize(APP_ROOT) | page cache | route sniff --> 404, die | AppContext + services | $app->dispatcher | autoload + .env + aliases | dispatcher + security | ->dispatch() | config? no --> install/ | | | scheduled --> publish | | | switch-lang --> redirect |
- Step 1 - Entry. A web server is pointed at the project root `index.php`. It starts output buffering, then `require`s `lib/main.php`.
- Step 2 - Options and constants. `lib/main.php` includes `lib/options.php` and `lib/common.php` first. These define the path constants and the `define()`d guards that every other file's `Direct access not permitted` check depends on.
- Step 3 - Early route sniff. Before loading anything heavy, `lib/main.php` matches the request against a small list of known path patterns and query parameters. An unrecognised URL is answered with a bare 404 on the spot, without booting the framework.
- Step 4 - Autoloaders. It requires `lib/Autoloader.php`, then `lib/vendor/autoload.php` if readable, and loads `.env` through Dotenv.
- Step 5 - Backward-compatibility aliases. `lib/autoload-aliases-map.php` is read and registered with `class_alias()` for every global class name that predates the namespace. This is a lazy map: an alias is only created when the old global name is actually touched.
- Step 6 - Bootstrap class. `lib/core/Bootstrap.php` is required.
- Step 7 - Initialise. `Bootstrap::initialize(APP_ROOT)` returns an `AppContext`. Loading continues only if a `config.php` exists; otherwise the request is redirected to the installer in `install/`.
- Step 8 - Scheduled posts. Due `scheduled` posts are promoted to `publish`. The code comments this as “never fails a request”, and it runs *before* output is served, so a scheduled post cannot stay hidden behind the page cache.
- Step 9 - Language switch and output. The `switch-lang` query parameter is handled, then `lib/main.php` returns to `index.php`, which hands control to `$app→dispatcher→dispatch()`.
3. Wiring
`Bootstrap` is a static factory made of many small private methods. The order of its own methods is the service graph:
- `loadConfiguration()`, `initializeServices()`, `createDatabaseConnection()`
- `initializeRegistry()`, `createBaseDaos()`, `createSession()`
- `createAuthenticator()`, `createThemeRenderer()`, `createFrontService()`
- `createDownloadChain()`, `createContentDaos()`, `storeInRegistry()`
- `createDispatcher()`, `buildServiceMap()`, `buildHandlerRegistry()`
- `initializeI18n()`, `applySecurity()`, `initializePostSecurity()`
Everything is then published into the global `Registry` via `Registry::setAll()`, and the service map is bound into a service locator. Controllers reach their collaborators through that locator rather than through constructors, which is why the dependency graph is hard to read from the controller class alone.
4. Request lifecycle
Once `Dispatcher::dispatch()` runs, the shape of the response depends on one configuration value, `rewrite_status`:
- `'yes'` — SEO-friendly URLs. `handleSeoFriendlyUrl()` resolves an optional locale prefix first, strips it, and matches the remainder against the route table.
- anything else — `handleQueryStringUrl()` serves the classic `?pg=`, `?p=`, `?a=`, `?cat=`, `?tag=`, `?search=`, `?q=` form.
The `?download=` case is handled before the SEO branch as well, so a download keeps working regardless of the permalink setting.
A matched route is then validated by a `validate*()` method (`validateContentExists`, `validateSinglePost`, `validatePage`, `validateCategory`, `validateArchive`, `validateTag`), the matching theme handler is looked up, and the response is produced by `invokeTheme()`. `renderTheme()` serves a full page; `renderHtmxFragment()` serves a partial for HTMX requests. Frontend routes are compiled once into `$frontendCompiled`.
5. The route table
`Bootstrap::defineRoutingRules()` returns the complete front-end route table. It is a single source of truth; adding a page type means adding a pattern here.
| Key | Pattern | Named captures |
|---|---|---|
| `home` | `/` | |
| `category` | `/category/(?'category'[\w\-]+)` | `category` |
| `archive` | `/archive/[0-9]{2}/[0-9]{4}` | |
| `archives` | `/archives` | |
| `blog` | `/blog([^/]*)` | |
| `page` | `/page/(?'page'[^/]+)` | `page` |
| `single` | `/post/(?'id'\d+)/(?'post'[\w\-]+)` | `id`, `post` |
| `tag` | `/tag/(?'tag'[\w\- ]+)` | `tag` |
| `search` | `/search` | |
| `privacy` | `/privacy` | |
| `locale` | `/locale` | |
| `download` | `/download/(?'identifier'[a-f0-9\-]+)` | `identifier` |
| `download_file` | `/download/(?'identifier'[a-f0-9\-]+)/file` | `identifier` |
6. Where the layers do not hold
The layers above are the intended shape, not the actual one. The generated reference shows controllers importing DAOs directly, `core` importing `controller` and `handler`, and `handler` importing `core` back. Those cycles are real and are documented with evidence in Layer Boundaries.
The limits page lists the boundaries that the code does not enforce, and Negative Space lists what is present but unreachable.
