Clean Framework · DOCUMENTATION
Database and migrations
Database and migrations
Bounded candidate reference — 2026-10-20: 0.2.0-rc.1 is committed locally through 84f415b3098af5653577b2c65eea31fca21df79c, unpublished and not production-approved; stable PHP >=8.5.11 <8.6.0 is required. This is documentation, not adoption: this knowledge base retains its installed 0.1.0-rc.1 dependency and PHP 8.2.11 local profile. Dated PHP 8.2 evidence is historical; earlier dirty-source reports retain their original test provenance, not retroactive clean-commit proof. Candidate examples are illustrative: API signatures were reviewed, but candidate code was not executed by this editorial update. See current checkpoint, open criteria and adoption boundary.
Database boundary
Connection exposes value-bound execute, immutable query results, fetchOne, and fetchAll, plus explicit transaction state, transaction ownership, and health checks. Historical one, all, begin, and rollback names remain as compatibility aliases. MysqliConnection connects lazily, enables strict mysqli errors, applies the configured charset and fixed-offset session time zone, binds every value, and converts driver failures to safe DatabaseException metadata.
Values must always be parameters:
$row = $connection->one(
'SELECT id, title FROM records WHERE id = ?',
[$id]
);SQL identifiers cannot be parameterized. Dynamic table/column/order identifiers must be selected from a closed application-owned allowlist using SqlIdentifier; never pass raw request input as an identifier. Persistence stores canonical values and does not HTML-escape them.
Parameter typing is deterministic: booleans and integers bind as integers, floats bind as doubles, decimal values remain strings, null binds as a nullable string, and stream resources bind as binary data from their current position. Arrays, objects, and non-stream resources are rejected before a connection is opened. Successful query logs contain only operation category, duration, and row count; SQL text and parameter values are never logged.
Use TransactionManager::run() or explicit connection methods when one use case owns a transaction. Nested transactions fail by default. A caller may select TransactionOptions::NESTED_JOIN only when an inner operation should join the owner's transaction without committing or rolling it back. Savepoints are not implied. Framework bootstrap, views, and destructors do not start transactions.
Connection::health() performs a non-destructive SELECT 1 readiness probe and reports safe status, latency, and failure category. It is not a liveness probe and should not be called for every request.
Migration files
Each application migration file returns one Migration object with a stable ID, a transactional flag, and up(Connection $connection). New migrations should also implement NamedMigration; existing files remain compatible and receive a deterministic display name derived from the ID. A migration can implement CapabilityAwareMigration to declare optional minimum/maximum database capability labels that appear in planning/readiness output:
return new class implements Migration, NamedMigration {
public function id(): string { return '20261002000000_create_records'; }
public function name(): string { return 'Create records'; }
public function transactional(): bool { return false; }
public function up(Connection $db): void
{
$db->execute('CREATE TABLE records (id BIGINT UNSIGNED PRIMARY KEY)');
}
};Files are discovered lexically and then sorted by migration ID. IDs must be unique. Applied entries retain SHA-256 checksums; changing an applied file is a hard error. Applied ledger IDs missing from source are also treated as drift. Correct a released migration with a new migration.
MigrationDescriptor retains the source path, runtime class, checksum, name, transaction declaration, and optional capabilities. The configurable ledger defaults to cf_migrations and records migration_id, name, checksum_sha256, batch_id, transactional, UTC-microsecond applied_at, duration_ms, and safe applied_by identity. Ledger, lock, and actor values come from MIGRATION_LEDGER_TABLE, MIGRATION_LOCK_NAME, and MIGRATION_APPLIED_BY; identifiers are validated before SQL is built.
Commands and safety
migrate:statusreads discovered/applied state.migrate:plan [--to=<id>]prints applied, pending, outside-target, transaction, and declared-capability state.migrate:verifyrejects checksum drift and applied IDs missing from source, then reports bounded readiness counts without mutation.migrateacquires a database lock, creates the ledger if needed, validates all checksums, applies pending migrations once, and records batch/duration.migrate --to=<id>stops after the inclusive forward target. A target before an already-applied migration is rejected and never means rollback.migrate --dry-runverifies and counts the selected plan without acquiring the migration advisory lock or changing database state. Actual apply uses a bounded lock timeout and refuses more migrations than the console batch policy permits before creating/changing the ledger.
Transactional migrations roll back on failure. MySQL DDL often commits implicitly, so DDL migrations should normally report transactional(): false; plans, verification, and application output then show a prominent warning that automatic rollback is not guaranteed. Recovery remains a matched code/database restore or a deliberate forward corrective migration—not a destructive down assumption.
In PROD, MigrateCommand requires exact --confirm-environment=PROD and an opaque --backup-token=<token> accepted by an application/deployment-owned BackupCheckpointVerifier. The neutral skeleton binds a rejecting verifier, so production migration is fail-closed until real recent-checkpoint tooling is composed. The framework never prints or stores the token. Deployment tooling should avoid interactive shell history and process-list exposure when passing it.
Deterministic fake-based tests cover fresh/target/latest application, no-op replay, complete ledger records, checksum and orphan drift, lock denial, transaction rollback, non-transactional warnings, verification, and PROD checkpoint authorization. Real MySQL 8.4.11 tests now additionally cover scoped process contention, migration/failure and fixed-table synthetic restore. MariaDB, whole-app recovery and queue-process acceptance remain TO VERIFY; see the actual checkpoint. Never point framework tests at production.
The optional integration script now requires TEST mode, explicit mutation acknowledgement, an exact fixture-schema pattern, numeric loopback host, port and TEST-only credentials before Composer or a connection can load. A name prefix is not proof of disposability: an operator must supply the authorized throwaway schema. See the runbook.
APP_ENV=TEST CLEAN_TEST_DB_ALLOW_MUTATION=YES \
CLEAN_TEST_DB_NAME=clean_framework_test \
CLEAN_TEST_DB_HOST=127.0.0.1 \
CLEAN_TEST_DB_PORT=3306 \
CLEAN_TEST_DB_USERNAME=test_runner \
CLEAN_TEST_DB_PASSWORD='<local-test-password>' \
php tests/mysql_integration.phpThe basic script creates/removes only fixed fixture tables/ledgers inside its dedicated database. Supplying a different guarded CLEAN_TEST_DB_RESTORE_NAME enables the expanded exercises and a fixed synthetic table in that second schema. Without that explicit target, expanded checks print NOT_RUN rather than masquerading as completed. A basic pass proves typed prepared round trips (including a large streamed binary), fixed-offset session time zone, health and transaction state, rollback, ledger storage, and advisory locking for the local server/version.
Editorial provenance
Source: docs/DATABASE_AND_MIGRATIONS.md. Source SHA-256: afc529876407b0da412d1ac27c5442da9b31a5e84800e0b4a709221c1cd918e9.
Framework candidate reference; editorial checkpoint 2026-10-20. See candidate status and adoption boundary.