Search by

citomni / infrastructure

LarsGMortensen

Lean, cross-mode infrastructure for CitOmni apps: DB, logging, text/i18n, mail, cURL, brute-force protection, and URL slugs.

v1.0.1.5 2026-10-26 23:39 UTC

README

Lean, shared infrastructure services for CitOmni HTTP and CLI applications: database access, input/output normalization, JSONL logging, application secrets, language files, outbound HTTP requests, email, brute-force protection, and URL slugs.

citomni/infrastructure supplies the practical building blocks that almost every application needs, without embedding them in a particular controller, command, or business workflow. The package contributes a service map and configuration defaults to citomni/kernel. Services are resolved lazily through the application and reused within the same App instance.

Its primary services are native CitOmni implementations. Database access uses MySQLi, HTTP requests use PHP's cURL extension, logging writes structured JSON Lines, and localization reads PHP language files. Email delivery delegates to PHPMailer, while the package owns its application-facing interface and configuration.

This is an infrastructure package, not an application framework, ORM, authentication system, or HTTP layer. It performs reusable technical work; the consuming application remains responsible for deciding when and why that work happens.

Highlights

  • One provider for both HTTP and CLI, with the same eleven registered service IDs.
  • Lazy App services through $this->app->db, $this->app->log, $this->app->curl, and their peers.
  • Native MySQLi database access with positional bindings, convenience CRUD operations, transactions, and a bounded prepared-statement cache.
  • Explicit value conversion in both directions between localized form input, SQL values, and display-ready values.
  • Append-only JSONL logging with per-file locking, size-based rotation, and retention.
  • Environment-specific application secrets, resolved lazily from app-local PHP files and never placed in normal configuration.
  • PHP language-file lookup for application and Composer-package translations, with deterministic %PLACEHOLDER% replacement.
  • Outbound HTTP via cURL, with a normalized response contract, optional connection reuse, and explicit redaction of selected query parameters.
  • Email delivery through PHPMailer: one stateless call per message, with required STARTTLS by default, SMTP secrets, attachments, a generated plain-text part, and metadata-only logging.
  • Database-backed brute-force protection for identifiers and IP addresses, with independently configured contexts and bounded counters.
  • Deterministic URL slugs: one policy for making, checking, and deduplicating slugs, with a fixed transliteration that gives the same slug on every server.
  • Clear failure semantics: invalid configuration and unavailable infrastructure fail visibly instead of silently switching strategies.
  • No transport coupling: the same services work in an HTTP controller, an application operation, a registered service, or a CLI command.

What this package is

citomni/infrastructure is CitOmni's shared, App-aware technical infrastructure package.

It answers questions such as:

  • How does a repository execute a parameterized query against the application's database?
  • How does an adapter turn 1.234,50 into a precise SQL DECIMAL string?
  • Where does a component write an operational event that can be parsed later?
  • How does a service read an SMTP password without placing it in the regular config tree?
  • How does an application obtain a translation from its own or a dependency's language files?
  • How does an operation call an external API and receive a consistent response shape?
  • How does a mail workflow compose and send a message?
  • How does an authentication workflow limit repeated failed attempts?

It does not decide which SQL tables represent a domain, which event warrants an email, which response an HTTP client receives, or which account is permitted to perform an action.

What this package provides

Service ID Service Purpose
db Db MySQLi connection, prepared queries, CRUD helpers, transactions
valueToSql ValueToSql Validate and normalize input for SQL storage
valueFromSql ValueFromSql Convert SQL values to localized, presentation-ready values
log Log JSONL logging, file locking, rotation, retention
secrets Secrets Read environment-specific application secrets
txt Txt Resolve language-file strings and placeholders
curl Curl Execute outbound HTTP requests
mailer Mailer Compose and send email through PHPMailer
bruteForce BruteForce Persist and evaluate failed-attempt counters
slugger Slugger Make, check, and deduplicate URL slugs
formatNumber FormatNumber Deprecated decimal conversion compatibility service

All eleven IDs are registered in both the HTTP and CLI maps. formatNumber remains available for existing consumers, but new code should use valueToSql and valueFromSql.

What this package owns

The package owns its technical service contracts and their shared mechanics:

  • Database connection setup, query execution, prepared-statement reuse, and transaction control.
  • Locale-aware parsing and formatting for supported SQL-compatible values.
  • Log file creation, structured event serialization, rotation, and retention.
  • App-local secrets loading and validation.
  • Language-file resolution, caching, and placeholder replacement.
  • cURL request construction, response normalization, and transport diagnostics.
  • PHPMailer integration, SMTP configuration, and mail diagnostics.
  • Brute-force counter rules and persistence through its own repositories.
  • The slug policy and its transliteration.
  • Infrastructure-specific exceptions and a bounded transaction-isolation enum.

The package's provider contributes services and configuration, not routes, controllers, commands, or templates.

What this package does not own

citomni/infrastructure deliberately does not implement:

  • HTTP routing, requests, responses, sessions, cookies, or CSRF protection.
  • CLI command dispatch or scheduler registration.
  • User identity, login workflows, MFA, permissions, or authorization.
  • Domain-specific repositories, database schemas, or business decisions.
  • Database migrations for host applications.
  • An ORM, entity manager, or application query builder.
  • A generic template engine or HTML escaping for email content.
  • Incoming HTTP uploads or file-storage policy.
  • Application-specific retry policies, API rate limits, or queue processing.
  • Secret rotation or an external secret-management server.
  • Automatic database installation merely because the Composer package is present.

A general database connection belongs here; an application's SQL belongs in that application's Repository classes. Similarly, the mailer transports a composed message, while an Operation decides whether a customer should receive it.

Relationship to other CitOmni packages

citomni/kernel
      ↑
citomni/infrastructure
      ↑
application and higher-level packages
      │
      ├── citomni/http        (optional HTTP transport)
      ├── citomni/cli         (optional CLI transport)
      └── citomni/authenticate (optional identity workflows)

citomni/kernel provides the App, the provider composition model, and the base service/repository contracts. Infrastructure uses those contracts but does not require the HTTP or CLI package.

citomni/authenticate and application-specific login flows may use bruteForce, db, and secrets; infrastructure itself does not know what a login session or authenticated user is.

Requirements

  • PHP 8.5+.
  • Composer autoloading.
  • citomni/kernel ^1.0.
  • PHP extensions ext-curl and ext-mysqli.
  • phpmailer/phpmailer ^6.9.

Optional integrations and performance aids:

  • ext-intl together with ext-mbstring for email addresses with an internationalized domain name, which PHPMailer then sends in punycode.
  • ext-opcache for production performance.
  • citomni/http, citomni/cli, or citomni/authenticate according to the host application's needs.
  • A reachable MySQL/MariaDB server for db and bruteForce. Database access is lazy, so unrelated services do not need to open a database connection.

The database implementation targets the MySQL/MariaDB dialect. Transaction-isolation handling is written for MySQL 8.0.16+ and MariaDB 10.6+.

Most buffered read operations require mysqlnd. Where that extension is unavailable, selectNoMysqlnd() provides a streaming alternative with specific lifetime constraints.

Installation

Install the package as a Composer dependency of the application:

composer require citomni/infrastructure

Register the provider in the application's config/providers.php:

<?php
declare(strict_types=1);

return [
	\CitOmni\Infrastructure\Boot\Registry::class,
];

Merge this entry with the application's existing providers rather than replacing registrations for other packages.

Services are available through the normal CitOmni lazy service map:

