Slim, deterministic HTTP delivery for CitOmni apps. Zero "magic", PSR-4 all the way, PHP 8.2+, tiny boot, predictable overrides.
- Deterministic boot -> vendor baseline -> providers -> app (last wins)
- Lean routing with exact + placeholder/"regex" routes
- Deep, read-only config ->
$this->app->cfg->http->base_url - Service maps (no scanning) ->
$this->app->{id}resolves instantly (cacheable) - Prod-friendly -> optional compiled caches in
/var/cache/*.php(atomic writes) - HTTP ErrorHandler (optional, auto-installed if present in package)
- Maintenance 503 with
Retry-Afterand allow-list - Security foundations -> CSRF token helper, cookie/session CSP/Samesite defaults
- Webhook HMAC (
WebhooksAuth) with TTL, clock skew tolerance, nonce/replay protection - ♻️ Green by design - lower memory use and CPU cycles -> less server load, more requests per watt, better scalability, smaller carbon footprint.
CitOmni's "Green by design" claim is empirically validated at the framework level.
The core runtime achieves near-floor CPU and memory costs per request on commodity shared infrastructure, sustaining hundreds of RPS per worker with extremely low footprint.
See the full test report here: CitOmni Docs → /reports/2025-10-02-capacity-and-green-by-design.md
- PHP 8.2 or newer
- Recommended extensions:
ext-json(required),mbstring(recommended)
Optional CitOmni packages: citomni/infrastructure, citomni/auth, citomni/testing - OPcache strongly recommended in production
composer require citomni/httpYour app's composer.json must PSR-4 map your code:
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}Then:
composer dump-autoload -o/public/index.php (minimal front controller):
<?php
declare(strict_types=1);
define('CITOMNI_START_TIME', microtime(true));
define('CITOMNI_ENVIRONMENT', 'dev'); // 'dev' | 'stage' | 'prod'
define('CITOMNI_PUBLIC_PATH', __DIR__);
define('CITOMNI_APP_PATH', \dirname(__DIR__));
// In stage/prod you should define an absolute public root URL (or set http.base_url in cfg):
if (\defined('CITOMNI_ENVIRONMENT') && \CITOMNI_ENVIRONMENT !== 'dev') {
define('CITOMNI_PUBLIC_ROOT_URL', 'https://www.example.com');
}
require __DIR__ . '/../vendor/autoload.php';
// Hand over to the HTTP Kernel (it will resolve app/config paths from the public dir)
\CitOmni\Http\Kernel::run(__DIR__);Folder layout (app):
/app-root
/bin
/config
providers.php # optional list of provider FQCNs
citomni_http_cfg.php # app baseline config (HTTP)
citomni_http_cfg.stage.php # optional per-env overlay
citomni_http_cfg.prod.php # optional per-env overlay
services.php # optional service map overrides/additions
/public
index.php
/src
/Http/{Controller,Service,Model}
/Service /Model ...
/templates
/var/{cache,flags,logs,nonces,state}
/vendor
Vendor HTTP baseline lives in \CitOmni\Http\Boot\Config::CFG.
At runtime, the app builds config as:
- Vendor HTTP baseline
- Provider CFGs (if any; listed in
/config/providers.php) - App base cfg
/config/citomni_http_cfg.php - App env overlay
/config/citomni_http_cfg.{env}.php(optional)
Merge rules:
- Associative arrays -> merged per key, last wins
- Numeric lists -> replaced by the last source
- Empty values (
'',false,0,null,[]) are valid overrides and still win
Deep access via read-only wrapper:
$this->app->cfg->locale->timezone;
$this->app->cfg->http->base_url;
$this->app->cfg->routes['/']; // raw array (routes are exposed as raw arrays)<?php
declare(strict_types=1);
return [
'identity' => [
'app_name' => 'My CitOmni App',
'email' => 'support@example.com',
'phone' => '(+45) 12 34 56 77',
],
'locale' => [
'language' => 'da',
'timezone' => 'Europe/Copenhagen',
'charset' => 'UTF-8',
],
'http' => [
'base_url' => '', // dev will auto-detect when empty
'trust_proxy' => false,
'trusted_proxies' => ['10.0.0.0/8', '192.168.0.0/16', '::1'],
],
'error_handler' => [
'log_file' => CITOMNI_APP_PATH . '/var/logs/system_error_log.json',
'recipient' => 'errors@example.com',
'sender' => 'noreply@example.com',
'max_log_size' => 10_485_760,
'template' => __DIR__ . '/../vendor/citomni/http/templates/errors/failsafe_error.php',
'display_errors' => (defined('CITOMNI_ENVIRONMENT') && CITOMNI_ENVIRONMENT === 'dev'),
],
'session' => [
'name' => 'CITSESSID',
'save_path' => CITOMNI_APP_PATH . '/var/state/php_sessions',
// 'gc_maxlifetime' => 1440, // seconds PHP keeps idle session data. citomni/authenticate raises it
// to its idle timeout; app cfg wins, so never set it lower than that.
'gc_probability' => 1, // with gc_divisor: GC chance per session start
'gc_divisor' => 1000,
'use_strict_mode' => true,
'use_only_cookies' => true,
'lazy_write' => true,
// Session cookie overrides; null takes the attribute from 'cookie' below
'cookie_secure' => null,
'cookie_httponly' => true, // pinned: Scripts must not read the session id
'cookie_samesite' => null,
'cookie_path' => null,
'cookie_domain' => null, // '' forces host-only
],
'cookie' => [
'secure' => null, // null: Inferred from base_url, CITOMNI_PUBLIC_ROOT_URL or the request
'httponly' => true,
'samesite' => 'Lax',
'path' => '/',
'domain' => null, // null: Host-only; 'example.com' shares cookies with subdomains
],
'view' => [
'cache_enabled' => false,
'trim_whitespace' => false,
'remove_html_comments' => false,
'allow_php_tags' => false,
'marketing_scripts' => '',
'view_vars' => [],
// 'asset_version' => '2025-09-29',
],
'security' => [
'csrf_protection' => true,
'csrf_field_name' => 'csrf_token',
'captcha_protection' => true,
'honeypot_protection' => true,
'form_action_switching'=> true,
],
'maintenance' => [
'flag' => [
'path' => CITOMNI_APP_PATH . '/var/flags/maintenance.php',
'template' => __DIR__ . '/../vendor/citomni/http/templates/public/maintenance.php',
'allowed_ips' => [],
'default_retry_after'=> 300,
],
'backup' => [
'enabled' => true,
'keep' => 3,
'dir' => CITOMNI_APP_PATH . '/var/backups/flags',
],
'log' => [
'filename' => 'maintenance.json',
],
],
'webhooks' => [
'enabled' => true,
'ttl_seconds' => 300,
'ttl_clock_skew_tolerance' => 60,
'allowed_ips' => [],
'nonce_dir' => CITOMNI_APP_PATH . '/var/nonces',
// 'secret' => '*** put shared secret here ***',
// 'bind_context' => true,
// 'algo' => 'sha512',
],
// Routes: you can inline them here or require from a separate file
'routes' => [
'/' => [
'controller' => \CitOmni\Http\Controller\PublicController::class,
'action' => 'index',
'methods' => ['GET'],
'template_file' => 'public/index.html',
'template_layer' => 'citomni/http',
],
// 403/404/405/500 defaults exist in vendor baseline; override as needed.
'regex' => [],
],
];/config/citomni_http_cfg.stage.php
<?php
return [
'http' => ['base_url' => 'https://stage.example.com'],
];/config/citomni_http_cfg.prod.php
<?php
return [
'http' => ['base_url' => 'https://www.example.com'],
];- dev: Kernel auto-detects when
http.base_url='' - stage/prod: no auto-detect -> require an absolute URL in cfg or define
CITOMNI_PUBLIC_ROOT_URL - Kernel defines
CITOMNI_PUBLIC_ROOT_URL(no trailing slash)
If you run behind Nginx/Apache/Cloudflare, configure http.trust_proxy and http.trusted_proxies correctly. Only include trusted proxy IPs/CIDR blocks, but include every proxy in the chain (load balancer, CDN ranges): the client IP is read from the right end of X-Forwarded-For, and the first address that is not a trusted proxy counts as the client.
Config:
'http' => [
'base_url' => '', // dev auto-detects; stage/prod: absolute URL required
'trust_proxy' => true,
'trusted_proxies' => ['10.0.0.0/8', '192.168.0.0/16', '127.0.0.1', '::1'],
],Nginx (example):
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;If you publish under a sub-path (e.g. https://example.com/app), make sure your base_url includes that path, or set CITOMNI_PUBLIC_ROOT_URL accordingly. The router handles base-prefix stripping correctly either way.
Keep routes inline under cfg['routes'] or load them from a separate PHP file.
Placeholders available:
{id}->[0-9]+{email}->[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+{slug}->[a-zA-Z0-9-_]+{code}->[a-zA-Z0-9]+Unknown placeholders fall back to[^/]+.
Override 403/404/405/500 in cfg['routes']:
404 => [
'controller' => \CitOmni\Http\Controller\PublicController::class,
'action' => 'errorPage',
'methods' => ['GET'],
'template_file' => 'errors/404.html',
'template_layer' => 'app', // or 'citomni/http' for vendor template
'params' => [404],
],Template variables provided to errorPage:
status_code(int)errors(array|null, only filled for 500 when ErrorHandler has entries)
- Framework controllers:
CitOmni\Http\Controller\* - App controllers:
App\Http\Controller\*The router instantiates controllers and injects the App and view hints:
<?php
declare(strict_types=1);
namespace App\Http\Controller;
use CitOmni\Kernel\Controller\BaseController;
final class HomeController extends BaseController {
// Optional boot hook (called by BaseController::__construct)
protected function init(): void {
// e.g. preload something for the view
}
public function index(): void {
$this->app->view->render(
$this->routeConfig['template_file'] ?? 'public/index.html',
$this->routeConfig['template_layer'] ?? 'app',
[
'noindex' => 0,
'canonical' => \CITOMNI_PUBLIC_ROOT_URL,
]
);
}
}A minimal route for load balancers/uptime checks:
'/health' => [
'controller' => \CitOmni\Http\Controller\PublicController::class,
'action' => 'health',
'methods' => ['GET'],
],// In PublicController:
public function health(): void {
$this->app->response->jsonStatus([
'status' => 'ok',
'env' => defined('CITOMNI_ENVIRONMENT') ? CITOMNI_ENVIRONMENT : 'unknown',
'maintenance' => $this->app->maintenance->isEnabled(),
], 200);
}View renders LiteView templates from either your app (/templates) or a vendor "layer" like citomni/auth. It also exposes a small set of globals and closures you can call directly in templates.
use CitOmni\Kernel\Controller\BaseController;
final class PublicController extends BaseController
{
public function index(): void
{
$this->app->view->render('public/home.html', 'citomni/http', [
'title' => 'Welcome',
'lead' => 'Fast, predictable HTTP runtime for CitOmni apps.',
'cta_path' => '/docs/get-started',
]);
}
}Using Template helpers (LiteView syntax)
<!doctype html>
<html lang="{{ $language }}">
<head>
<meta charset="{{ $charset }}">
<title>{{ $app_name }} - {{ $title }}</title>
<meta name="description" content="{{ $lead }}">
<link rel="stylesheet" href="{{ $asset('/assets/app.css') }}">
{{{ $marketing_scripts }}} {# optional, if you inject any #}
</head>
<body>
<main>
<h1>{{ $title }}</h1>
<p>{{ $lead }}</p>
<a class="btn" href="{{ $url($cta_path) }}">
{{ $txt('cta.get_started', 'home', 'citomni/http', 'Get started') }}
</a>
</main>
{# Existing examples kept #}
<a href="{{ $url('/member/home.html') }}">Home</a>
{% if $hasPackage('citomni/auth') %}
<a href="{{ $url('/member/profile.html') }}">Profile</a>
{% endif %}
<form method="post" action="{{ $url('/feedback.json') }}">
{{{ $csrfField() }}}
<!-- fields -->
</form>
<script src="{{ $asset('/assets/app.js') }}"></script>
</body>
</html>LiteView syntax:
{{ ... }}prints escaped,{{{ ... }}}prints raw. Control structures use{% ... %}and comments use{# ... #}. Find more examples in the documentation inside the View-service.
app_name(string)base_url(string) - fromhttp.base_url(or auto-detected in dev)public_root_url(string) -CITOMNI_PUBLIC_ROOT_URLif defined, elsebase_urllanguage(string),charset(string)marketing_scripts(string),view_vars(array)csrf_protection,honeypot_protection,form_action_switching,captcha_protection(bool flags)env(array) ->['name' => 'dev|stage|prod', 'dev' => bool]
Closures:
$txt(string $key, string $file, ?string $layer = null, string $default = '', array $vars = []): stringRequires a registeredtxtservice (commonly fromcitomni/infrastructure).$url(string $path = '', array $query = []): stringJoinsbase_url+ normalizedpath+ optional query.$asset(string $path, ?string $version = null): stringAbsolute ifpathis already a URL; otherwisebase_url + path, with?v=...appended ifversionorview.asset_versionis set (preserves existing query).$hasService(string $id): bool- service id in the map?$hasPackage(string $slug): bool- vendor/package detected via services/routes?$csrfField(): string- hidden CSRF<input>(empty string if disabled/not available).$captchaField(): string- hidden<input>with this request's captcha challenge id;$captchaUrl(): string- absolute URL of its image. Both are empty strings when captcha protection is off or the service is missing. See Captcha.$currentPath(): string- request path (lazy; resolves only if called).$role(string $fn, mixed ...$args)- role checks/labels (if role gate is present) Examples:$role('is','admin'),$role('any','manager','operator'),$role('label').
- Base URL: set an absolute
http.base_urlfor stage/prod; dev can auto-detect. - Canonical links: prefer
public_root_urlwhen constructing canonicals or sitemaps. - Vendor layers: pass
template_layer(e.g.citomni/http) andtemplate_filevia routes, or callrender('...', 'vendor/package')directly. - i18n: if you don't use i18n, you can ignore
$txt; if you do, ensure thetxtservice is registered (typically via a provider).
Baseline map shipped by this package:
\CitOmni\Http\Boot\Services::MAP
// [
'request' => \CitOmni\Http\Service\Request::class,
'response' => \CitOmni\Http\Service\Response::class,
'router' => \CitOmni\Http\Service\Router::class,
'session' => \CitOmni\Http\Service\Session::class,
'cookie' => \CitOmni\Http\Service\Cookie::class,
'view' => \CitOmni\Http\Service\View::class,
'security' => \CitOmni\Http\Service\Security::class,
'nonce' => \CitOmni\Http\Service\Nonce::class,
'maintenance' => \CitOmni\Http\Service\Maintenance::class,
'webhooksAuth' => \CitOmni\Http\Service\WebhooksAuth::class,
// ]Extend/override in /config/services.php:
<?php
return [
// Simple override:
'router' => \CitOmni\Http\Service\Router::class,
// With options (constructor is __construct(App $app, array $options = []))
'view' => [
'class' => \CitOmni\Http\Service\View::class,
'options' => ['asset_version' => '2025-09-29'],
],
];Use anywhere:
$this->app->response->noCache();
$this->app->request->json();
$this->app->maintenance->enable(['1.2.3.4']);Note:
log,mailer, andconnectionare provided by citomni/infrastructure and are not part of the HTTP baseline. This package only references them when present.
Request
- Proxy awareness:
http.trust_proxy+http.trusted_proxies baseUrl(),fullUrl(),host(),port(),ip()(with CIDR trust list)json()(auto content-type guard;+jsonsupported)
Response
json()/jsonStatus()/jsonProblem()(neverreturn; sends headers+exits)memberHeaders()/adminHeaders()set sane security headersdownload($path, $name)withX-Content-Type-Options: nosniff
Session / Cookie
Cookieowns the default attributes of every cookie, the session cookie included: Host-only unlesscookie.domainis set, HttpOnly,SameSite=Lax, and Secure inferred fromhttp.base_url,CITOMNI_PUBLIC_ROOT_URLor the request.attributes()resolves overrides on top; invalid values throw.Sessionowns storage: Save path, retention (gc_maxlifetime) and garbage collection are applied explicitly, and a setting PHP refuses throws.session.cookie_*overrides single attributes of the session cookie, whose lifetime is always 0.gc_maxlifetimeis storage retention, not a login lifetime. Debian and Ubuntu shipgc_probability=0and clean only their php.ini save paths from cron, so the baseline sets 1/1000 explicitly; setgc_probabilityto 0 only when a scheduled job cleanssession.save_path.regenerate(true)belongs at privilege changes. It deletes the old session at once, so a concurrent request with the old id gets a new, empty session.session.rotate_intervalandsession.fingerprintwere removed; Session throws while either is enabled.- Reads never create a session:
get(),has(),remove()anddestroy()resume a session that the request's cookie (or an earlier start in the same request) names, and otherwise return without a cookie, a session file or cache headers. Writes (set(),start(),regenerate()) create it. Flash readers and CSRF verification follow the same rule, so a guest page that reads them stays session-free. - Flash (
$this->app->flash): Messages, old input and field errors across one redirect;pullAll()reads and clears,keep()keeps them for one more read.
View
- Renders via LiteView; exposes helpers:
url(),asset(),csrfField(), etc.
Security
- CSRF token helpers (
csrfToken(),verifyCsrf(),csrfHiddenInput())
Nonce
- File-backed nonce ledger; atomic create; TTL-based purge; replay protection
Maintenance
- 503 guard with
Retry-After, allow-list, flag backup + pruning
WebhooksAuth
-
HMAC verify with TTL + clock skew tolerance, optional context binding, IP allow-list, nonce replay protection.
-
Example (strict mode):
$raw = \file_get_contents('php://input') ?: ''; $this->app->webhooksAuth ->setOptions($this->app->cfg->webhooks) ->assertAuthorized($_SERVER, $raw); // throws on failure
<?php
$secret = 'shared-secret';
$ts = time();
$nonce = bin2hex(random_bytes(16));
$body = json_encode(['event' => 'ping'], JSON_UNESCAPED_UNICODE);
// Simple mode base: "<ts>.<nonce>.<rawBody>"
$base = $ts . '.' . $nonce . '.' . $body;
$sig = hash_hmac('sha256', $base, $secret); // hex
$ch = curl_init('https://example.com/admin/webhook');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Citomni-Timestamp: ' . $ts,
'X-Citomni-Nonce: ' . $nonce,
'X-Citomni-Signature: ' . $sig,
],
CURLOPT_RETURNTRANSFER => true,
]);
$resp = curl_exec($ch);If you enable
bind_context, the client must build the canonical string exactly as documented (METHOD, PATH, QUERY,sha256(body)on separate lines).
Controller (POST handler):
public function submit(): void {
$ok = $this->app->security->verifyCsrf($this->app->request->post('csrf_token'));
if (!$ok) {
$this->app->security->logFailedCsrf('form.submit');
$this->app->response->jsonProblem('Invalid CSRF token', 422);
}
// ... handle form
$this->app->response->jsonStatus(['ok' => true], 200);
}Form (LiteView template):
<form method="post" action="{{ url('submit') }}">
{{ csrfField()|raw }}
<!-- your fields -->
<button type="submit">Send</button>
</form>The captcha service runs a session-backed challenge for HTML forms: issue a code, show its image, verify the post once. Codes and images come from the captchaImage service in citomni/image, so install that package and register its provider in /config/providers.php (\CitOmni\Image\Boot\Registry::class).
1. Route the image (opt-in). This package registers no captcha route. Add one to /config/citomni_http_routes.php, at the path in security.captcha.image_path:
'/captcha.png' => [
'controller' => \CitOmni\Http\Controller\CaptchaController::class,
'action' => 'image',
'methods' => ['GET'],
],2. Put the challenge in the form. $captchaField() issues the request's challenge and renders its id as a hidden field; $captchaUrl() is the image of the same challenge:
{% if ($captcha_protection) %}
{{{ $captchaField() }}}
<img src="{{ $captchaUrl() }}" width="200" height="64" alt="Security code">
<label for="captcha">Code</label>
<input type="text" id="captcha" name="captcha" autocomplete="off" autocapitalize="characters" spellcheck="false" required>
{% endif %}3. Verify the post:
if (!$this->app->captcha->verify()) {
// Wrong, expired or missing: render the form again; it gets a new challenge.
}Behavior:
- Every challenge gets exactly one attempt.
verify()removes it before comparing, right or wrong, so a solved image cannot be replayed. - Challenges expire after
security.captcha.ttlseconds. Up tomax_pendingchallenges are kept per session, so a form open in several tabs keeps working; issuing more drops the oldest. - A challenge's image is rendered with a stored seed, so reloading it shows the same picture instead of a second distortion to compare.
- Answers are compared case-insensitively, ignoring whitespace, in constant time (
captchaImage->verify()). - With
security.captcha_protectionoff,verify()passes,$captchaField()and$captchaUrl()render nothing, and the image route answers 404. - The image route answers 404 without a body for missing, unknown or expired ids, and starts no session for a request without the session cookie.
- Issuing starts the session. As with
$csrfField(), render the form before output is flushed, or keep output buffering on. - Without citomni/image, issuing a challenge fails with
CaptchaConfigException.
Configuration baseline:
'security' => [
'captcha_protection' => true,
'captcha' => [
'session_key' => '_captcha',
'ttl' => 1200, // Keep below session.gc_maxlifetime.
'max_pending' => 5,
'id_field' => 'captcha_id',
'answer_field' => 'captcha',
'image_path' => '/captcha.png',
],
],Outside HTML forms, use current() and imagePath() for the challenge and verifyAnswer($id, $answer) for the check.
A text captcha slows down generic form bots. It does not stop targeted attacks with OCR or vision models, and it excludes users who cannot solve visual challenges. Rate-limit the image route (each image costs a few milliseconds of CPU), combine the captcha with the honeypot and server-side validation, and offer another way to reach you where accessibility matters. The Danish forms_common language file has a message for a wrong code (err_incorrect_captcha).
Providers export their own config/services and are explicitly whitelisted:
/config/providers.php
<?php
return [
\Vendor\Foo\Boot\Services::class, // contributes MAP_HTTP + CFG_HTTP
\Vendor\Bar\Boot\Services::class,
];Providers merge between vendor baseline and app overrides (last wins).
If present, Kernel installs \CitOmni\Http\Exception\ErrorHandler using config under cfg['error_handler'] (not runtime args). It supports:
- JSON-lines log file (with rotation by size)
- Friendly details in dev; safe minimal output in prod
- Optional mail notification via
error_log()(recipient/sender)
Flag file (app-owned): /var/flags/maintenance.php returns:
<?php
return [
'enabled' => true,
'allowed_ips' => ['1.2.3.4'],
'retry_after' => 600,
];HTTP will emit 503 with Retry-After; allow-listed IPs bypass maintenance.
Pre-merge and cache:
/var/cache/cfg.http.php-> merged cfg/var/cache/services.http.php-> final service map
Warm from code:
$result = $this->app->warmCache(overwrite: true, opcacheInvalidate: true);Writes are atomic (tmp + rename), with best-effort OPcache invalidation.
- Prod: Set absolute
http.base_urlorCITOMNI_PUBLIC_ROOT_URL - Cookies: Use
SameSite=Noneonly withSecure=true - HTTPS: Enable HSTS (
adminHeaders()sets it automatically when HTTPS) - Proxy: Set
http.trust_proxy=true+ correcttrusted_proxies - CSRF: Enable and verify tokens on state-changing routes
- Maintenance: Protect with allow-list; enable backup policy
- Webhooks: Configure
webhooks.secret,nonce_dir, and reasonablettl_seconds - Error output:
display_errors=falsein prod; use ErrorHandler logging - Sessions: Rotate with
regenerate(true)at login, logout and privilege changes; keepgc_maxlifetimeat least as long as the longest idle login - Cookie domain: Leave
cookie.domainnull (host-only) unless subdomains must share the cookies
-
Composer
{ "config": { "optimize-autoloader": true, "classmap-authoritative": true, "apcu-autoloader": true } }Then:
composer dump-autoload -o -
OPcache (prod)
opcache.enable=1 opcache.validate_timestamps=0 ; reset on deploy opcache.revalidate_path=0 opcache.save_comments=0 ; if you don't need docblocks at runtime realpath_cache_size=4096k realpath_cache_ttl=600 -
Keep vendor HTTP baseline lean. Put optional integrations in providers.
- Add
?_perf=1to any URL in dev to print execution time, memory, and included files as HTML comments. App::memoryMarker($label, $asHeader=false)prints a compact perf line (dev only).
- Kernel defines
CITOMNI_PUBLIC_ROOT_URL(no trailing slash). - You may keep defining
CITOMNI_APP_PATHandCITOMNI_PUBLIC_PATHinindex.php. - Old route entries using FQCN strings still work; prefer
::classfor IDE/rename safety.
Q: Should I auto-detect base URL in prod?
A: No. Dev -> auto-detect; stage/prod -> set absolute URL in citomni_http_cfg.{env}.php or define CITOMNI_PUBLIC_ROOT_URL.
Q: Can I add per-service options?
A: Yes. All services accept __construct(App $app, array $options = []). Put options in /config/services.php.
Q: Where do role/text helpers come from?
A: View exposes helpers that call services if present (role, txt). If those services aren't installed, the helpers gracefully fallback.
"Base URL is wrong behind proxy"
Set http.trust_proxy=true, fill trusted_proxies, and ensure your proxy sets X-Forwarded-* headers.
"Headers already sent"
Don't echo/var_dump before using Response methods. See response_errors.json (requires a log service).
"CSRF fails on POST"
Ensure the hidden input <input name="csrf_token"> exists and matches security.csrf_field_name.
"Nonce storage failed"
webhooks.nonce_dir (or var/nonces) must exist and be writable by the PHP process.
- Code style: PHP 8.2+, PSR-4, tabs, K&R braces.
- Keep vendor files side-effect free (OPcache-friendly).
- Don't swallow exceptions in core; let the global error handler log.
All CitOmni projects follow the shared conventions documented here: CitOmni Coding & Documentation Conventions
CitOmni HTTP is open-source under the MIT License.
See: LICENSE.
Trademark notice: "CitOmni" and the CitOmni logo are trademarks of Lars Grove Mortensen.
You may not use the CitOmni name or logo to imply endorsement or affiliation without prior written permission.
For details, see the project NOTICE.
"CitOmni" and the CitOmni logo are trademarks of Lars Grove Mortensen.
You may make factual references to "CitOmni", but do not modify the marks, create confusingly similar logos,
or imply sponsorship, endorsement, or affiliation without prior written permission.
Do not register or use "citomni" (or confusingly similar terms) in company names, domains, social handles, or top-level vendor/package names.
For details, see the project's NOTICE.
Developed by Lars Grove Mortensen © 2012-present Contributions and pull requests are welcome!
Built with ❤️ on the CitOmni philosophy: low overhead, high performance, and ready for anything.