citomni / infrastructure
Lean, cross-mode infrastructure for CitOmni apps: DB, logging, text/i18n, mail, cURL, brute-force protection, and URL slugs.
Requires
- php: ^8.5
- ext-curl: *
- ext-mysqli: *
- citomni/kernel: ^1.0.2.9
- phpmailer/phpmailer: ^7.1.1
Requires (Dev)
- phpstan/phpstan: ^1.11
Suggests
- ext-intl: Lets Mailer send to internationalized domain names, together with ext-mbstring.
- ext-mbstring: Lets Mailer send to internationalized domain names, together with ext-intl.
- ext-opcache: Better performance in production.
- citomni/authenticate: User accounts, login, registration, password lifecycle, MFA, trusted devices, and member profile flows.
- citomni/cli: CLI runtime for infrastructure-related commands, maintenance tasks, jobs, and developer tooling.
- citomni/http: HTTP runtime for request handling, views, and security integration.
Provides
None
Conflicts
None
Replaces
None
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,50into a precise SQLDECIMALstring? - 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-curlandext-mysqli. phpmailer/phpmailer^6.9.
Optional integrations and performance aids:
ext-intltogether withext-mbstringfor email addresses with an internationalized domain name, which PHPMailer then sends in punycode.ext-opcachefor production performance.citomni/http,citomni/cli, orcitomni/authenticateaccording to the host application's needs.- A reachable MySQL/MariaDB server for
dbandbruteForce. 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:
valueToSqlvalidates potentially untrusted input and produces storage-ready values.valueFromSqlvalidates 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_000bytes. - Default retention is ten rotated files per filename family;
nulldisables pruning. - Each file uses a separate
.locksidecar 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 throwsOutOfBoundsExceptionfor 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 = []);
appresolvesCITOMNI_APP_PATH/language/<language>/<file>.php.- A layer such as
vendor/packageresolvesCITOMNI_APP_PATH/vendor/vendor/package/language/<language>/<file>.php. $filemay contain safe relative subdirectories, for exampleemails/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
$defaultand 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 whileslug.transliterateis on, replaces each run of disallowed characters with the separator, collapses and trims separators, lowercases ASCII letters and shortens the result toslug.max_lengthbytes. It does not check the reserved slugs.normalize()does the same without transliterating or shortening, for comparing a slug that someone typed.validate()throws\InvalidArgumentExceptionfor a slug that breaks the policy, also for a reserved one.ensureUnique()tries the base and thenbase-2,base-3and 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->sluggerneeds no change. - Move an override of
http.slugtoslug, and renamemaxLengthtomax_lengthandallowedPatterntodisallowed_pattern; the same goes for options in a service map entry. The keys ofhttp.slughave no effect anymore. - Transliteration no longer goes through iconv, whose result depended on the process locale:
"Ærø"becameaerunderLC_CTYPE=CandaerounderC.UTF-8. New slugs follow the map above. Stored slugs do not change. - A slug policy value that is not valid throws
SluggerConfigExceptionwith the cfg path, where the previous slugger ignored some values: a non-boollowercaseortransliterate, 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 theumodifier makesslugify(),normalize()andvalidate()throw\InvalidArgumentExceptionfor 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 namepairs. An address thatto,cc, andbccname 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-intlandext-mbstring,send()refuses an address with a non-ASCII domain withInvalidArgumentException. - 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
eaivalidator instead ofphp, whatever their order. - One of
htmlandtextis required. Withhtmlalone, the plain-text alternative is generated: tags dropped together with the content ofhead,title,style, andscript, links written astext (URL). An HTML body without text, such as a single image, gets no alternative. Passtextto write it yourself. fromreplacesmail.fromfor one message; with SMTP, it is also the envelope sender.replyToreplacesmail.reply_to;replyTo: []sends none.attachmentstakes 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:
- An identifier, such as a username or email address.
- 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 BYfragments,WHEREfragments, 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_keysis 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.
Dbdoes not connect until it is used and reuses prepared statements within its bounded cache.Db::fetchValue(),fetchRow(), andfetchAll()release their results; manual streaming remains explicit.ValueToSqlandValueFromSqlnormalize typed values without hidden database queries or locale globals.Secretsloads one small file once per App instance, with no network dependency.Txtcaches loaded PHP language files per service instance.Curlmay reuse its cURL connection/DNS state within one process.Logwrites append-only records and isolates contention with per-file locks.Mailerbuilds 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.BruteForceuses bounded counter rows rather than an ever-growing history of individual attempts.Sluggertransliterates 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.
Dbtargets 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.
Txthas no automatic language fallback chain and no HTML escaping.- The
Txtdiagnostic file name is fixed totxt.jsonl. Curlhas no automatic JSON body handling, retries, or SSRF protection for user-supplied URLs.Mailerhas 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 tomailer.jsonl.BruteForcerequires a separately installed table and application-owned maintenance scheduling.formatNumberis 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.phpregisters 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.