$this->app->db;
$this->app->valueToSql;
$this->app->valueFromSql;
$this->app->log;
$this->app->secrets;
$this->app->txt;
$this->app->curl;
$this->app->mailer;
$this->app->bruteForce;

The provider does not automatically create a database, install the brute-force table, define the application's language, or provision secret values. Configure the features you intend to use, as described below.

Quick start

A typical application can use the package without instantiating service classes directly.

Normalize form values

$price = $this->app->valueToSql->decimal('1.234,50', required: true);
$units = $this->app->valueToSql->integer('12', required: true, min: 1);

// $price === '1234.50'
// $units === 12

Store values from a Repository

SQL belongs in the application's Repository layer:

$id = $this->app->db->insert('products', [
	'name' => 'Example product',
	'price' => $price,
	'units' => $units,
]);

$product = $this->app->db->fetchRow(
	'SELECT id, name, price, units FROM products WHERE id = ?',
	[$id]
);

products is an illustrative application-owned table; the package does not create it.

Format a database value

$displayPrice = $this->app->valueFromSql->decimal($product['price']);

// With the default locale.format config: '1.234,50'

Log an event

$this->app->log->write('catalog.jsonl', 'product.created', [
	'product_id' => $id,
]);

Call an external HTTP API

$response = $this->app->curl->execute([
	'url' => 'https://api.example.com/products',
	'query' => ['limit' => 10],
]);

if ($response['is_http_success']) {
	$payload = json_decode($response['body'], true, flags: JSON_THROW_ON_ERROR);
}

These examples demonstrate the service boundaries. They are not a complete application workflow, and no request/response objects, routes, or table schemas are supplied by this package.

Public API at a glance

The service map is the intended entry point. These are the primary methods currently exposed.

Service Public methods
db fetchValue(), fetchRow(), fetchAll(), countRows(), exists(), select(), selectNoMysqlnd(), execute(), executeMany(), insert(), insertBatch(), update(), delete(), queryRaw(), queryRawMulti(), beginTransaction(), commit(), rollback(), transaction(), easyTransaction(), checkConnection(), ensureConnection(), reconnect(), lastInsertId(), affectedRows(), countQueries(), getLastError(), getLastErrorCode(), setStatementCacheLimit(), clearStatementCache(), close()
valueToSql integer(), decimal(), boolean(), date(), time(), dateTime(), text(), enum(), json()
valueFromSql integer(), decimal(), boolean(), date(), time(), dateTimeLocal(), text(), json()
log write(), setDir(), setMaxFileSize(), setMaxRotatedFiles()
secrets has(), get()
txt get()
curl execute()
mailer send()
bruteForce assertStorageReady(), status(), record(), clear(), prune()
slugger slugify(), normalize(), validate(), enforceLength(), isReserved(), ensureUnique()
formatNumber toDb(), fromDb() — deprecated

The following sections explain parameters, result shapes, configuration, and failure behavior that matter to consumers.

Database service

$this->app->db provides a MySQLi-based database connection and a deliberately small query API. SQL stays explicit, parameters are bound separately, and convenience write methods cover ordinary table operations without introducing an ORM.

Connection lifecycle

A Db instance is obtained lazily through the App service map. It reads configuration at initialization and connects on first database operation, not simply because the application has registered the provider.

The connection setup supports:

  • Host, port, optional socket, username, and database name.
  • Character set, defaulting to utf8mb4.
  • An optional SQL mode.
  • Explicit connection/session time zone, or the PHP application's configured time zone.
  • A configurable connect timeout.
  • A bounded prepared-statement cache.

The database password is read from the Secrets service at connection time. By default the key is db.password, with db.password_secret available to select another secret key.

Do not put db.pass in app configuration. The database service explicitly rejects that legacy setting. Put the password in the secret store instead.

The service sets PHP's process-wide MySQLi error reporting to MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT. This is intentional, but applications using MySQLi directly should be aware that the setting affects the process rather than one connection.

Reading data

Use positional ? placeholders for data values:

// Inside an application Repository.
$email = 'customer@example.com';

$customer = $this->app->db->fetchRow(
	'SELECT id, email, status FROM customers WHERE email = ?',
	[$email]
);

$customerId = $this->app->db->fetchValue(
	'SELECT id FROM customers WHERE email = ?',
	[$email]
);

$rows = $this->app->db->fetchAll(
	'SELECT id, email FROM customers WHERE status = ? ORDER BY id',
	['active']
);

fetchValue() returns the first column of the first row, or null; fetchRow() returns one associative row, or null; fetchAll() returns an array of associative rows. The methods release their result resources for the caller.

For a result that should be iterated manually:

$result = $this->app->db->select(
	'SELECT id, email FROM customers WHERE status = ?',
	['active']
);

try {
	while ($row = $result->fetch_assoc()) {
		// Process one row.
	}
} finally {
	$result->free();
}

select() returns a mysqli_result and the caller must free it. It requires mysqlnd.

selectNoMysqlnd() is the streaming alternative:

foreach ($this->app->db->selectNoMysqlnd(
	'SELECT id, email FROM customers WHERE status = ?',
	['active']
) as $row) {
	// Consume this row before advancing the generator.
}

The yielded rows are backed by MySQLi bound-result references. Process each row in the loop; do not retain the yielded arrays across iterations without copying their scalar values. Avoid concurrent streaming generators on the same connection.

For existence checks, exists($table, $where, $params) accepts a trusted SQL condition, not arbitrary untrusted text:

$exists = $this->app->db->exists('customers', 'email = ?', [$email]);

Writing data

$id = $this->app->db->insert('customers', [
	'email' => 'customer@example.com',
	'status' => 'active',
]);

$changed = $this->app->db->update(
	'customers',
	['status' => 'inactive'],
	'id = ?',
	[$id]
);

$deleted = $this->app->db->delete('customers', 'id = ?', [$id]);

insert() returns the insert ID. update() and delete() return affected row counts and require a non-empty WHERE expression. Table and column identifiers used by the helpers are validated and quoted; caller-provided conditions and raw SQL fragments remain the caller's responsibility.

For a custom statement, use execute():

$changed = $this->app->db->execute(
	'UPDATE customers SET status = ? WHERE id = ?',
	['inactive', $id]
);

executeMany($sql, $paramSets) executes the same prepared SQL with multiple parameter sets. insertBatch($table, $rows) inserts rows with the same column layout, using a bounded multi-row statement when practical and smaller executions for larger inputs. The bulk path may use its own transaction and is not a nested-transaction facility; plan surrounding transactions accordingly.

queryRaw() and queryRawMulti() execute unparameterized SQL. They are intended for trusted internal statements, not user-provided values; result resources returned by these low-level methods must be released by the caller.

Transactions

Use transaction() for ordinary units of work:

use CitOmni\Infrastructure\Enum\TransactionIsolation;
use CitOmni\Infrastructure\Service\Db;

$orderId = $this->app->db->transaction(
	static function (Db $db) use ($order, $lines): int {
		$orderId = $db->insert('orders', $order);

		foreach ($lines as $line) {
			$db->insert('order_lines', [
				'order_id' => $orderId,
				'product_id' => $line['product_id'],
				'quantity' => $line['quantity'],
			]);
		}

		return $orderId;
	},
	TransactionIsolation::ReadCommitted
);

The callback receives the current Db instance. A successful callback commits and returns its result. A thrown exception triggers rollback; if rollback succeeds, the original exception is rethrown.

