Canonical, vendor-neutral guide for AI coding agents (Claude Code, Cursor, Aider, Copilot, etc.) working on the Univeros / Altair Framework.
This file is the source of truth. Tool-specific entry points (CLAUDE.md) point here.
- Name:
univeros/framework, PHP framework, MIT licensed. - Root namespace:
Altair\*(legacy reasons; the Composer package isuniveros/*and 40 sub-packages are bundled viareplace). - Origin: Started ~7 years ago as a learning vehicle. Originally targeted PHP 7.0-7.2 and PSR-3/6/7/11/15/16 v1.
- Current target: PHP 8.3+ (modernization started 2026-05).
- Architecture style: Library-first / framework-agnostic; every sub-package is meant to be usable standalone behind PSR interfaces.
.
├── .github/workflows/ci.yml ← CI (PHP 8.3 + 8.4 matrix, PHPStan, CS-Fixer, Rector, Codecov)
├── .php-cs-fixer.dist.php ← PHP-CS-Fixer v3 config (@PER-CS2.0, @PHP83Migration)
├── phpunit.xml.dist ← PHPUnit 12 config
├── phpstan.neon.dist ← PHPStan config (level 8, no baseline)
├── rector.php ← Rector config (PHP 8.3 + code-quality + dead-code sets)
├── composer.json ← Root manifest (monorepo via `replace`)
├── src/Altair/
│ ├── AgentSpec/ ← AI-readable manifests describing every package + the host app (.agent/, manifest:generate)
│ ├── Bootstrap/ ← bin/altair new — materialises a runnable API from the skeleton template
│ ├── Cache/ ← PSR-6 + PSR-16 cache; filesystem/Redis/Predis/Memcached backends
│ ├── Cli/ ← Attribute-driven CLI on top of Symfony Console
│ ├── Common/ ← Pure-PHP utilities (string/array helpers, key-value registry) shared everywhere
│ ├── Configuration/ ← Container-aware config; loads .env (phpdotenv 5) + wires bindings
│ ├── Container/ ← Reflection-backed PSR-11 DI: auto-wiring, contextual bindings, tags, decorators, scopes
│ ├── Cookie/ ← Immutable HTTP cookie value objects + PSR-7 read/write manager
│ ├── Courier/ ← Synchronous command bus routing immutable messages to handlers via middleware
│ ├── Data/ ← Immutable-by-default data objects: JSON/Serializable, Carbon date mutators
│ ├── Doctor/ ← bin/altair doctor — health checks; agent-actionable JSON + human text
│ ├── Eval/ ← bin/altair eval — sandboxed PHP-snippet runner inside the project container
│ ├── Events/ ← Append-only mutation event log (.altair/events.jsonl) — session memory; events:*
│ ├── Examples/ ← Curated idiomatic-pattern library + CLI/MCP discovery tools
│ ├── Filesystem/ ← Flysystem v3 wrapper: local/S3/SFTP/FTP/Dropbox behind one API
│ ├── Happen/ ← PSR-14 event dispatcher: priorities, subscribers, wildcards, batch dispatch
│ ├── Http/ ← PSR-15 stack: Action/Domain/Input/Responder, FastRoute, content negotiation, JWT
│ ├── Idempotency/ ← Stripe-style Idempotency-Key primitive: storage contract + adapters
│ ├── Index/ ← bin/altair index — AST+spec symbol index (find-usages, callers, dead-code); SQLite, JSON
│ ├── Introspection/ ← "What's wired right now?" inspectors: container/routes/listeners/middleware/specs/config
│ ├── Logging/ ← PSR-3 logging backed by Monolog, wired from LOG_*; JSON-lines to stderr by default
│ ├── Mcp/ ← Model Context Protocol server exposing the framework as MCP tools for agents
│ ├── Messaging/ ← MessageBus + worker bridge over Symfony Messenger; scaffold queue: block
│ ├── Middleware/ ← Generic typed-Payload pipeline driven by a Runner (NOT PSR-15 HTTP middleware)
│ ├── MigrationIntelligence/ ← bin/altair db:migration-plan — safe Cycle migration plans from spec/entity diffs
│ ├── Module/ ← Pluggable extensions: one class self-registers routes/entities/migrations (module:new)
│ ├── Observability/ ← observability:* — native OTel-compatible spans/metrics (OTLP-JSON) + per-request middleware
│ ├── Observatory/ ← Dev-only web monitoring panel over introspection/doctor/events/messaging/persistence
│ ├── Persistence/ ← Repository + UnitOfWork over Cycle ORM v2 + migration CLI (db:migrate*)
│ ├── Profiling/ ← bin/altair profile — sampling profiler (excimer/xdebug): call tree, flamegraph, diff
│ ├── Sanitation/ ← Composable input sanitation: untrusted input → safe canonical form
│ ├── Scaffold/ ← YAML-spec-to-code: Action/Input/Responder + OpenAPI + tests; journal, SDK emitters, openapi:import
│ ├── Security/ ← Crypto primitives: key derivation (HKDF/PBKDF2), symmetric encryption, timing-safe MAC
│ ├── Session/ ← Server-side sessions: file, MongoDB, PDO (MySQL/Postgres/SQLite), Redis handlers
│ ├── Structure/ ← Typed structures (Map, Set, Vector, Deque, Queue, Stack, PriorityQueue) in pure PHP
│ ├── Suggest/ ← bin/altair suggest — proposes refactors (dead bindings, fat ctors, routes w/o specs)
│ ├── TestReporter/ ← AI-native PHPUnit reporter: JSON failures mapped to source-under-test
│ ├── Tinker/ ← bin/altair tinker — PsySH REPL with the container in scope (dev tool, not an agent surface)
│ ├── Validation/ ← Rule-based input validation — the gatekeeping counterpart to Sanitation
│ └── Webhooks/ ← Signing, inbound-verify middleware, outbound dispatcher (retry/dead-letter/replay)
└── tests/ ← Mirrors `src/Altair` layout. Suffix `Test.php`. Fixtures: `tests/{pkg}/fixtures.php`.
Each sub-package has its own composer.json (so split repos remain valid). The root composer.json declares them in the replace section so they're satisfied by this monorepo when consumed together.
All commands run from the repository root. Composer scripts wrap the binaries; prefer them.
composer install # install deps
composer update -W # bump deps within constraints
composer test # vendor/bin/phpunit
composer test:coverage # phpunit + HTML coverage in build/coverage
composer cs # PHP-CS-Fixer dry-run with diff
composer cs:fix # apply fixer
composer stan # PHPStan
composer rector # Rector dry-run
composer rector:fix # apply Rector
composer qa # cs + stan + test (the pre-merge gate)CI runs the same scripts on every push/PR (see .github/workflows/ci.yml).
PHP: >=8.3. We use 8.3 features (typed class constants, json_validate, #[\Override], readonly classes, enums, first-class callables, match, nullsafe, intersection/DNF types).
Core runtime deps (current majors):
| Package | Version | Used by / notes |
|---|---|---|
psr/log ^3 · psr/container ^2 · psr/cache ^3 · psr/simple-cache ^3 · psr/clock ^1 · psr/event-dispatcher ^1 |
PSR interfaces the framework implements | |
psr/http-message ^2 · psr/http-factory ^1.1 · psr/http-server-middleware / psr/http-server-handler ^1 |
PSR-7 + PSR-15 (single-pass) | |
laminas/laminas-diactoros |
^3.5 | PSR-7 implementation (replaced abandoned zend-diactoros) |
relay/relay |
^2.1 | PSR-15 single-pass dispatcher |
nikic/fast-route |
^1.3 | HTTP routing |
neomerx/cors-psr7 ^3 · willdurand/negotiation ^3.1 |
CORS + content negotiation | |
lcobucci/jwt |
^5.3 | HTTP JWT authentication |
monolog/monolog |
^3 | backs univeros/logging (PSR-3) |
symfony/console |
^7 | the bin/altair CLI (univeros/cli) |
symfony/messenger |
^7 | univeros/messaging bus + worker |
symfony/serializer · symfony/uid · symfony/yaml |
^7 | serialization · ULIDs (events/journal) · spec parsing |
cycle/orm + cycle/database cycle/migrations cycle/annotated cycle/schema-builder |
^2 / ^4 | univeros/persistence ORM bridge + migrations |
nikic/php-parser ^5 · spiral/tokenizer ^3.13 |
AST parsing for the scaffold drift linter + univeros/index |
|
opis/json-schema |
^2.4 | OpenAPI / spec schema validation |
vlucas/phpdotenv |
^5.6 | .env loading |
nesbot/carbon |
^3.8 | date mutators in univeros/data |
league/flysystem |
^3.29 | univeros/filesystem (v3) |
Dev tooling:
| Tool | Version | Notes |
|---|---|---|
phpunit/phpunit |
^11.4 | |
phpstan/phpstan |
^2.1 | level 8, no baseline |
rector/rector |
^2.0 | dry-run in CI's static-analysis job, not in composer qa |
friendsofphp/php-cs-fixer |
^3.64 | @PER-CS2.0 + @PHP83Migration |
squizlabs/php_codesniffer |
^3.10 | |
psy/psysh |
^0.12 | powers bin/altair tinker |
roave/security-advisories |
dev-latest |
Optional test deps gated behind extensions/services (skipped when absent): mongodb/mongodb, predis/predis, pda/pheanstalk, the league/flysystem-* adapters, and ext-{mongodb,redis,memcached,intl,openssl}.
Conventions below are required for any new or modified file.
Every PHP file starts with:
<?php
declare(strict_types=1);
/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Altair\<Subpackage>;declare(strict_types=1) is mandatory; 100% of source files already comply.
- PER-CS 2.0 (PHP-CS-Fixer
@PER-CS2.0+@PHP83Migration). - 4-space indent, LF line endings, no trailing whitespace (enforced by
.editorconfig). - Short array syntax
[]. Imports ordered. No uselesselse/return. Ordered class elements. - Use native types for parameters/return/properties. Use PHPDoc only for collections (
array<int, Foo>) or unions PHP can't express natively.
New objects, never mutate. Value objects use readonly properties (or readonly class). Methods that "change" a value return a new instance (withFoo()). See Altair\Cookie\Cookie for the canonical pattern.
- Files: 200-400 LOC typical, 800 hard cap.
- Functions: under 50 LOC. Extract helpers freely.
- Nesting: max 4 levels. Use early returns / guard clauses.
- Organize by feature/sub-package, not by type.
- Throw typed exceptions from
Altair\<Subpackage>\Exception\*. - Never silently swallow errors. Log on the server side with full context.
- Validate at boundaries (HTTP request, env vars, file contents). Trust internal calls.
- Interfaces live in
Contracts/and end inInterface(CacheItemStorageInterface). Yes, this is unusual; keep it consistent with existing code. - Traits live in
Traits/and end inTrait. - Exceptions live in
Exception/. - Configuration classes live in
Configuration/and implementConfigurationInterface. - Factories live in
Factory/and end inFactory.
- Don't add backwards-compat shims for PHP < 8.3.
- Don't reintroduce
Zend\Diactoros\*; useLaminas\Diactoros\*. - Don't use
relay/middlewareor double-pass middleware; Relay 2 is PSR-15 single-pass. - Don't add Flysystem v1 adapters (Rackspace, GridFs, ZipArchive, WebDAV, cached-adapter; removed in v3).
- Don't write
@var/@param/@returnwhen a native type works. PHPDoc supplements types, doesn't replace them.
Determinism is a first-class quality, not a nice-to-have. Any command or emitter that writes files or produces --format=json MUST be byte-stable: the same inputs produce byte-identical output across runs, machines, and PHP minor versions. Agents read diff as signal; non-deterministic output looks like drift and triggers phantom "fixes".
The standard: a deterministic emitter has:
- Byte-identical output: same content, same LF line endings, same trailing newline.
- Stable ordering: sort by a stable key (name, numeric id) before emitting anywhere you iterate a map/set, and
->sortByName()on everyFinder/scandirused for code generation (FS order is inode-dependent). - No wall-clock timestamps inside emitted content, except a single explicit
generated_at/duration_msfield where one is genuinely useful. - No machine identifiers: no hostname, username, or absolute paths.
- No nondeterministic randomness: derive any UUID from the spec SHA (
uuid5), neverrandom_*.
Enforcement is layered and live:
- In-process tests:
tests/Determinism/TwiceHarness.phpis the shared harness, andtests/AgentSpec/Determinism/,tests/Scaffold/Determinism/ship "run twice, diff empty" tests for the manifest pipeline, the OpenAPI fragment merger, and the TypeScript + Python SDK emitters. Add one anywhere you add a new emitter. - CI determinism gate:
.github/workflows/ci.ymlregenerates.agent/andgit diff --exit-codes it on every PR. A non-deterministic emitter fails the gate. bin/altair doctor: thedeterminism_checkfromuniveros/doctorruns the same regenerate-and-diff gate against any generators a host configures (seeDoctorConfiguration's constructor).- Skeleton workflow:
src/Altair/Bootstrap/resources/skeleton/.github/workflows/determinism.ymlships withbin/altair new, so generated host projects inherit the gate.
Any new package added to the framework must meet this standard. Tracked in #74.
- Framework: PHPUnit 12, attribute-style (
#[DataProvider],#[Group],#[Test]). - Bootstrap:
tests/bootstrap.php. Per-package fixtures intests/{Container,Courier,Sanitation,Validation}/fixtures.php. - Suffix
Test.php, mirror thesrc/Altair/<Pkg>layout undertests/<Pkg>/. - Coverage target: 80%+ for new code (unit + integration).
vendor/bin/phpunit # full suite (canonical gate)
vendor/bin/phpunit --filter CookieManagerTest # one test class
composer test:par # parallel run via paratest — fast local loopSome packages talk to a real service (Redis, MongoDB, Memcached, Beanstalk, a SQL database). Those tests resolve their endpoint in this order, and skip gracefully when none is available, so composer test stays green and Docker-free on a bare machine:
- an explicit env endpoint (e.g.
REDIS_HOST/REDIS_PORT); - a service already listening on the conventional local port (a CI service container, or a locally-running server), reused as-is;
- a throwaway container booted via the
dockerCLI (tests/Support/Integration/DockerContainer.php), torn down on shutdown; - otherwise the test is skipped.
So with Docker running you get the integration coverage locally with no global service install and no PHP client extension, provided the client is pure-PHP (predis/predis, pda/pheanstalk, PDO). Tests using ext-redis / ext-mongodb / ext-memcached still need that extension loaded in the CLI runtime even with a container, so they skip without it. PredisCacheItemStorageTest is the reference port; resolve new ones through a small per-service helper like RedisServer.
We deliberately do not use testcontainers-php: its Docker client requires
psr/http-message ^2, which conflicts with this tree'srelay/relay ~1.0andneomerx/cors-psr7 ^1.0. The dependency-freedocker-CLI helper does the same job.
paratest runs the suite across CPU cores for a fast local loop. composer test (single-process) remains the canonical gate (CI uses it): a few suites that share an on-disk fixture path (tests/Cache/tmp for the filesystem cache, the events log under Events\ReaderTest) are not yet isolated per worker, so they can collide under test:par. Making every service/file-backed test parallel-safe (per-worker container / per-test keyspace, DB name, or temp dir keyed off TEST_TOKEN) is tracked in #129.
The migration from PHP 7.2 / abandoned deps to PHP 8.3 is complete. All phases (1, 2, 3a–3d, 4) are done. The tree targets PHP 8.3+, runs a PHP 8.3 + 8.4 CI matrix, and passes PHPStan level 8 with no baseline. The section below is kept as a record of what each phase changed; there is no outstanding modernization work. New work is tracked in the open issues, not here.
- Composer manifest rewritten (root + 16 sub-packages),
,,syntax error fixed. - Dep majors bumped (see §4). PHP floor
>=8.3. Zend\Diactoros\*→Laminas\Diactoros\*swapped in 8 files..travis.yml→.github/workflows/ci.yml(8.3 + 8.4 matrix)..php_cs→.php-cs-fixer.dist.php(v3 format, PER-CS 2.0).phpunit.xml.dist→ PHPUnit 12 schema.phpstan.neon.dist+rector.phpadded..pre-commit-config.yamlupdated..gitignorecovers new tool caches.
composer rector:fix has been applied across the codebase; rector process --dry-run is green on master and runs in CI's static-analysis job (note: it is not part of composer qa, so run it manually before pushing). All the configured sets are satisfied: constructor promotion, native types from PHPDoc, match, nullsafe/??=/first-class-callables/str_contains, dead-code/early-return/instanceof simplifications, and PRIVATIZATION.
PHP 8.4's ExplicitNullableParamTypeRector was added (target lifted to 8.4 to arm it) so implicit-nullable params (Type $x = null) stay fixed going forward; see #93.
One rule is deliberately skipped in rector.php: NewInInitializerRector. Collapsing ?? new X() bodies into promoted = new X() defaults narrows public constructor contracts (an explicit null arg becomes a TypeError); declined as a needless BC change. Remove the skip to adopt it intentionally.
All Altair\Http\Middleware\* classes migrated:
Altair\Http\Contracts\MiddlewareInterfacenowextends Psr\Http\Server\MiddlewareInterface; keeps theATTRIBUTE_*typed class constants (PHP 8.3).- All 14 middleware implement
process(ServerRequestInterface, RequestHandlerInterface): ResponseInterface. Short-circuiting middleware receiveResponseFactoryInterfacevia constructor injection. relay/middlewarev1 adapters (AbstractContentHandlerMiddleware,FormContentMiddleware,JsonContentMiddleware) are reimplemented inline; the package is gone in Relay 2.Altair\Http\Resolver\ContainerResolverno longer implements the removedRelay\ResolverInterface; it's a plain__invoke(object|string): objectcallable, which is what Relay 2 accepts.Altair\Http\Configuration\RelayConfigurationusesnew Relay($queue->toArray(), $resolver)(Relay 2 ctor).tests/Http/Middleware/AbstractMiddlewareTest::dispatch()builds a PSR-15 pipeline usingRelay\Relay::handle()with an anonymous terminal handler.Altair\Http\Responder\{Compound,Formatted}Respondertypehint the resolver ascallableinstead ofRelay\ResolverInterface.- Bug fix as part of migration:
CsrfMiddlewarepreviously returned 403 on valid tokens (if ($isPost && validate), inverted). Corrected toif ($unsafeMethod && !validate).
Decorator middleware that depend on the next response (CORS, cache headers) must now run before terminal middleware (ActionMiddleware) in the queue so they wrap the response that bubbles back up.
Altair\Configuration\EnvironmentConfigurationnow usesDotenv\Dotenv::createImmutable($dir, $file)(orcreateMutablewhen constructed with$immutable = false). TheDotenv\Loaderclass is internal in v5 and no longer used directly.tests/Configuration/EnvironmentConfigurationTestmigrated from PHPUnit 7 annotation-based exception expectations (@expectedException,@expectedExceptionMessageRegExp) to method-based (expectException(),expectExceptionMessageMatches()).
- Deleted configurations for adapters removed in Flysystem v3: Rackspace, Azure, WebDAV, ZipArchive, GridFs.
- Rewrote the survivors against v3 adapter constructors:
LocalFilesystemAdapter,FtpAdapter(usesFtpConnectionOptions::fromArray),AwsS3V3Adapter,SftpAdapter(usesSftpConnectionProvider), SpatieDropboxAdapter. Altair\Filesystem\Contracts\FilesystemAdapterInterfacenow extendsLeague\Flysystem\FilesystemOperator(wasFilesystemInterfacein v1).Altair\Filesystem\Adapter\FlysystemAdapterrewritten as a thin explicit decorator (no more magic__call) wrapping aFilesystemOperator. All v3 methods are forwarded;exists/prepend/append/listDirectoriesrewritten against v3's StorageAttributes/DirectoryListing iterables.FilesystemAdapterConfigurationno longer wires aCachedAdapter; Flysystem v3 removed caching from core. Wrap with a caching decorator separately if needed.- The
FlysystemAdapter.phpexclusion from PHPUnit coverage, PHPStan, and Rector is removed; the new implementation is testable.
- Value objects →
readonly: done. - Sentinel
class const→ enums: done. - Rector-driven cleanup of PHPDoc-only types: done (
TYPE_DECLARATIONset applied;rector --dry-runclean). Altair\Happen\*PSR-14: done (#97): the dispatcher/provider now implement PSR-14's object-based interfaces alongside the original name-based API.
- PHPStan level 5 → 8: done (#96). The tree analyses at level 8 with no
phpstan-baseline.neon; the original 746-error baseline was fully burned down. The only remaining suppressions are a handful of inlineignoreErrorsinphpstan.neon.dist, each with a comment explaining why (optional ext-* stubs, intentional collection-trait variance, etc.). Keep it that way: fix at root cause, and never reintroduce a baseline. - PHPUnit annotation → attribute migration: done. The suite uses
#[DataProvider]/#[CoversClass]; the only remaining@coversistests/TestReporter/Fixtures/LegacyCoversAnnotationTest.php, an intentional fixture exercising the test-reporter's legacy-annotation fallback. - Per-sub-package coverage ≥ 80% remains a standing quality goal (not a migration blocker).
- Adding a feature inside an existing sub-package: read that package's
Contracts/, then its concrete classes. Configurations live inConfiguration/. - Adding a new sub-package: mirror the layout of
src/Altair/Structure/(smallest, cleanest). Add acomposer.json, register in rootreplace, add an autoload entry. - Touching HTTP/middleware: PSR-15 single-pass only. Implement
Psr\Http\Server\MiddlewareInterface. - Touching cache: PSR-6 (
CacheItemPoolInterface) is the primary contract; PSR-16 is a façade over it. - Adding HTTP endpoints: write a YAML spec under
api/and runbin/altair spec:scaffold api/<file>.yaml. The generator emits Action / Input / Responder / domain stub / test / OpenAPI fragment / route entry. Hand-edits to these files surface viabin/altair spec:lint.
Run locally (or in CI):
composer qa # cs + stan + testIf Rector or PHPStan find new issues your change introduced, fix at root cause; don't add ignoreErrors entries without justification in a comment.
- Env-/service-backed tests only run where the extension or service exists. Tests needing
ext-mongodb,ext-redis,ext-memcached,ext-apcu,ext-excimer, or a live database skip (or error insetUp) on a machine without them; the SDK compile tests needtsc/mypy. A green run with those skipped is expected; don't read the skips as failures. Parallel + container-backed infra to make them runnable everywhere is tracked in #129. - Rector is a CI gate but is NOT in
composer qa. CI'sstatic-analysisjob runsrector process --dry-runover the whole tree (PHP 8.3);composer qais only cs + stan + test. Runcomposer rectormanually before pushing or CI will catch drift you didn't see locally. composer.lockis gitignored.composer installregenerates it; don't commit one.
- PSR specs: https://www.php-fig.org/psr/
- PHP 8.3 release notes: https://www.php.net/releases/8.3/en.php
- Rector docs: https://getrector.com/
- PHPStan rule levels: https://phpstan.org/user-guide/rule-levels
- PHPUnit 12 attributes: https://docs.phpunit.de/en/12.5/attributes.html
- Flysystem v3 migration: https://flysystem.thephpleague.com/docs/upgrade-from-1.x/
- Laminas Diactoros (PSR-7): https://docs.laminas.dev/laminas-diactoros/
- Relay 2 (PSR-15): https://github.com/relayphp/Relay.Relay
- Dotenv 5: https://github.com/vlucas/phpdotenv#usage