An isolation argument applies to that transaction only. Available enum cases are ReadUncommitted, ReadCommitted, RepeatableRead, and Serializable.

Manual beginTransaction(), commit(), and rollback() are also available. Nested transactions are not supported. easyTransaction() is a legacy alias of transaction().

Statement cache and connection controls

Prepared statements are cached per database service instance, with a default capacity of 128. The bounded cache avoids repeatedly preparing identical SQL in a long-running job while keeping resource usage predictable.

$this->app->db->setStatementCacheLimit(64);
$this->app->db->clearStatementCache();

$count = $this->app->db->countQueries();
$this->app->db->countQueries(reset: true);

A limit of 0 disables the cache. close() closes the database connection. checkConnection(), ensureConnection(), and reconnect() expose explicit connection-liveness behavior; a stale connection must not be silently replaced in the middle of an active transaction.

Connection errors use DbConnectException, while SQL execution failures use DbQueryException. Query errors include diagnostic SQL and parameter types, not interpolated parameter values. Avoid putting credentials directly into SQL literals, because SQL text itself may appear in diagnostics.

DbQueryException also provides isDuplicateEntry() for a MySQL/MariaDB duplicate-key violation (1062) and isDeadlock() for an actual deadlock (1213). A lock-wait timeout (1205) is not classified as a deadlock. These methods identify failure types; the calling Operation still owns any safe retry or conflict-resolution policy.

Values between forms and SQL

The two value services are deliberately asymmetric:

  • valueToSql validates potentially untrusted input and produces storage-ready values.
  • valueFromSql validates database values and produces strings or types appropriate for display and HTML inputs.

Neither service executes SQL. They are useful at transport boundaries and in focused input/output adaptation code, while repositories remain responsible for persistence.

ValueToSql

$values = [
	'quantity' => $this->app->valueToSql->integer('1.200', required: true),
	'amount' => $this->app->valueToSql->decimal('1.234,50', required: true, scale: 2),
	'active' => $this->app->valueToSql->boolean('yes', required: true),
	'date' => $this->app->valueToSql->date('08-10-2026', required: true),
	'time' => $this->app->valueToSql->time('13:45'),
	'datetime' => $this->app->valueToSql->dateTime('2026-10-08T13:45'),
	'code' => $this->app->valueToSql->enum('new', ['new', 'paid']),
	'name' => $this->app->valueToSql->text('  Example  ', maxLen: 100),
	'metadata' => $this->app->valueToSql->json(['source' => 'web']),
];

With the default locale, these become 1200, '1234.50', 1, '2026-10-08', '13:45:00', '2026-10-08 13:45:00', 'new', 'Example', and valid JSON, respectively.

Method Input / output contract
integer($value, $required=false, $min=PHP_INT_MIN, $max=PHP_INT_MAX, $allowNegative=true) Strict localized integer to PHP int or null; enforces bounds
decimal($value, $required=false, $scale=2, $allowNegative=true) Strict localized decimal to precise dot-decimal SQL string or null
boolean($value, $required=false) Recognized boolean input to 0, 1, or null
date($value, $required=false) ISO or supported localized date to YYYY-MM-DD or null
time($value, $required=false) HH:MM[:SS] to HH:MM:SS or null
dateTime($value, $required=false) Local ISO-style date/time to SQL YYYY-MM-DD HH:MM:SS or null
text($value, $required=false, $maxLen=0, $trim=true) String with optional trim and byte-length limit
enum($value, $allowed, $required=false) Exact, case-sensitive membership check
json($value, $required=false) Validate a JSON string or encode a PHP array/object

decimal() is especially important for monetary data. String input is never silently rounded. Too many fractional digits are rejected instead. Native float input follows an explicit half-up rounding path, but floats are approximate; use strings for decimal amounts that require exactness.

The $required flag distinguishes absent optional values from invalid non-empty values. A malformed value raises ValueToSqlException, not null.

ValueFromSql

$display = [
	'quantity' => $this->app->valueFromSql->integer('1200'),
	'amount' => $this->app->valueFromSql->decimal('1234.50'),
	'active' => $this->app->valueFromSql->boolean(1),
	'date' => $this->app->valueFromSql->date('2026-10-08', format: 'DD-MM-YYYY'),
	'time' => $this->app->valueFromSql->time('13:45:00'),
	'datetime' => $this->app->valueFromSql->dateTimeLocal('2026-10-08 13:45:00'),
	'name' => $this->app->valueFromSql->text('  Example  '),
	'metadata' => $this->app->valueFromSql->json('{"source":"web"}'),
];

With the default locale, the important results are '1.200', '1.234,50', true, '08-10-2026', '13:45', '2026-10-08T13:45', the untrimmed text, and a PHP array.

Method Output contract
integer() Localized integer string, or null; supports large SQL integer strings without converting them to native int
decimal() Localized decimal string, or null; grouping, scale, trailing zeros, and rounding can be overridden per call
boolean() PHP bool or null from a valid SQL boolean representation
date() String in YYYY-MM-DD, DD-MM-YYYY, DD/MM/YYYY, or MM/DD/YYYY format
time() HH:MM or HH:MM:SS according to config or per-call override
dateTimeLocal() YYYY-MM-DDTHH:MM or YYYY-MM-DDTHH:MM:SS for HTML datetime-local fields
text() Original non-empty SQL string, preserving whitespace, or null
json() Decoded PHP array, or null; rejects JSON scalar values

dateTimeLocal() changes format, not time zone. It does not convert UTC timestamps to the application's local time zone. SQL DATETIME values have no zone metadata; any required conversion belongs in the calling application.

For valueFromSql->decimal(), the default policy for SQL decimal strings is to fail rather than silently round when reducing precision. Zeros beyond the scale carry no value and are dropped first, so with scale 2 "1.500" becomes "1,50", while "1.505" fails. Explicit alternatives are truncate and half_up. These choices are defined under locale.format.

Validation errors and fields

Conversion errors use ValueToSqlException and ValueFromSqlException. They provide stable message keys and parameter data for an adapter that wants to turn a validation failure into a localized form message.

use CitOmni\Infrastructure\Exception\ValueToSqlException;

try {
	$amount = $this->app->valueToSql->decimal('not-a-number', required: true);
} catch (ValueToSqlException $e) {
	$fieldError = $e->withField('amount');
	$messageKey = $fieldError->getMessageKey();
	$messageParams = $fieldError->getMessageParams();
}

Catch such exceptions where the failure is recoverable as a user-input error. Invalid service configuration and programmer mistakes should normally fail fast. ValueConfigurationException and ValueDefinitionException describe those other failure categories.

Deprecated FormatNumber service

$this->app->formatNumber remains registered for existing consumers, but Registry explicitly marks it deprecated. Prefer the two directional value services for new applications.

The legacy methods are:

$sql = $this->app->formatNumber->toDb('1.234,50', precision: 10, scale: 2);
$text = $this->app->formatNumber->fromDb('1234.50', scale: 2);

// $sql === '1234.50'
// $text === '1.234,50'

toDb() supports comma-decimal UI numbers with either dots or ordinary ASCII spaces for thousands, checks DECIMAL(precision, scale) limits, and does not silently round. fromDb() accepts a dot-decimal SQL string and formats it with explicitly chosen separators. Unlike the current value services, its input conventions are fixed and it uses Txt for some error messages.

For new code, use:

$sql = $this->app->valueToSql->decimal('1.234,50', scale: 2);
$text = $this->app->valueFromSql->decimal('1234.50', scale: 2);

The newer services use the shared locale.format policy and return structured value exceptions. They do not reproduce every legacy calling convention automatically; review locale rules and null/empty-value semantics when migrating.

JSONL logging

$this->app->log writes one JSON object per line. This format can be processed with standard text tools and structured log collectors without special parsing of free-form messages.

$this->app->log->write('payments.jsonl', 'payment.received', 'Payment accepted', [
	'payment_id' => 123,
	'currency' => 'DKK',
]);

A representative line:

{"timestamp":"2026-10-08T18:00:00+02:00","category":"payment.received","message":"Payment accepted","context":{"payment_id":123,"currency":"DKK"}}

The timestamp comes from the runtime. The example above is illustrative, not a promise of a particular timestamp.

The public method is:

$this->app->log->write(?string $file, string $category, string|array|object $message, array $context = []);

$file = null or '' uses the configured default. A filename is a flat basename, never a directory path. Valid names are normalized to a .jsonl extension, including the registry's citomni_app.log default, which therefore writes to citomni_app.jsonl.

Rotation and retention

  • Default directory is CITOMNI_APP_PATH . '/var/logs'.
  • Default maximum active file size is 2_000_000 bytes.
  • Default retention is ten rotated files per filename family; null disables pruning.
  • Each file uses a separate .lock sidecar and an exclusive file lock for append/rotation coordination.
  • Rotation happens when necessary before and after append; archives receive timestamped filenames.
  • A failed log write or rotation raises a log-specific exception. Failed archive pruning is non-fatal housekeeping.

These limits can also be changed at runtime:

$this->app->log->setMaxFileSize(4_000_000);
$this->app->log->setMaxRotatedFiles(20);
$this->app->log->setDir('/var/app/logs', autoCreate: true);

setDir() requires an existing writable directory unless autoCreate: true is passed. The file-size limit must be at least 1,024 bytes; the retained archive count must be at least one or null.

Do not log passwords, tokens, full session data, or personal information that the operation does not need. JSONL is a structure and rotation policy, not a data-classification or encryption system.

Application secrets

$this->app->secrets reads an app-local, environment-specific PHP file. This separates credentials from normal application configuration and avoids inadvertently exposing them through configuration dumps.

The active environment comes from CITOMNI_ENVIRONMENT:

Environment App-local secret file
dev var/secrets/app.secret.dev.php
stage var/secrets/app.secret.stage.php
prod var/secrets/app.secret.prod.php

An example var/secrets/app.secret.dev.php:

<?php
declare(strict_types=1);

return [
	'db.password' => 'local-database-password',
	'mail.smtp.password' => 'local-smtp-password',
	'external_api.token' => 'local-api-token',
];

The file must return a flat array<string, string>. Dots in keys are namespacing characters, not nested arrays. The file is application-owned; keep real secrets outside version control and the public document root.

$token = $this->app->secrets->get('external_api.token');

if ($this->app->secrets->has('external_api.optional_token')) {
	$optionalToken = $this->app->secrets->get('external_api.optional_token');
}
  • has() checks the exact key and considers an empty string to be an existing value.
  • get() returns the string or throws OutOfBoundsException for a missing key.
  • Loading is deferred until first access and cached for the current App instance.
  • A missing file is an empty store; an existing malformed/unreadable file fails visibly.
  • There is no fallback from production to staging or development secrets.
  • A newly started App instance sees rotated secrets; a previously loaded instance keeps its values.

The package contains install/manifest.php, which describes create-only scaffolding for the three files. This manifest is not a promise that a bare composer require has generated or populated them. The manifest also declares the contents of var/secrets/ and var/logs/ for the .gitignore block that citomni/installer maintains, so the secret files and log files stay out of Git while each directory's .gitkeep stays versioned.

For normal deployments, provision the correct file, restrict its filesystem permissions, and verify that the active environment is the one intended. Database and SMTP credentials are consumed by their services only when needed.

Text and localization

$this->app->txt reads PHP language files from either the host application or an installed package.

The application must define a valid locale.language such as da or en. The infrastructure registry defines locale.format defaults but does not define locale.language.

An application file at language/da/messages.php might contain:

<?php
declare(strict_types=1);

return [
	'hello' => 'Hej %NAME%',
	'saved' => 'Ændringerne er gemt.',
];

Look up text with:

$hello = $this->app->txt->get('hello', 'messages', 'app', 'Hej', [
	'name' => 'Lars',
]);

// 'Hej Lars'

The signature is:

$this->app->txt->get($key, $file, $layer = 'app', $default = '', $vars = []);
  • app resolves CITOMNI_APP_PATH/language/<language>/<file>.php.
  • A layer such as vendor/package resolves CITOMNI_APP_PATH/vendor/vendor/package/language/<language>/<file>.php.
  • $file may contain safe relative subdirectories, for example emails/welcome.
  • Placeholder names are uppercased, so ['name' => 'Lars'] replaces %NAME%. Substitutions apply to fallback strings too.
  • Files are loaded and cached per Txt service instance.
  • Missing files, missing/empty keys, and unusable values fall back to $default and are logged.
  • There is no automatic language fallback chain.

Logging: Txt writes its diagnostics through the log service to txt.jsonl, in the log service's directory. The file name is fixed, and Txt reads no cfg besides locale.language.

Translation values are plain strings, not pre-escaped HTML. Escape them appropriately when inserting them into HTML, attributes, JavaScript, or another output context.

URL slugs

$this->app->slugger gives every caller the same slug policy: what a slug may contain, how long it may be, and which slugs are reserved. Whether a slug is taken belongs to the caller, which answers through a callback.

$base = $this->app->slugger->slugify($title);
$slug = $this->app->slugger->ensureUnique(
	fn(string $candidate): bool => $repository->slugExists($candidate),
	$base
);
  • slugify() transliterates letters to ASCII while slug.transliterate is on, replaces each run of disallowed characters with the separator, collapses and trims separators, lowercases ASCII letters and shortens the result to slug.max_length bytes. It does not check the reserved slugs.
  • normalize() does the same without transliterating or shortening, for comparing a slug that someone typed.
  • validate() throws \InvalidArgumentException for a slug that breaks the policy, also for a reserved one.
  • ensureUnique() tries the base and then base-2, base-3 and so on, and asks the callback about each. The check and the caller's later write are not atomic; a unique key in storage decides under concurrent writers.
  • enforceLength() shortens at a separator when one lies in the last 40 percent, and never cuts inside a UTF-8 character.

The transliteration is a fixed map in the class, so a text gives the same slug on every server, whatever the locale, the platform and the installed extensions. The Danish letters become ae, oe and aa, the German umlauts ae, oe and ue, and ß becomes ss. The other letters of the Latin-1 Supplement and Latin Extended-A lose their accents, also in decomposed form. Every other character is left to slug.disallowed_pattern, which by default replaces it with the separator. "Ærø Øl, løbesko & Ålborg" becomes aeroe-oel-loebesko-aalborg.

Moving from the slugger of citomni/http

Until now, the slugger service came from citomni/http, in the HTTP mode only, with its policy in http.slug. This package now registers slugger in both modes, and its policy is the slug node. When infrastructure is listed in config/providers.php, its slugger replaces the one of citomni/http, whose release without the service follows.

  • The public methods keep their names, parameters and results. Code that calls $this->app->slugger needs no change.
  • Move an override of http.slug to slug, and rename maxLength to max_length and allowedPattern to disallowed_pattern; the same goes for options in a service map entry. The keys of http.slug have no effect anymore.
  • Transliteration no longer goes through iconv, whose result depended on the process locale: "Ærø" became aer under LC_CTYPE=C and aero under C.UTF-8. New slugs follow the map above. Stored slugs do not change.
  • A slug policy value that is not valid throws SluggerConfigException with the cfg path, where the previous slugger ignored some values: a non-bool lowercase or transliterate, a reserved slug that is not a non-empty string, a pattern that does not compile or matches the empty string, and a separator that the pattern matches.
  • A numeric reserved slug, such as 404, is now reserved; a separator of several characters is collapsed and trimmed as a whole; enforceLength() no longer cuts inside a UTF-8 character; and a pattern with the u modifier makes slugify(), normalize() and validate() throw \InvalidArgumentException for text that is not valid UTF-8 instead of returning '' or accepting it.

Outbound HTTP with cURL

$this->app->curl->execute() accepts one explicit request array and returns a normalized array for both successful and unsuccessful HTTP status codes.

$response = $this->app->curl->execute([
	'url' => 'https://api.example.com/v1/orders',
	'method' => 'GET',
	'headers' => [
		'Accept: application/json',
	],
	'query' => [
		'page' => 1,
		'api_key' => $this->app->secrets->get('external_api.token'),
	],
	'sensitive_query_keys' => ['api_key'],
]);

if (!$response['is_http_success']) {
	// Application-specific handling of a non-2xx HTTP response.
}

The secret query value is sent to the server unchanged, but its configured key is redacted from URL metadata, diagnostics, and exceptions. The list is case-sensitive and applies to top-level query keys; it is not a generic detector for secrets in arbitrary URLs, headers, or bodies.

Request options

url is required. All other request keys are optional, and unknown keys are rejected.

Key Meaning / default
url Request URL string; normally an absolute HTTP(S) URL; required
method HTTP verb, default GET
headers Raw Header-Name: value lines; no CR/LF/NUL injection
body Raw request-body string, or null
query Array added to the URL using RFC 3986 query encoding
sensitive_query_keys Top-level query keys redacted from exposed URL metadata
timeout Total transfer timeout, default 30 seconds; fractions supported
connect_timeout Connection timeout, default 10 seconds
follow_redirects false by default
max_redirects 10 by default, relevant when redirects are enabled
user_agent CitOmni cURL by default
verify_peer, verify_host TLS certificate/hostname verification; enabled by default
ca_info, proxy Optional CA bundle and proxy
cookie_store Combined read/write cookie-jar file
cookie_file, cookie_jar Separate cookie input/output files
return_headers Capture/parse response headers, default true
capture_info Include cURL transfer information, default true
auto_referer, referer Redirect referer behavior and explicit referer
basic_auth, bearer_token Authentication options; not to be combined with conflicting Authorization headers
curl_options Advanced CURLOPT_* overrides applied last
log_file, log_context Per-request logging file and structured context

Raw curl_options are an escape hatch. Because they are applied last, they can override transport and security defaults; use only trusted, reviewed option sets.

Response contract

[
	'request' => [
		'method' => 'GET',
		'url' => 'https://api.example.com/v1/orders?page=1',
	],
	'status_code' => 200,
	'is_http_success' => true,
	'headers_raw' => "HTTP/1.1 200 OK\r\n...",
	'headers' => ['content-type' => 'application/json'],
	'body' => '{"orders":[]}',
	'body_bytes' => 13,
	'effective_url' => 'https://api.example.com/v1/orders?page=1',
	'content_type' => 'application/json',
	'info' => ['http_code' => 200],
]

This is an illustrative shape; metadata and byte counts depend on the actual request. headers uses lowercase names from the final response header block, with repeated headers represented as lists where necessary. headers_raw contains received response heads; info contains the full cURL transfer metadata when enabled (omitted in this abbreviated example).

HTTP 4xx/5xx is not a transport exception. Inspect status_code or is_http_success. cURL configuration and transport failures raise CurlConfigException and CurlExecException respectively.

The package does not decode JSON automatically, retry failed requests, enforce application API budgets, or decide whether a particular non-2xx response should be retried.

Connection reuse and logging

With curl.reuse_connections = true, the service shares relevant cURL connection/DNS state across calls within the same App lifetime. This avoids unnecessary setup for repeated outbound requests. It is not a cross-process pool.

Transport errors are logged by default and completed requests are not. Enable success logs deliberately if required. A completed HTTP 500 is a completed HTTP transfer, not a CurlExecException.

Do not pass attacker-controlled URLs to cURL without application-level scheme/host/IP restrictions. The service checks that url is a non-empty string but does not itself enforce an HTTP(S)-only scheme or an allowed host list. libcurl protocol support, DNS resolution, redirects, and proxy configuration can otherwise enable server-side request forgery (SSRF).

Email with Mailer

$this->app->mailer->send() sends one finished message per call through PHPMailer, over SMTP, a sendmail binary, or PHP's mail(). Every call builds its message on a new PHPMailer instance from its own arguments and the mail cfg; nothing carries over from one message to the next, so a failed or nested send cannot leak recipients, bodies, or transport state into another message.

use CitOmni\Infrastructure\Exception\MailerSendException;

try {
	$messageId = $this->app->mailer->send(
		to: ['customer@example.com' => 'Customer'],
		subject: 'Your receipt',
		html: '<p>Thank you for your order.</p>',
	);
} catch (MailerSendException $e) {
	// The transport refused the message. Let the owning workflow decide whether to retry or queue.
	$reason = $e->getMessage();
}

send() returns the Message-ID header value once the transport has accepted the message for every recipient. Otherwise it throws, and the exception type says what happened:

Exception When Was anything sent?
InvalidArgumentException No recipient or body, from naming other than one address, a non-string entry, an internationalized domain that punycode cannot express, or an address or attachment that PHPMailer rejects No
MailerException PHPMailer cannot build the message from valid arguments, such as when signing set up in a newPhpMailer() override fails No
MailerSendException The transport did not confirm the message for every recipient: connection, TLS, authentication, a refused sender or recipient, or no answer in time $e->accepted lists the recipients it confirmed; see below
OutOfBoundsException and the other Secrets exceptions The SMTP password secret is missing or unreadable No
MailerConfigException A mail cfg value is invalid; thrown when the service is created, with the cfg path in $e->path No

Addresses, bodies, and attachments

$messageId = $this->app->mailer->send(
	to: ['first@example.com', 'second@example.com' => 'Second Person'],
	subject: 'Report',
	html: '<p>Your <a href="https://example.com/reports/7">report</a> is attached.</p>',
	cc: 'team@example.com',
	bcc: ['archive@example.com'],
	replyTo: 'support@example.com',
	from: ['reports@example.com' => 'Reports'],
	attachments: ['report.pdf' => '/path/to/report-7.pdf'],
);
  • An address argument is one address, or an array of addresses and address => display name pairs. An address that to, cc, and bcc name more than once, in any letter case or with its domain once in Unicode and once in punycode, receives one copy.
  • An internationalized domain goes out in punycode. Without ext-intl and ext-mbstring, send() refuses an address with a non-ASCII domain with InvalidArgumentException.
  • An address with UTF-8 in its local part needs PHPMailer 6.10 or later and a server that offers SMTPUTF8. Every address of that message is then checked with PHPMailer's eai validator instead of php, whatever their order.
  • One of html and text is required. With html alone, the plain-text alternative is generated: tags dropped together with the content of head, title, style, and script, links written as text (URL). An HTML body without text, such as a single image, gets no alternative. Pass text to write it yourself.
  • from replaces mail.from for one message; with SMTP, it is also the envelope sender. replyTo replaces mail.reply_to; replyTo: [] sends none.
  • attachments takes paths of readable files. A string key is the file name the recipient sees.

Bodies go out as given. The mailer has no templating and no HTML escaping: render the body with txt or a template, and escape untrusted values for HTML before calling send().

Refusals, partial delivery, and logging

An SMTP server can accept a message for some recipients and refuse others. send() then throws MailerSendException with the accepted addresses in $e->accepted, spelled as in the arguments; sending the same message again would reach them twice.

$e->accepted lists only what the server confirmed. When the connection fails or mail.smtp.timeout runs out before the server has answered the message data, the server may deliver the message although $e->accepted is empty. A workflow that retries automatically should allow for that duplicate.

With mail.log_errors (on by default), every refused send writes a mailer.error line, and with mail.log_success every accepted send writes a mailer.success line, both to mailer.jsonl through the log service when the application has one. A line holds the Message-ID, transport, sender, recipients as sent (Bcc included), subject, and duration, and for a refusal the error and the accepted addresses. The Mailer adds no body and no credential, but addresses and subjects are personal data in many applications: restrict access to the log, or turn mail.log_errors off. Logging is best-effort: a log write that fails is dropped, so that it can neither hide a refusal nor turn an accepted send into an exception.

The error in $e->getMessage() and in the log line quotes the server's reply as the server wrote it. The Mailer cannot vouch for that text: a content filter that quotes the message in its refusal puts that quote into the exception and the log.

The bodies stay out of stack traces as well: html and text are #[\SensitiveParameter], and exceptions raised while PHPMailer holds the message carry no arguments in their traces, because zend.exception_ignore_args is on for that span. A host that locks the setting off, with php_admin_flag for example, keeps the arguments, and PHPMailer's frames then show the message.

Transport setup

The baseline transport is smtp on port 587 with required STARTTLS: a server that does not offer it fails the send instead of receiving credentials and messages in plain text. Choose smtps for implicit TLS, or none for a local relay without TLS. With none, everything crosses the connection in plain text, including the password when mail.smtp.username is set. An unknown transport or encryption value is rejected rather than replaced with a fallback.

mail.smtp.timeout bounds connecting and each reply of the server; PHPMailer allows twice that for the reply to the message data. A server that stops answering fails the send after that time instead of PHPMailer's default of 300 seconds per reply. Raise it for a relay that takes long to accept a message, such as one that scans large attachments before it answers.

The application must set mail.from.email, and for SMTP mail.smtp.host; until it does, using the service throws MailerConfigException. Set mail.smtp.username to authenticate. The password is read from the secret that mail.smtp.password_secret names, mail.smtp.password by default, and only when a message is sent.

mail.hostname fixes the domain of the Message-ID header and the name in the SMTP greeting. When it is '', PHPMailer derives both itself: from the request's server name in HTTP, and from the machine name in CLI.

The protected newPhpMailer() creates the PHPMailer instance for each message. A subclass, registered under the mailer service ID in the application's service map, can override it to add DKIM signing or SMTP stream options. Overriding the protected htmlToText() changes the generated plain-text part.

Brute-force protection

$this->app->bruteForce provides database-backed failed-attempt tracking independent of HTTP routing and authentication policy.

It evaluates two subjects separately:

  1. An identifier, such as a username or email address.
  2. A client IP address, including a shared address used by multiple accounts.

Each configured context has its own thresholds and time windows. Only default is supplied in the baseline registry. An application that wishes to call status('login', ...) must define a login context first.

Required database table

The service requires the bruteforce_counters table. Its schema is supplied in:

sql/citomni_bruteforce.sql

Important deployment warning: This file is a MariaDB SQL dump and currently contains DROP TABLE IF EXISTS bruteforce_counters. Running it against a populated database deletes existing brute-force counters. Review the DDL, turn it into a controlled one-time migration, and never treat the dump as an idempotent production setup command.

The package does not create this table when the provider boots. An explicit readiness check is available:

$this->app->bruteForce->assertStorageReady();

Protect an action

Configure a login context under security.bruteforce, then use the service in the operation that owns the authentication attempt:

$guard = $this->app->bruteForce->status('login', $email, $ip);

if ($guard['blocked']) {
	// The HTTP/CLI adapter decides how to report the delay.
	$retryAfter = $guard['retry_after_seconds'];
} else {
	// Attempt the protected operation.
	if (!$credentialsValid) {
		$this->app->bruteForce->record('login', $email, $ip);
	} else {
		$this->app->bruteForce->clear('login', $email);
	}
}

status() only reads counters; record() writes an unsuccessful attempt; clear() removes the specified buckets. Record failures only, and do not rely on record() to perform a preceding block check.

On a successful login, clearing the identifier bucket only is generally safer than clearing the IP bucket, because multiple unrelated users may share an IP behind NAT.

Status result

[
	'blocked' => false,
	'reason' => null,
	'retry_after_seconds' => 0,
	'blocked_until' => null,
	'identifier_attempts' => 2,
	'ip_attempts' => 3,
	'identifier_remaining' => 3,
	'ip_remaining' => 22,
	'max_identifier_attempts' => 5,
	'max_ip_attempts' => 25,
	'interval_minutes' => 15,
]

reason is null, identifier, ip, or both. blocked_until is a Unix timestamp when blocked. The counters and remaining budgets reflect the context configuration and the active attempt window.

Subjects are normalized and SHA-256 hashed before persistence; raw email addresses and IP addresses are not used as stored counter identifiers. This reduces direct exposure but is not an anonymity guarantee for guessable identifiers.

The special IP inputs unknown and cli do not create IP buckets; they can still accompany an identifier. Supply a real client IP when IP throttling matters.

Cleanup

$deletedRows = $this->app->bruteForce->prune();

Run prune() periodically from an application-owned CLI command or maintenance job. The package does not register a scheduler. Its default pruning age is seven days, with additional handling for orphaned contexts.

The service uses one row per context/subject rather than storing every failed attempt. Its SQL lives in BruteForceRepository, with table-readiness checks in DatabaseSchemaRepository.

Configuration reference

The provider's CFG_COMMON supplies the baseline nodes for both HTTP and CLI. The consuming application may override them through normal CitOmni provider/application configuration composition.

The examples below document recognized settings and actual defaults; they are not a request to duplicate the entire registry into each application's config.

Db, Log, Curl, Mailer, Slugger, ValueToSql and ValueFromSql read the db, log, curl, mail, slug and locale.format keys directly, without fallbacks in code, so the baseline is the only source of those defaults. A compiled config from a version of the package that did not ship a key yet makes the first read throw \OutOfBoundsException. Rebuild the compiled config after updating the package: php bin/citomni cache:warm from citomni/cli rebuilds both modes; without citomni/cli, call the signed /_system/reset-cache or /_system/warmup-cache webhook from citomni/http.

Database — db

Key Default Meaning
host localhost Database host
user citomni Username
name citomni Database name
charset utf8mb4 Connection character set
port 3306 TCP port
socket null Unix socket path; null or '' for the mysqli default: the default socket for host localhost, TCP otherwise
connect_timeout 5 Connection timeout in seconds
password_secret db.password Name of the secret to read on connection
sql_mode null Session SQL mode; null or '' keeps the server default
timezone null Session time zone; null or '' sets the UTC offset of PHP's active time zone
statement_cache_limit 128 Maximum cached prepared statements; 0 disables

No plaintext database password key belongs in this node.

Logging — log

Key Default
path CITOMNI_APP_PATH . '/var/logs'
default_file citomni_app.log (normalized to citomni_app.jsonl)
max_bytes 2_000_000
max_files 10 (or null for unlimited)

Language — locale.language

The application must supply locale.language for the txt service, normally in its common cfg config/citomni_cfg.php, where the citomni/kernel scaffold declares it. The package does not prescribe an application language.

Txt has no cfg node of its own; its diagnostics always go to txt.jsonl through the log service. The LiteTxt-era keys txt.log.file and txt.log.path are no longer part of the baseline and have no effect, so a txt.log node left in application config can be deleted.

Values — locale.format

'locale' => [
	'format' => [
		'decimal_separator' => ',',
		'thousand_separator' => '.',
		'group_thousands' => true,
		'decimal_scale' => 2,
		'decimal_string_rounding' => 'fail',
		'decimal_trim_trailing_zeros' => false,
		'time_include_seconds' => false,
		'datetime_local_include_seconds' => false,
	],
],

The decimal and thousands separators must be distinct. The decimal separator is one character; the thousands separator is empty or one character. Decimal scale ranges from 0 to 18. String rounding modes are case-sensitive fail, truncate, or half_up.

valueToSql uses the locale separators to parse input. valueFromSql also uses the grouping, decimal scale, rounding, trailing-zero, and time/datetime precision defaults. Method arguments can override supported display options per call.

cURL — curl

'curl' => [
	'timeout' => 30,
	'connect_timeout' => 10,
	'follow_redirects' => false,
	'max_redirects' => 10,
	'user_agent' => 'CitOmni cURL',
	'verify_peer' => true,
	'verify_host' => 2,
	'ca_info' => null,
	'proxy' => null,
	'return_headers' => true,
	'capture_info' => true,
	'auto_referer' => true,
	'log_errors' => true,
	'log_success' => false,
	'log_file' => 'curl.jsonl',
	'reuse_connections' => true,
],

In addition to registry configuration, the service definition may supply options for defaults, log_errors, log_success, and log_file. Per-request options take precedence when supported. Keep TLS verification on for production requests.

Mail — mail

'mail' => [
	'from' => ['email' => '', 'name' => ''],
	'reply_to' => ['email' => '', 'name' => ''],
	'hostname' => '',
	'transport' => 'smtp',
	'sendmail_path' => '/usr/sbin/sendmail',
	'smtp' => [
		'host' => '',
		'port' => 587,
		'encryption' => 'starttls',
		'username' => '',
		'password_secret' => 'mail.smtp.password',
		'timeout' => 15,
	],
	'log_errors' => true,
	'log_success' => false,
],
Key Accepted values
from.email A valid address; required. Write an internationalized domain in punycode.
reply_to.email '' for none, or a valid address with an internationalized domain in punycode
hostname '' to let PHPMailer choose, or a host name
transport smtp, sendmail, or mail; exact spelling
sendmail_path A sendmail-compatible binary; required for the sendmail transport
smtp.host A host name or IP address (IPv6 in brackets), or several separated by ; and tried in order; required for the smtp transport. An entry may end in :port, which replaces smtp.port for that host. An ssl:// or tls:// prefix is refused, since smtp.encryption alone sets TLS.
smtp.port An integer from 1 to 65535
smtp.encryption starttls (required STARTTLS), smtps (implicit TLS), or none
smtp.username '' to send without authentication
smtp.password_secret The secrets key of the password, without leading or trailing whitespace
smtp.timeout Seconds that connecting and each reply of the server may take, twice that for the reply to the message data; an integer, at least 1
log_errors, log_success Booleans; both write to mailer.jsonl

Mailer checks every value once, when the service is created, and throws MailerConfigException naming the path of the first invalid one. Every key is checked, also the keys of a transport that is not in use; smtp.host and sendmail_path are required only by their own transport.

Keys that the previous Mailer read are refused, whatever their value, with what replaces each in the message: format (send() takes html or text), logging (log_errors and log_success), smtp.auth (a non-empty smtp.username turns authentication on), smtp.auto_tls (smtp.encryption alone sets the TLS policy), smtp.keepalive (every message opens its own connection), and smtp.password (see below). The message names only the type of the value.

For authenticated SMTP, add the secret that smtp.password_secret names to the active app-local secret file. Do not put the password in the mail config.

Slugs — slug

Key Default Meaning
separator - Replaces disallowed characters and joins the number in ensureUnique(); not empty, and without characters that disallowed_pattern matches
lowercase true Lowercase ASCII letters; validate() then rejects ASCII uppercase
max_length 140 Maximum slug length in bytes, at least 1
transliterate true Let slugify() transliterate letters to ASCII first
reserved [] List of slugs that validate() rejects, compared without the case of ASCII letters
disallowed_pattern /[^a-z0-9\-]+/i PCRE that matches the characters to replace or reject; must compile and must not match the empty string

A service map entry for slugger may set the same keys as options, which win over cfg.

Brute-force — security.bruteforce

The registry provides a default context:

'security' => [
	'bruteforce' => [
		'default' => [
			'max_identifier_attempts' => 5,
			'max_ip_attempts' => 25,
			'interval_minutes' => 15,
			'retry_after_seconds' => 900,
			'prune_after_seconds' => 604800,
		],
	],
],

To separate login, MFA, and other contexts, define additional named entries with their own values. Thresholds, windows, and retry delays must be positive; prune_after_seconds defaults to seven days where omitted.

The concrete action name is application-owned. login, 2fa, or password_reset are not built-in policies; they are simply possible configuration keys. A context name is matched exactly, letter case included, and must be valid UTF-8 of at most 64 characters; status() and record() throw BruteForceConfigException for any other name.

Error handling

Infrastructure errors represent different kinds of failures and should not all be handled the same way.

Area Exceptions / behavior Intended handling
Database DbConnectException, DbQueryException Fail fast or recover explicitly at an owning transaction boundary
Input conversion ValueToSqlException Translate recoverable form errors in the transport adapter
Output conversion ValueFromSqlException Treat unexpected stored values as data-integrity problems
Conversion setup ValueConfigurationException, ValueDefinitionException Fix configuration/calling code; do not hide errors
Logging LogConfigException, LogDirectoryException, LogFileException, LogWriteException, LogRotationException Surface operational failure; do not assume a log event was persisted
Secrets OutOfBoundsException, RuntimeException, UnexpectedValueException Provision/correct the secret source
Text TxtConfigException, InvalidArgumentException; missing entries fall back and log Fix invalid configuration or language resources
cURL CurlConfigException, CurlExecException Reject invalid requests or handle transport failure explicitly
Mailer MailerSendException (with accepted), MailerConfigException, MailerException, InvalidArgumentException Decide per refused send whether to retry or queue; fix configuration, the PHPMailer setup, or calling code otherwise
Brute-force BruteForceConfigException, BruteForceSetupException, input exceptions Fix context or persistence installation
Slugs SluggerConfigException; InvalidArgumentException from validate() and ensureUnique() Fix the slug policy; answer a rejected or taken slug in the adapter

Do not wrap every infrastructure call in a broad catch (\Throwable) that silently produces a default result. Catch only where the owning layer has a real recovery path. The framework's global error handler should remain responsible for unexpected failures.

Security and operational boundaries

Some concerns are important precisely because the package exposes low-level capabilities.

  • Prepared parameters protect values, not SQL syntax. Never interpolate untrusted identifiers, ORDER BY fragments, WHERE fragments, or raw SQL.
  • Secret files must be private and environment-correct. has() does not validate the business meaning of a secret; get() does not supply fallback credentials.
  • Log content is caller-owned. The logger does not automatically redact arbitrary data. Sanitize or omit sensitive fields before writing.
  • cURL redaction is explicit and scoped. sensitive_query_keys is helpful for diagnostics but does not replace secure URL handling or authorization-header discipline.
  • The mailer sends bodies as given. Escape untrusted values for HTML in the calling workflow before they reach send().
  • Brute-force counters need controlled schema management. The provided SQL dump is destructive if reapplied.
  • HTTP status and transport success are different. An HTTP 429/500 response can be a successfully completed cURL request.
  • Configured services are not permission checks. An application Operation or Policy still decides who can execute a protected action.

Determinism and performance

The package favors predictable contracts and low work on normal application paths.

  • Services are lazy App singletons rather than constructed on every use.
  • Db does not connect until it is used and reuses prepared statements within its bounded cache.
  • Db::fetchValue(), fetchRow(), and fetchAll() release their results; manual streaming remains explicit.
  • ValueToSql and ValueFromSql normalize typed values without hidden database queries or locale globals.
  • Secrets loads one small file once per App instance, with no network dependency.
  • Txt caches loaded PHP language files per service instance.
  • Curl may reuse its cURL connection/DNS state within one process.
  • Log writes append-only records and isolates contention with per-file locks.
  • Mailer builds every message on a new PHPMailer instance from its arguments and cfg, and restores the address validator that PHPMailer switches for the whole process, so no message depends on the ones before it.
  • BruteForce uses bounded counter rows rather than an ever-growing history of individual attempts.
  • Slugger transliterates with a fixed map rather than iconv, so a text gives the same slug on every server, whatever its locale and extensions.

External effects—database server behavior, filesystem permissions, SMTP delivery, TLS behavior, and network latency—are not deterministic. The package promises explicit policies and failures, not identical response times or successful delivery from unreliable dependencies.

Current limitations

This package is intentionally smaller than an all-purpose infrastructure framework.

  • Db targets MySQL/MariaDB and does not implement PostgreSQL, SQLite, ORM entities, or schema migrations.
  • Buffered MySQLi reads depend on mysqlnd; the streaming alternative has reference/lifetime constraints.
  • Nested database transactions are not supported.
  • Txt has no automatic language fallback chain and no HTML escaping.
  • The Txt diagnostic file name is fixed to txt.jsonl.
  • Curl has no automatic JSON body handling, retries, or SSRF protection for user-supplied URLs.
  • Mailer has no templating, no inline images, and no per-message custom headers, and it opens a new SMTP connection for every message. Its log file name is fixed to mailer.jsonl.
  • BruteForce requires a separately installed table and application-owned maintenance scheduling.
  • formatNumber is a compatibility service marked deprecated by the registry.

The package also contains a legacy model base class, BaseModelLiteMySQLi, under src/Model/. It is not a registered service and should not be confused with the current native Db API; it depends on the LiteMySQLi class, which is not declared as a runtime Composer requirement.

Testing

Use a source checkout, not only the installed Composer distribution, because tests/ is excluded from the package archive.

Run all included suites from the package root:

php tests/run.php

The runner discovers isolated tests/*/run.php suites and database-backed tests/*/database.php suites, running each in a separate PHP process. Individual tests can be run directly:

php tests/registry/run.php
php tests/secrets/run.php
php tests/log/run.php
php tests/txt/run.php
php tests/curl/run.php
php tests/mailer/run.php
php tests/slugger/run.php
php tests/value-to-sql/run.php
php tests/value-from-sql/run.php
php tests/db/database.php
php tests/brute-force/database.php

The database suites require a reachable test database server and suitable permissions. The test harness uses CITOMNI_TEST_PASSWORD for its database connection and CITOMNI_TEST_PARALLEL=1 where parallel worker coverage is desired. These tests exercise database creation and cleanup; never point them at a production database.

The suites cover behavior such as provider maps, secret validation, JSONL locking/rotation, translation resolution, cURL request and redaction semantics, mail transport behavior, the slug contract, value normalization, database transactions, and brute-force concurrency.

There is currently no composer test script declared in this package's composer.json; invoke the test runner explicitly.

Internal structure

The current source layout is organized by responsibility:

src/
├── Boot/
│   └── Registry.php
├── Enum/
│   └── TransactionIsolation.php
├── Exception/
│   ├── BruteForce*.php
│   ├── Curl*.php
│   ├── Db*.php
│   ├── Log*.php
│   ├── Mailer*.php
│   ├── SluggerConfigException.php
│   ├── TxtConfigException.php
│   └── Value*.php
├── Model/
│   └── BaseModelLiteMySQLi.php
├── Repository/
│   ├── BruteForceRepository.php
│   └── DatabaseSchemaRepository.php
└── Service/
    ├── BruteForce.php
    ├── Curl.php
    ├── Db.php
    ├── FormatNumber.php
    ├── Log.php
    ├── Mailer.php
    ├── Secrets.php
    ├── Slugger.php
    ├── Txt.php
    ├── ValueFromSql.php
    └── ValueToSql.php

install/
└── manifest.php                  # Secret-file scaffolding and runtime .gitignore rules
sql/
└── citomni_bruteforce.sql        # Schema dump; review before importing
language/
└── da/format_number.php          # Legacy format-number translations
tests/
└── ...                           # Isolated and DB-backed regression suites

The boundaries are deliberate:

  • Boot/Registry.php registers services and defaults for HTTP and CLI.
  • Service/ contains App-aware reusable capabilities, not application SQL repositories or transport adapters.
  • Repository/ owns SQL for the package's persistent brute-force state.
  • Enum/ provides a closed vocabulary for database isolation choices.
  • Exception/ gives callers stable, infrastructure-specific failure categories.
  • Model/ retains legacy compatibility code, separate from the current service interface.

Db is the generic database driver service; domain SQL written by consuming applications still belongs in their own Repository classes.

Coding and architectural principles

citomni/infrastructure follows the wider CitOmni contract:

  • PHP 8.5+.
  • PSR-4 autoloading.
  • PascalCase classes, camelCase methods and variables, UPPER_SNAKE_CASE constants.
  • Tab indentation and K&R braces.
  • English PHPDoc and inline comments.
  • Fail fast on invalid configuration and programmer misuse.
  • No HTTP-specific or CLI-specific behavior in shared infrastructure services.
  • SQL for persistent application features in Repositories.
  • No implicit service resolution outside the App service map.
  • No abstraction merely to conceal a straightforward native PHP operation.
  • Explicit resource ownership, bounded caches, and low runtime overhead.

Coding & Documentation Conventions

All CitOmni packages follow the shared conventions:

CitOmni Coding & Documentation Conventions

License

CitOmni Infrastructure is open-source under the MIT License.

See LICENSE.

Trademark notice: "CitOmni" and the CitOmni logo are trademarks of Lars Grove Mortensen. Usage of the name or logo must follow the policy in NOTICE. Do not imply endorsement or affiliation without prior written permission.

Trademarks

"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 NOTICE and TRADEMARKS.md.

Author

Developed by Lars Grove Mortensen (c) 2012-present.

CitOmni - low overhead, high performance, ready for anything.