preflow / data
Preflow data — storage drivers, models, migrations
Requires
- php: >=8.4
- ext-pdo: *
- ext-pdo_sqlite: *
- preflow/core: ^0.58 || @dev
- preflow/validation: ^0.52 || @dev
Requires (Dev)
- phpunit/phpunit: ^11.0
- preflow/testing: ^0.64 || @dev
Suggests
None
Provides
None
Conflicts
- preflow/core: <0.58
- preflow/folio: <0.67
- preflow/publishing: <0.72
- preflow/validation: <0.52
Replaces
None
- dev-main
- v0.76.0
- v0.75.0
- v0.74.0
- v0.73.0
- v0.72.1
- v0.72.0
- v0.71.1
- v0.71.0
- v0.70.0
- v0.69.2
- v0.69.1
- v0.69.0
- v0.68.1
- v0.68.0
- v0.67.0
- v0.66.2
- v0.66.1
- v0.66.0
- v0.65.0
- v0.64.0
- v0.63.2
- v0.63.1
- v0.63.0
- v0.62.0
- v0.61.0
- v0.60.1
- v0.60.0
- v0.59.0
- v0.58.1
- v0.58.0
- v0.57.0
- v0.56.1
- v0.56.0
- v0.55.0
- v0.54.1
- v0.54.0
- v0.53.2
- v0.53.1
- v0.53.0
- v0.52.0
- v0.51.0
- v0.50.0
- v0.49.1
- v0.49.0
- v0.48.5
- v0.48.4
- v0.48.3
- v0.48.2
- v0.48.1
- v0.48.0
- v0.47.1
- v0.47.0
- v0.46.1
- v0.46.0
- v0.45.0
- v0.44.0
- v0.43.0
- v0.42.1
- v0.42.0
- v0.41.2
- v0.41.1
- v0.41.0
- v0.40.0
- v0.39.0
- v0.38.0
- v0.37.0
- v0.36.0
- v0.35.0
- v0.34.1
- v0.34.0
- v0.33.0
- v0.32.0
- v0.31.0
- v0.30.0
- v0.29.0
- v0.28.0
- v0.27.1
- v0.27.0
- v0.26.0
- v0.25.1
- v0.25.0
- v0.24.1
- v0.24.0
- v0.23.0
- v0.22.1
- v0.22.0
- v0.21.0
- v0.20.0
- v0.19.2
- v0.19.1
- v0.19.0
- v0.18.1
- v0.18.0
- v0.17.2
- v0.17.1
- v0.17.0
- v0.16.2
- v0.16.1
- v0.16.0
- v0.15.0
- v0.14.2
- v0.14.1
- v0.14.0
- v0.13.1
- v0.13.0
- v0.12.0
- v0.11.4
- v0.11.3
- v0.11.2
- v0.11.1
- v0.11.0
- v0.10.4
- v0.10.3
- v0.10.2
- v0.10.1
- v0.10.0
- v0.9.1
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.0
- v0.4.1
- v0.4.0
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- v0.1.1
- v0.1.0
This package is auto-updated.
Last update: 2026-11-14 19:29:44 UTC
README
Multi-storage data layer for Preflow. Supports JSON files, SQLite and MySQL/MariaDB; different models can use different backends simultaneously.
Installation
composer require preflow/data
Requires PHP 8.4+, ext-pdo, and ext-pdo_sqlite or ext-pdo_mysql for the SQL storage you use.
What it does
Models are annotated with PHP attributes. DataManager is the single entry point — it reads model metadata via reflection, selects the right storage driver, and returns typed results. QueryBuilder provides a fluent API for filtering, sorting, searching, and pagination. Migrations are handled by Schema + Table + Migrator.
API
Attributes
| Attribute | Target | Description |
|---|---|---|
#[Entity(table: 'posts', storage: 'sqlite')] |
Class | Maps model to a table/collection and a named driver. storage defaults to 'default'. |
#[Id] |
Property | Marks the primary key field. Works on any property — use string $uuid for UUIDs or int $id for auto-increment. |
#[Field(searchable: true)] |
Property | Marks a field; searchable: true includes it in full-text search. unique: true (or unique: ['scope', ...]), index: true and length: n are described in Unique fields and indexes. |
#[Index('state', 'updated_at')] |
Class (repeatable) | A composite index over fields of a type (#[Entity(type: …)]), in that order; see Unique fields and indexes. |
#[Timestamps] |
Class or property | save(), insert() and update() of a plain model set createdAt or created_at (the first the model has) when it is empty and updatedAt or updated_at on every write, matching the migration's $t->timestamps(). A ?string property gets Y-m-d H:i:s text, an ?int one the Unix time; a ?\DateTimeImmutable one is stored as that text. Other types (a mutable \DateTime too) are not stamped. A class-backed type (#[Entity(type: …)]) is not stamped: it stores what its type definition says, so it sets these values itself or declares them as type fields. |
Model
$model->fill(array $data): void // hydrate from associative array $model->toArray(): array // export public properties
A bool (or ?bool) property reads what SQL stores for it: 1/0 and '1'/'0' (SQLite and
MySQL have no boolean type) become true/false in fill(), and null stays null, and an empty value is null for a ?bool (false for a bool) (since 0.65;
before, such a property threw a TypeError on a SQL storage).
DataManager
$dm->find(Post::class, $id): ?Post $dm->query(Post::class): QueryBuilder<Post> $dm->save(Model $model): void // INSERT when ID is empty, UPDATE otherwise $dm->insert(Model $model): void // explicit INSERT; an int id reads back lastInsertId() $dm->update(Model $model): void // explicit UPDATE $dm->delete(Post::class, $id): void // delete by class + ID $dm->delete($model): void // delete by model instance $dm->raw(string $sql, array $bindings, string $storage): array // raw SQL query $dm->saveTypeIfVersion(DynamicRecord $record, int $expected): bool // compare-and-set, see below
save() detects an empty ID field and automatically issues an INSERT, then reads back lastInsertId() into the model — no need to pre-generate IDs for auto-increment tables. A string id that is empty (or unset) gets a UUID v4 before the write instead; insert() refuses an id that exists.
QueryBuilder
All methods return $this for chaining except the terminal methods.
->where('status', 'published') ->where('views', '>', 100) ->orWhere('featured', true) ->where('deleted_at', null) // IS NULL; '!=' / '<>' with null: IS NOT NULL ->where('status', 'IN', ['draft', 'review']) ->orderBy('created_at', SortDirection::Desc) ->limit(10)->offset(20) ->search('php') // searches all #[Field(searchable: true)] fields ->get(): ResultSet // all results ->first(): ?Model ->paginate(perPage: 15, currentPage: 2): PaginatedResult
where() / orWhere() take =, !=, <>, <, >, <=, >=, LIKE, NOT LIKE, IN,
NOT IN (any case; IN / NOT IN take a list); where($field, $value) is =. Field names in
where(), orWhere(), whereAny(), orderBy() and search() are identifiers
([A-Za-z_][A-Za-z0-9_]*). Anything else throws \InvalidArgumentException before any SQL is
built: never pass a request's operator or column name unchecked.
Storage drivers
| Class | Backend |
|---|---|
JsonFileDriver |
One .json file per record at {basePath}/{table}/{id}.json |
SqliteDriver |
PDO-based SQLite via PdoDriver + QueryCompiler |
MysqlDriver |
PDO-based MySQL / MariaDB via PdoDriver + QueryCompiler (see MySQL and MariaDB) |
Lazy connection (0.59). SqliteDriver, MysqlDriver and any PdoDriver subclass take a
\PDO or a \Closure(): \PDO. Given a closure, the driver opens the connection on its first
query and runs its session setup (configure()) then; connected() tells whether it has.
Core builds the drivers from config/data.php with closures, so a request that runs no query
opens no database connection, and preflow optimize doesn't need the database. What that
changes for site code:
- A database outage now surfaces at the first query, inside the request (the error page), not at boot. A failed connect is retried on the next query.
\PDOin the container is still the driver's own connection (the migrator, transactions and the driver share it), but it is a lazy singleton: getting it from the container opens the connection.- A
PdoDriversubclass that overrides the constructor must pass the\PDO|\Closureon and must not touch the connection in the constructor; per-connection setup belongs inconfigure(). Read the connection through$this->pdo(orpdo()), never keep your own\PDO.
Your own storage driver (0.65)
A config/data.php entry names its driver by its key (sqlite, mysql, json) or by a
'driver' key: one of those names, a built-in class, or the class of your own
Preflow\Data\StorageDriver. Core builds that class through the container: the entry's other
keys are passed to its constructor by parameter name, the other parameters are autowired. An
unknown driver, a class that is no StorageDriver or a key the constructor doesn't take throws at
boot.
'drivers' => [ 'sqlite' => ['path' => __DIR__ . '/../storage/data/app.sqlite'], 'archive' => ['driver' => 'sqlite', 'path' => __DIR__ . '/../storage/data/archive.sqlite'], 'pg' => ['driver' => App\Data\PgsqlDriver::class, 'dsn' => getenv('PG_DSN') ?: ''], ],
The class is built on its first use (the DataManager takes a closure for it): after the service providers ran, so its constructor may take services a provider binds. The class and the option keys are checked at boot.
For another SQL database, a PdoDriver subclass supplies a Dialect (every method of the interface, see "Raw writes"; likeOperator() decides
the case-insensitive match where(…, 'like', …), search() and CONTAINS use, e.g. ILIKE;
numericOrder() the numeric sort; missingColumn(\PDOException) reads the column a failed write
names, so the "run preflow migrate" message works on that database) and registers its schema grammar for its PDO driver name, so
Schema, RecordTableSync and preflow migrate work:
Grammars::register('pgsql', new PgsqlGrammar()); // a SchemaGrammar; in the driver or a provider's register()
Check a driver with Preflow\Testing\Contract\StorageDriverContract (preflow/testing).
Migrations
abstract class Migration { abstract public function up(Schema $schema): void; public function down(Schema $schema): void {} }
Schema methods: create(string $table, callable $callback), drop(string $table).
Table builder: uuid, string, text, integer, boolean, json, timestamps, nullable(), primary(), index().
Schema introspection (SQLite and MySQL/MariaDB; any other PDO driver throws a LogicException):
$schema->hasTable('post'); // bool $schema->columns('post'); // ['id', 'title', ...] in table order $schema->addColumn('post', 'subtitle'); // nullable column; type TEXT on SQLite, LONGTEXT on MySQL
addColumn() is what lets a re-runnable step add what a newer model needs without a
migration file.
Changing a table in a migration (since 0.65; SQLite 3.25+ and MySQL 8, through the storage's
SchemaGrammar, identifiers quoted as everywhere else):
$schema->renameColumn('staff', 'passwordHash', 'password_hash'); // data, type and indexes kept $schema->addColumns('staff', function (Table $table) { // the create() builder; every column added nullable $table->boolean('active'); $table->string('auth_epoch')->index(); }); $schema->fillNulls('staff', 'active', true); // backfill: rows where it is NULL; returns the count $schema->copyColumn('staff', 'createdAt', 'created_at'); // old into new where new is NULL; returns the count $schema->dropIndex('staff', 'idx_staff_email'); // an index by name; refuses a missing one and SQLite's sqlite_autoindex_* $schema->dropColumn('staff', 'createdAt'); // SQLite 3.35+, MySQL; refuses an indexed column
All checks run before any SQL and throw LogicException (nothing changed): renameColumn()
refuses a column the table doesn't have and a new name it already has; addColumns() refuses a
column it already has, a primary key, and an index on a column that is neither added nor in the
table; copyColumn() and dropColumn() refuse a missing column; dropColumn() also refuses a column that
belongs to any index (a LogicException naming the index: drop it first with dropIndex(), as SQLite would fail and
MySQL would silently narrow a composite UNIQUE index) and SQLite older than 3.35 ("dropColumn needs SQLite 3.35 or newer"). Column names are compared without
case, as SQLite and MySQL do. Check with columns() first to write a migration that converges from
any shape of the table (preflow/starter-folio's staff upgrade merges an old column into its new one
with copyColumn() + dropColumn()). A registered SchemaGrammar implements renameColumn(),
copyColumn(), dropColumn() and dropIndex() (each returns the SQL), indexColumns() (index name => its columns) and
assertCanDropColumn() (throws a LogicException when the server can't drop a column).
Converting a column (since 0.75): convertColumn() changes an existing column's type,
length, collation or nullability in place and keeps its data, its default, its indexes and the
other columns. On MySQL/MariaDB it is one ALTER TABLE … MODIFY (NULL / NOT NULL and DEFAULT
carried over unless changed); SQLite cannot change a column, so the table is rebuilt in one
savepoint (new table, rows copied, old one dropped, new one renamed, indexes and triggers made
again; views keep working). The new column is a record-table ColumnSpec, typed as
RecordTableSync makes it (nullability kept), or one column declared with the create() builder
(NOT NULL unless nullable(), as in create()):
use Preflow\Data\Schema\ColumnKind; use Preflow\Data\Schema\ColumnSpec; // An indexed record field made LONGTEXT before 0.72: VARCHAR(191) COLLATE utf8mb4_bin on MySQL. $schema->convertColumn('object', new ColumnSpec('state', ColumnKind::Short)); // length: 191 by default $schema->convertColumn('object', new ColumnSpec('price', ColumnKind::Number)); // DOUBLE / NUMERIC $schema->convertColumn('staff', fn (Table $t) => $t->string('name', 80)->nullable()); $schema->convertColumn('staff', fn (Table $t) => $t->boolean('active')); // NOT NULL now
It refuses (LogicException, nothing changed) a column the table doesn't have, a value longer than
a bounded new type (string, uuid, a Short spec; counted in characters), NULLs in a column that
becomes NOT NULL (fillNulls() first), and a builder callback with no or several columns, a primary
key or an index (convert, then addColumns()/create() the index). A value the new type cannot
hold otherwise (text into a number) is the server's call: MySQL in strict mode refuses it, SQLite
keeps it. On SQLite it also refuses a table whose definition has what the rebuild cannot carry
over (REFERENCES, CHECK, UNIQUE, COLLATE, CONSTRAINT, AUTOINCREMENT, a generated column,
WITHOUT ROWID, STRICT); tables made by create() and RecordTableSync have none. With foreign
keys on, the rebuild switches them off and checks them before it commits; inside an open
transaction it cannot, so there it refuses unless they are off. Check columnTypes() first to skip
a column that is converted already. A registered SchemaGrammar implements
convertColumn(\PDO $pdo, string $table, string $column, string $type, ?bool $nullable): void
(it runs the statements itself; $nullable null keeps the column's nullability).
Several migration paths. new Migrator($pdo, [$dir1, $dir2]) takes one path or a list
and runs the files of all of them in name order; the same file name in two paths is one
migration. preflow migrate (see preflow/devtools) uses the paths of
Preflow\Data\Migration\MigrationRegistry, a container singleton that packages fill from
their service provider:
$registry = $container->get(\Preflow\Data\Migration\MigrationRegistry::class); $registry->addPath(__DIR__ . '/../migrations'); // a missing directory is ignored $registry->afterMigrate('mypkg.tables', fn (): array => ['created table x']); // lines to print
The app's migrations/ and core's own (the preflow_throttle table) are registered by
the application. A post-migrate step runs on every preflow migrate, so it must be
idempotent (hasTable() / columns() before changing anything); registering the same step
name twice throws.
Related behaviour: a storage name that is not configured is an error, never a silent
fallback to the default; a record key with no column fails with a message naming
preflow migrate; and orderBy($field, $direction, numeric: true) compares as numbers even
where the column stores text.
Auto-increment IDs
Place #[Id] on an int property and leave it at 0 (or unset). save() / insert() will issue an INSERT and read the generated ID back via lastInsertId().
#[Entity(table: 'comments', storage: 'sqlite')] final class Comment extends Model { #[Id] public int $id = 0; #[Field] public string $body = ''; } $comment = new Comment(); $comment->body = 'Great post!'; $dm->save($comment); echo $comment->id; // e.g. 42 — populated after insert
Raw SQL
Use raw() to run a query that the fluent builder cannot express. Results are returned as plain arrays.
$rows = $dm->raw( 'SELECT p.*, COUNT(c.id) AS comment_count FROM posts p LEFT JOIN comments c ON c.post_id = p.id GROUP BY p.id', [], 'sqlite', );
Raw writes
execute() runs a write the builder cannot express (a conditional UPDATE, a DELETE,
an upsert) and returns the affected rows. Only SQL (PDO) storages run it; any other driver
throws a LogicException. Ints, bools and nulls are bound with their type, so arithmetic and
comparisons in SQL work on numbers.
$taken = $dm->execute( 'UPDATE seats SET free = free - 1 WHERE id = ? AND free > 0', [$id], 'sqlite', ); // 1 if a seat was taken, 0 if none was left
One conditional statement decides by its affected rows, so concurrent requests cannot both
take the last seat. A package writing its own SQL asks the driver for PdoDriver::dialect() and
lets the Dialect write what differs between databases, never checking for MysqlDialect or
SqliteDialect, so the SQL also runs on a third-party dialect (PostgreSQL, say):
quoteIdentifier(); insertOrUpdateSql($table, $columns, $idField, $set) for one statement that
inserts a row or updates the one with that id with SQL expressions, which read the existing row
through table-qualified columns ("table"."column": PostgreSQL rejects a bare name there), null
where the dialect has none (then read and write under a lock; preflow/core's throttle does); wholeNumber($expr) for a
column that may hold a number or text such as '' or '3.0', safe inside an UPDATE under
MySQL's strict mode (preflow/codes' use counting); numericValue(), lockingRead() and the others
in Dialect. A custom Dialect must provide insertOrUpdateSql() and wholeNumber() since
0.66.1; preflow/testing's DialectContract checks them on its database.
Observing reads and writes (the page cache)
DataManager::observe() attaches a Preflow\Data\Observe\DataObserver: it is told about every
read and write through the manager (read, wrote, rawRead, rawWrote). preflow/core's page
cache attaches one when it is on, to learn what a page read and what a write changed; nothing
is observed otherwise (observe(null) stops it). Writes are reported after they ran, also inside
a transaction that may still roll back. The tables of raw SQL are read conservatively
(SqlTables): a statement it can't be sure of counts as the whole storage.
// Code that reads or writes around the manager (straight through driver(), an import): $dm->reportRead('default', 'member', 42); // a read of one record (null: a listing) $dm->reportWrite('default', 'member', 42); // a write of one record (null: many or unknown) // A memo that answers later calls from memory: capture the reads once, replay them on each hit, // so every page using the memo still depends on what it holds. [$value, $reads] = $dm->captureReads(fn () => $this->load()); $dm->replayReads($reads); // on every memo hit // Queries: $dm->queryType('asset')->byIds(['a1', 'a2'])->get(); // reported as one record read per id $dm->queryType('page')->where('slug', $slug)->unreported()->first(); // reported by the caller
byIds($ids)filters on the id field and is reported as one record read per id (found or not), not as a listing of the table: a page showing these records depends on them alone. Another filter added to the query makes it a listing again.unreported()keeps the query's runs from the observer, for a lookup whose caller reports what it really depends on (Folio finds a page by its address and reports the record and the address, not the whole table).reportedStorage($name)is the name a storage is reported under:defaultfor the default driver, whatever it is configured as.
Version-checked saves (compare-and-set)
Reading a record, comparing its version and then saving it leaves a window: two requests
that both read version 3 both pass the check, and the later save overwrites the earlier one.
saveTypeIfVersion() closes it. The write itself carries the version it expects:
$record = $dm->findType('job', $id); $loaded = (int) ($record->get('_version') ?? 0); // what the editor loaded $record->set('title', $title); if (!$dm->saveTypeIfVersion($record, $loaded)) { // someone else saved in between: nothing was written } // written with _version = $loaded + 1
- Of two writers holding the same version exactly one gets through; the other gets
falseand its record keeps the version it had (a record that had none gets none back). - The record is written with
$next(default$expected + 1) in$versionField(default_version).next: $expectedwrites without moving the version on. - A missing or empty stored version counts as 0. A record that is not stored yet is
inserted when
$expectedis 0; of two such inserts one fails. - Validation runs as in
saveType()(validate: falseskips it). - SQLite and MySQL check in the write's own
UPDATE … WHERE id = ? AND _version = ?, in a transaction (one the caller opened is used as it is). The JSON driver checks and writes under a lock file (.lockin the type's folder). All three implementPreflow\Data\VersionedStorage::saveIfVersion(); a driver that does not gets a plain read, compare and write (and oneE_USER_NOTICEper driver class saying so). - SQL compares the stored version as text: only a canonical integer string matches (
'5', not'05'or'5.0', which JSON reads as 5);NULLand''match 0. Versions are written canonical. - Inside your own transaction: a save committed by another connection after your first read
gives
falseon MySQL; MariaDB 11.6+ (innodb_snapshot_isolation) and SQLite in WAL mode refuse the write with an error instead, and your transaction has to roll back. In the driver's own transaction a deadlock or that error is a lost race (false).
Folio's Publisher::saveDraft() uses it, and throws VersionConflict instead of returning
false.
Saving null clears a column
save() writes every key it is given, null included: $record->set('expires_at', null)
clears the column on SQLite, MySQL and JSON alike (before 0.42 SQL drivers dropped nulls, so a
clear needed ''). A key the caller does not pass is left alone: a partial save no longer
nulls the other columns on SQLite (it used INSERT OR REPLACE, which replaced the whole row;
it is an upsert on the primary key now, and on MySQL an UPDATE with an INSERT for a new
record). A save never touches another record: a value colliding with a unique index is an error
(below), not an overwrite.
A migration-made column that is NOT NULL now fails on a null instead of silently keeping
its old value.
Unique fields and indexes
A field is declared unique in the model JSON or with the attribute, optionally scoped by other fields (unique within them), and may carry an index and a length. A type may also declare composite indexes, each over several of its fields in order:
"slug": { "type": "string", "unique": true, "length": 191 }, "code": { "type": "string", "unique": ["purpose"] }, "city": { "type": "string", "index": true }
{ "key": "job", "table": "jobs", "fields": { … }, "indexes": [["state", "updated_at"]] }
#[Field(unique: true, length: 320)] public ?string $email = null; #[Field(unique: ['purpose'])] public ?string $code = null; #[Index('state', 'updated_at')] // Preflow\Data\Attributes\Index, repeatable #[Entity(table: 'jobs', type: 'job')] final class Job extends Model { … }
- Validation.
save(),insert(),update(),saveType()andsaveTypeIfVersion()add thePreflow\Data\Validation\Uniquerule for every unique field (per locale for a translatable field). A duplicate is aValidationExceptionwithvalidation.unique("already taken") on the field, not an exception from the database.DataManager::validateUnique($record)checks only the unique fields;DataManager::validateStorable($record)checks what the storage itself would refuse, the unique fields and the length of every bounded column (validation.max.string), for a save that skips the other rules (Folio uses it for draft saves and restored revisions). Empty values (null,'') and values with anullscope are never duplicates. Comparison is exact: case and accents count, on every database. App forms use the rule directly:new Unique($dm, 'job', 'slug', scope: ['company'], exceptId: $id)(the scope is read from the validated data, or given as['company' => 'acme']). - Database guard. On SQL the unique index is the last line: two requests passing the
validation together, one gets a
ValidationExceptionon the field from the index, and both rows stay as they were. A save withvalidate: falsecan therefore throwValidationExceptiontoo. Translatable unique fields (stored as JSON, no index) and every unique field on JSON storage are checked and written under the type's lock; the JSON check reads every record of the type, so unique fields on large JSON types are slow. - Plain models need the index in their migration. Tables of types (model JSON files,
#[Entity(type: …)]classes) are made by sync, unique indexes included. A plain#[Entity]model's table comes only from the app's migration:#[Field(unique: true)]there adds the validation rule, not the index, so put->unique()on the column in the migration. Without it the rule alone does not stop two concurrent saves of the same value on SQL storage. - Empty is NULL. An empty value in a unique field is stored as
NULL(a unique index would refuse a second''). For aModel, the property must acceptnull. - Index and length.
uniqueandindexmake a short text column:VARCHAR(length)(default 191) withutf8mb4_binon MySQL, so the index compares exactly; validation then limits the value tolengthcharacters, also for the scope fields of a unique field and the fields of a composite index. Free text, blocks and translatable fields get no index.__revisionstables are never checked or indexed;__publishedtables are typed like their working table and carry its declared indexes (field and composite), plain ones only (never unique). - Composite indexes.
"indexes"(or#[Index]) lists the fields of each index in order; the index isidx_{table}_{field1}_{field2}…(shortened past 64 characters like every index name). Refused when the type is defined, with a message naming the type and the index or field: an index without fields, over a name that is not one of the type's fields (the id is not one), over a translatable field (free text gets no index), with a field twice, and two indexes with one name (["company"]besideindex: trueoncompany, or["a", "b"]beside a fielda_bthat is indexed or unique). An index over just a unique field (["slug"]besideuniqueonslug) is refused too: its unique index serves. A unique field and its scope fields are indexed plain in__publishedtables, so a composite named like one of them is refused; one over just a scope field is that field's index there. A class's admin file may not set"indexes"(it is schema). - What sync does.
preflow migratemakes every declared index that is missing, on new and existing columns alike:idx_{table}_{field}forindex, the composite ones, anduniq_{table}_{field}(over the field and its scope) forunique. An index is found by its name or by its columns: any index over the same columns in the same order (one a migration made under its own name) does for a plain one, a unique one over them for a unique one, so a second migrate changes nothing. A unique index is made on an existing column only when every column it covers has its expected type and no value is there twice (empty values never count); otherwise migrate printswarning: post.slug has no unique index (2 values are there more than once); remove the duplicates (tools/convert-typed-columns.php --check lists them), the next migrate adds it(or the drift warning when a column has another type). If a save writes a duplicate while the index is being made, the database refuses it and migrate warns the same way. On MySQL a plain index over an existing TEXT column (made before it was indexed) covers its firstlengthcharacters, at most 191. If the server refuses a plain index, migrate prints a warning naming it and the server's reason and goes on. A declared plain or composite index over a free-text column (atextorblocksfield, a multiple relation, a custom kind stored as text) can't be made: migrate printswarning: post: no index over state, body (body is free text); see the data READMEon every run; take the field out of the index or make it a string. MySQL caps a key at 3072 bytes (counted aslengthor 191 × 4 per short text field, 8 per number, date or yes/no): there an index whose key may be longer, plain or unique, is not made, and migrate printswarning: post: no index over a, b, n (a key of up to 3208 bytes; MySQL allows 3072); give its text fields a shorter "length"on every run. SQLite has no such limit. - Upgrading to 0.72. Before 0.72 an index came only with a new column.
- The first
preflow migrateafter the upgrade adds every declared index an existing table lacks (the working and__publishedtables); on big tables that can take a while, so run it when the site is quiet. On existing__publishedtables that includes the plain indexes of unique fields and their scope fields. - Declaring
indexon an existing string, select or relation field that was stored as free text, or naming it in a composite index, changes its expected kind to short: migrate prints the drift warning until the table is converted (Converting record tables below), the index over a TEXT column covers its first 191 characters on MySQL, and validation limits the value to 191 characters (itslength). SchemaGrammar::createRecordIndex()takes two more parameters,array $columnTypes = []andarray $lengths = [](the declared lengths of short-text columns: MySQL's prefix over a TEXT column is that length, at most 191), and the interface has a newuniqueIndexes(\PDO $pdo, string $table): array(the names of the unique indexes). A grammar of your own (Grammars::register()) must add them.
- The first
- Upgrading to 0.75.
SchemaGrammarhas a new method,convertColumn(\PDO $pdo, string $table, string $column, string $type, ?bool $nullable): void(behindSchema::convertColumn()). A grammar of your own (Grammars::register()) must add it. - Custom field types tell the data package their column with
Preflow\Folio\Field\HasColumnKind(columnKind(array $config): ColumnKind) orFieldKinds::column($type, ColumnKind|Closure).
Slugs
Slugs::unique() makes a slug no other record has, from Preflow\Validation\Rules\Slug::clean()
("Müller" becomes mueller): the cleaned text, then -2, -3... until it is free.
use Preflow\Data\Slugs; $slug = Slugs::unique($data, 'job', $title); // a type key, or a model class $slug = Slugs::unique($data, Job::class, $title, exceptId: $job->uuid); // the record being renamed keeps its slug
The signature is unique(DataManager $data, string $type, string $text, string $field = 'slug', ?string $exceptId = null, int $max = 80, string $fallback = 'item'). The result is at most $max
characters, suffix included: a long text is cut at a hyphen when one is in the last half of
$max, otherwise hard-cut, and a suffix shortens the base rather than the total. A text without
letters or digits gives $fallback. $exceptId (a string or an int) is left out of the lookup, so a record renamed to its own slug
keeps it even when other records share it. A field the type does not have, or a translatable
one (its value is a map per locale), is an InvalidArgumentException.
For an import that holds its slugs in memory, Slugs::among($text, $taken) does the same against
$taken (array<string, true>, by reference) and adds what it returns.
MySQL and MariaDB
preflow migrate, Schema introspection and Folio's table sync run on MariaDB 10.6+ (tested;
MySQL 8 expected to work, not yet tested) exactly as on SQLite; a schema grammar picked from the
PDO driver name writes the SQL. SQLite output is unchanged (same DDL, no new pending migrations
for existing sites). Any other PDO driver throws No schema grammar for PDO driver "<name>".
Config (config/data.php). host, port, database, username and password fall back to
the DB_HOST, DB_PORT, DB_NAME, DB_USER and DB_PASS environment variables:
return [ 'default' => 'mysql', 'drivers' => [ 'mysql' => [ 'host' => '127.0.0.1', // or DB_HOST 'port' => 3306, // or DB_PORT 'database' => 'myapp', // or DB_NAME 'username' => 'myapp', // or DB_USER 'password' => '', // or DB_PASS ], 'json' => ['path' => 'storage/data'], ], 'aliases' => ['codes' => 'default', 'throttle' => 'default'], ];
default names the driver for models without a storage; aliases map purpose names to a
configured driver (see Aliases below). preflow migrate migrates the site's SQL storage; with
several SQL drivers pass --storage.
Column types. SQLite stays TEXT/INTEGER; MySQL gets sized types:
Table method |
SQLite | MySQL |
|---|---|---|
uuid() |
TEXT | VARCHAR(64) COLLATE utf8mb4_bin |
string($n = 255) |
TEXT | VARCHAR($n) (COLLATE utf8mb4_bin when primary()) |
text() |
TEXT | TEXT |
json() |
TEXT | LONGTEXT (not JSON: MySQL's type rewrites key order and spacing) |
integer() |
INTEGER | BIGINT |
boolean() |
INTEGER | TINYINT(1) |
timestamps() |
TEXT, nullable | VARCHAR(32), nullable |
addColumn() default |
TEXT | LONGTEXT |
Record tables (the tables RecordTableSync makes from a type definition: Folio's types,
codes, members, media assets) use id VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY (TEXT on
SQLite), and every other column is nullable and typed by its field's column kind
(FieldKinds::columnKindOf()):
| Kind | Field types | SQLite | MySQL |
|---|---|---|---|
| text | text, blocks, translatable fields, JSON, strings that are neither unique nor indexed | TEXT | LONGTEXT |
| short | a string, select or single relation that is unique or indexed | TEXT | VARCHAR(length, default 191) COLLATE utf8mb4_bin |
| number | number | NUMERIC ('12' stays 12, sorts numerically) |
DOUBLE |
| boolean | boolean | INTEGER | TINYINT(1) |
| date | date | TEXT | VARCHAR(32) (ISO text) |
Columns are typed only when sync creates them (with the table, or added later). The unique
(uniq_{table}_{field}, over the field and its scope), plain (idx_{table}_{field}) and composite
(idx_{table}_{field1}_{field2}…) indexes are made whenever they are missing (see
Unique fields and indexes); names past 64 characters are shortened
with a crc32 hash. An existing column is never altered: a column whose type differs from its kind,
or a unique field without its index, is printed as a warning (warning: post.seats is text, expected number; convert the table (see the data README); see Converting record tables below). Packages register
their tables with RecordTables: it lists every record table, whichever package syncs it.
Converting record tables. Sync never changes a column it did not create, so a column the warning names is converted on purpose:
- An untyped column on an existing site (a table made before typed columns) is converted once
with the monorepo's one-time script
tools/convert-typed-columns.php, which finds the tables throughRecordTables.php tools/convert-typed-columns.php <site-dir> --checklists every value that does not fit (a number field holding text, a yes/no other than'',0,1,true,false, a date in no stored format, a short text over its length, a duplicate in a unique field) with table, record id, field and reason, and changes nothing. Without--checkit backs the database up tostorage/backups/(SQLite: a file copy; MySQL: a dump), converts table by table (a rerun continues after an interruption and keeps the first backup), and checks again. Empty strings in number, yes/no and date columns, and in unique fields, becomeNULL.--sqlite=<file>runs against a copy instead of the configured SQLite file (its backup then goes tobackups/beside the copy),--backup-dir=<dir>puts the backup elsewhere, and--storage=<driver>picks the SQL storage when several are configured. A MySQL backup needsmysqldumpormariadb-dumpon the machine running the script. The site needspreflow/core0.42 or later too (it bindsRecordTables; the script refuses otherwise). Upload the wholetools/directory to a site, run it with writes stopped, remove it afterwards. To restore: copy the SQLite backup back, or load the dump with the site's own database credentials (mysql --defaults-extra-file=<option file> <database> < <backup>.sql). Until a site is converted,preflow migrateprints the drift warnings on every run. RecordTableSync::specs()andRecordTableSync::indexes()are public (the script builds its columns and indexes with them, so it makes the indexes migrate would).- A column whose kind changed later (a field turned translatable, a relation became
multiple, a field's type was edited or it became unique) is converted with a migration in the project, withSchema::convertColumn()(since 0.75; see "Converting a column"), for example$schema->convertColumn('object', new ColumnSpec('state', ColumnKind::Short))for a field that became indexed.
preflow_migrations is migration VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY, ran_at VARCHAR(32) NOT NULL.
64-character names. MySQL caps table and index names at 64 characters. The index name is
idx_{table}_{column} and Folio adds {table}__published and {table}__revisions, so long
table names can cross it; the grammar throws a LogicException naming the identifier instead of
sending SQL MySQL would refuse. SQLite has no such limit.
TEXT columns can't be indexed. $t->text('body')->index() throws a LogicException
(Cannot index TEXT column "posts.body" on MySQL: use string() for indexed columns.); use
string() for anything indexed or unique. SQLite does not care, so a migration that indexes
text() passes there and fails on MySQL.
Composite and unique indexes. $t->unique('company_id', 'slug') declares one unique index over
the columns, in that order; $t->index('a', 'b') a plain one. Without arguments both mark the
column just defined ($t->string('slug')->unique()). Names are uniq_{table}_{columns} and
idx_{table}_{columns} (columns joined by _), shortened with a crc32 past 64 characters; a
one-column index() keeps its name idx_{table}_{column}. Every column must exist and not be
text(), checked before anything is created. Empty (NULL) values never collide in a unique index.
On MySQL, string() columns compare case-insensitively (utf8mb4_unicode_ci), so Ab and ab
collide there but not on SQLite.
Charset and collation. Tables are created ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci (MariaDB 10.6 has no utf8mb4_0900_*), identifiers in backticks.
Ids and keys (uuid() columns, primary string() columns, Folio record ids,
migration names) are utf8mb4_bin, so they compare exactly, as on SQLite and JSON: A, a and
ä are three records. Known difference from SQLite: value columns stay
utf8mb4_unicode_ci, which compares case-, accent- and trailing-space-insensitively, so values
that differ only in case, accents or trailing spaces (Abc and abc, cafe and café) are
equal in WHERE and collide in a unique index on MySQL while SQLite keeps them apart. Slugs are
values: on MySQL a page with slug cafe can shadow one with café. (utf8mb4_bin pads too, so
an id with a trailing space still equals the one without.)
Connection. createMysqlDriver builds the PDO with MysqlDriver::connectionOptions(), and
MysqlDriver sets the session; together they give:
PDO::ATTR_ERRMODE => ERRMODE_EXCEPTION, so failures throw instead of returning false.Pdo\Mysql::ATTR_FOUND_ROWS => true: anUPDATEthat matches a row but changes nothing still counts as 1 affected row, as on SQLite, so rowCount-based code (codes, tokens, throttle) behaves the same. This can only be set when the connection is opened: when you build thePDOyourself, passMysqlDriver::connectionOptions()as its options.- Session
sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION': too-long values are an error, not silently cut, and the result does not depend on the server's default mode. NoANSI_QUOTES, since identifiers use backticks. utf8mb4for the connection.- PHP booleans are written as
1/0(strict mode refuses''forfalsein aTINYINT(1)).
The upsert keeps VALUES(col) because MariaDB has no row-alias syntax.
Choosing storage. Storage is chosen per type: "storage" in a model's JSON, or
#[Entity(storage: '...')] on a class. Aliases map purposes (codes, auth, throttle) to a
driver. Moving a type to another storage means: change its storage, preflow migrate, then
folio:export and folio:import. Two limits today:
config/data.phpselects the driver class by its key (sqlite,mysql,json), so there is one connection per driver kind; two MySQL databases at once are not possible yet (aliases only add names).- There are no SQL joins across storages; relations between types on different storages resolve by id.
PostgreSQL would need a driver, a dialect and a schema grammar (Grammars::for refuses unknown
PDO drivers today).
Running the MySQL tests. They run when MYSQL_HOST and MYSQL_DATABASE are set (plus
MYSQL_USER, MYSQL_PASS) and are skipped otherwise, so vendor/bin/phpunit stays green without
a server. Put the values in a gitignored .env.mysql-test at the repo root and run:
composer test:mysql # sources .env.mysql-test, runs phpunit --group mysql
The tests share one database and drop the tables they create.
Relations and onDelete
A dynamic type declares a relation on a field of a relation kind (relation by default;
$dm->fieldKinds()->relation('mytype') registers more, ->text('mytype') registers extra
free-text kinds). The pointer is stored as today: an id string, or a JSON array string when
multiple is true.
"company": { "type": "relation", "relation": { "to": "company", "multiple": true, "onDelete": "restrict", "labelField": "name" } }
| Key | Meaning |
|---|---|
to |
Target type. A field without it is no relation. |
multiple |
Several targets (JSON array) instead of one. Default false. |
onDelete |
What deleting a target does to the pointing records (see Delete plans): detach (default) removes the id from the pointer, cascade deletes the pointing record too, restrict refuses the delete. Any other value throws InvalidArgumentException. |
onSelfDelete |
The same choices, applied when the owner deletes their own record (deletePlan(..., DeletedBy::Self)) instead of staff. Unset follows onDelete; any other value throws InvalidArgumentException. |
labelField |
The target field shown and searched as its label. |
metaField |
(since 0.75) The target field a picker shows under the label, such as an email under a name. Read as Relation::$metaField; #[Relation(metaField: 'email')] on a model class. |
$dm->relationsOf('job'); // list<Relation>: what 'job' declares $dm->relationsTo('company'); // list<Relation>: every relation, across all types, pointing at 'company' $dm->getTypeRegistry()->keys(); // every type key, sorted
A site without a models directory has no type registry. A package that defines its types in code
calls $dm->ensureTypeRegistry(new TypeRegistry($path)): it sets that registry only when there is
none and returns the one in use (the members core does this for its member type).
Relations work across storages: a SQL type may point at a JSON type and back.
Aggregates
aggregate() on a query builder groups the records its filters select and counts, sums, averages or finds the
smallest or largest value per group. It works on typed models and on dynamic types, on SQLite, MySQL/MariaDB and
JSON storage, and gives the same rows on all of them.
$rows = $dm->queryIn($answerType)->where('correct', true)->aggregate() ->groupBy('member') ->count() // result name 'count' unless you pass another ->countDistinct('step', 'steps') ->sum('seconds', 'total') ->avg('seconds', 'average') ->min('seconds', 'fastest') ->max('seconds', 'slowest') ->having('count', '>=', 3) // on a result name, a number ->orderBy('total', SortDirection::Desc) ->limit(10)->offset(0) ->get(); // [['member' => 'm1', 'count' => 3, 'steps' => 2, 'total' => 22.5, ...], ...]
The builder's where(), whereAny(), search(), searchLoose() and locale decide which records are grouped,
as they are when aggregate() is called (later changes to the builder do not reach it). The builder's own
orderBy(), limit() and offset() do not apply; the aggregate query has its own. Without orderBy() the rows come
back by the group fields, ascending, and the group fields also break ties. Without groupBy() you get one row of
totals, also when no record matches. Translatable group fields use the builder's locale.
Rows are arrays keyed by the group fields and the result names. Fields are the ones of the type (or the model's
#[Field] properties and its id); a field name is never SQL.
Null rules.
count()counts rows, whatever their values.countDistinct,sum,avg,minandmaxof a field skip null (and''for number fields).avg,minandmaxof no values arenull;sumof no values is0. Records with no value in a group field form one group withnull.having()never matches anullresult. In ordering, null comes first ascending and last descending.
Types. count and countDistinct are int. Other numbers are int when integral (within 2^53), else float.
Group values and min/max of boolean fields are PHP bool on every storage; those of text and date fields are
strings as stored.
Case. Text group values are case-sensitive everywhere: 'a' and 'A' are two groups. On MySQL/MariaDB the
group fields (and countDistinct, min, max, orderBy, having on text) use a binary collation
(utf8mb4_bin), so the rows match SQLite and JSON. The where() filters keep the table's collation, so a
where('grade', 'a') still matches 'A' on a _ci table. The binary comparison keeps MySQL from using an index
for the GROUP BY; aggregates scan the filtered rows anyway.
Refused (\InvalidArgumentException, when the method is called unless noted):
- a field that does not exist, in
groupBy()or a measure (this also refuses SQL fragments); - result names that are not
[A-Za-z_][A-Za-z0-9_]*, are used twice, or (atget()) equal a group field; groupBy()on a field that holds multiple relations (grouping by several relations is a later item);sum()andavg()of a field that is not a number field;having()on an unknown result name, onmin/maxof a non-number field, with an unknown operator (=,!=,<,<=,>,>=) or a non-finite value;- at
get(): anorderBy()name that is neither a result name nor a group field, and a query with neither group nor measure; - negative limit or offset.
A storage driver that does not implement AggregatingStorage gives a \LogicException naming the driver. A custom
Dialect implementation must now provide numericValue() and exactText() (see the 0.44 notes).
whereAny operators
whereAny() takes a list of conditions of which at least one must hold (an OR-group).
Several calls are AND-ed with each other and with where().
$dm->queryIn($typeDef)->whereAny([ ['field' => 'title', 'operator' => 'CONTAINS', 'value' => 'engineer'], ['field' => 'company', 'operator' => 'IN', 'value' => ['a1', 'b2']], ['field' => 'tags', 'operator' => 'HAS', 'value' => ['x']], ])->get();
| Operator | Matches when |
|---|---|
= |
The field equals the value. |
CONTAINS |
The field contains the value as a substring, case-insensitively. %, _ and the escape character match literally. |
IN |
The field equals one of the listed values. An empty list matches nothing. |
HAS |
The field, an id string or a JSON array string, holds at least one of the listed ids. |
Driver differences: SQLite's LIKE folds case for ASCII letters only (umlauts and other
non-ASCII letters match case-sensitively); the JSON driver folds with mb_stripos and
compares full Unicode. On MySQL/MariaDB CONTAINS and IN follow the column's collation,
so a case- or accent-insensitive collation matches more than the other drivers do.
searchLoose
$dm->queryIn($typeDef)->searchLoose('senior engineer')->get(); $dm->queryIn($typeDef)->searchLoose('engineer', fields: ['title', 'company'])->get();
The term is split on whitespace. Every word must match (AND); a word matches when it is a
case-insensitive substring of at least one field (OR). %, _ and quotes in the term match
literally. Unlike search(), which keeps using exactly the searchable: true fields:
- By default it searches every text-kind field (
string,text, plus kinds registered withfieldKinds()->text()) and every relation field, unless the field declares"searchable": false(or#[Field(searchable: false)]on a typed model, where onlystringproperties count). A field without asearchablekey is included — except a hidden one ("hidden": true), which is searched only when it says"searchable": true. Mark secrets (password hashes, tokens)"searchable": falseas well. fields:restricts the search to the named fields; names the type does not have are dropped, and so aresearchable: falsefields and hidden fields withoutsearchable: true— naming a field never makes it searchable. A type with nothing searchable matches no record.- A relation field is searched one level deep: the word is matched against the target's
labelField(or its firststringfield that is not hidden) and its text fields, by the same rules (hidden fields left out), and the records pointing at a matching target match. Relations are not followed further. - A word matching more than
QueryBuilder::RELATION_MATCH_LIMIT(1000) records of a related type is too unspecific for that relation: the relation does not count for that word (it can still match the type's own fields or other relations). All words of a query together bind at mostQueryBuilder::RELATION_ID_BUDGET(10000) related ids; a relation that would go over it does not count for that word either. - Repeated words count once; words after the first
QueryBuilder::MAX_LOOSE_WORDS(32) are ignored. - Relations are resolved when
get()runs, solocale()applies to translatable fields on both sides. They need aDataManager; a builder made without one searches the type's own fields only.
JSON storage cache
JsonFileDriver takes an optional second argument, a cache directory. With it, a query
reads one cache file per type and data path (json-{type}-{key}.json, where {key} is a short hash
of the resolved data path, the same for every spelling of it) instead of every record file. Drivers
of different data paths (each test's own data, parallel workers) can share one cache directory. The cache is
checked on every query with one directory stat and one small file read: the type's generation
and the type directory's mtime. A record added or removed changes the directory's mtime, and a
save or delete through any driver (this or another process: Folio, publishing, transfer
imports included) writes a new generation (json-{type}-{key}.gen, kept by clearCache()) and
drops the type's cache. A directory mtime has whole-second resolution, so a cache built in the
second the directory last changed is checked against every record file (count, newest mtime,
total size) until it is rebuilt or found unchanged in a later second. The generation is read
before the records, so a reader that read records before a write can never leave a cache that
is used after it; a cache is not written when the signature moved while the records were read.
Edge case: a record file edited by hand in place, outside the driver (an editor, sed, a deploy that
overwrites a file), changes neither the generation nor the directory's mtime and is not noticed until the
next save of that type or preflow cache:clear. Adding or removing a file by hand is noticed.
A cache that cannot be written only costs
speed. Core's Application passes storage/cache/data.
$driver = new JsonFileDriver($basePath, cacheDir: $cacheDir); $driver->clearCache('job'); // one type of this data path $driver->clearCache(); // every type of this data path
clearCache() leaves other data paths' caches in the same directory alone; preflow cache:clear
(the registry's "JSON storage query cache") removes every data path's cache and temp files, but keeps
the .gen generation files (a few bytes each) that guard a cache write racing a save.
Without a cache directory the driver behaves as before.
Delete plans
deletePlan() computes what deleting records would do across the declared relations, without
changing anything; executeDeletePlan() carries it out.
$plan = $dm->deletePlan('company', [$id]); $plan->deletes(); // ['company' => [...ids], 'job' => [...ids]] roots plus cascaded records $plan->detaches(); // [['type' => 'job', 'id' => '7', 'field' => 'company', 'remove' => ['3']], ...] $plan->restrictions(); // ['job' => 2] records still pointing at a deleted one $plan->restricted(); // true when any restriction blocks the delete $plan->counts(); // ['deleted' => [...], 'detached' => [...], 'restricted' => [...]] for a confirmation $dm->executeDeletePlan($plan);
cascadefollows recursively;detachloads the pointing record, removes the id and saves it without validation;restrictblocks the whole plan. A restricted plan is refused with aRuntimeExceptionbefore anything changes.- The optional third argument,
fn (TypeDefinition $t): array, names extra definitions that hold copies of a type's records (for example a published shadow table). They count toward restrictions only. - Who deletes. The fourth argument,
DeletedBy::Staff(default) orDeletedBy::Self, picks each relation's rule:SelfappliesonSelfDelete(unset:onDelete), for a person deleting their own record (the members package's account deletion andmembers:prune). Folio's staff deletes passStaff. - The plan is a snapshot. Records added or changed between
deletePlan()andexecuteDeletePlan()are not part of it; plan and execute close together, and plan again if the user confirms later. - Transactions. SQL writes run in one transaction per PDO connection, committed at the end and rolled back on any error (a transaction the caller already opened is left to the caller). JSON-file writes cannot be rolled back: if a later step fails, the JSON changes made before it stay, and so does a SQL/JSON mix that fails halfway on the JSON side.
Aliases
config/data.php may map purpose names to configured drivers, so a package can store on its
own name and the app decides where that lives:
return [ 'default' => 'json', 'drivers' => ['json' => [...], 'sqlite' => [...]], 'aliases' => ['codes' => 'sqlite', 'throttle' => 'sqlite'], ];
An alias is the same driver instance as its target; default is a valid target ('codes' => 'default'). A target that is not a configured driver,
or an alias named like a driver (or default), throws a RuntimeException at boot.
Translatable fields
Dynamic types can mark fields "translatable": true. When the app has several content
locales, config/i18n.php builds a Preflow\Data\Locales(available, default, fallback),
which is bound in the container and carried on every TypeDefinition. Those fields then
store a locale map, {"en": "…", "de": "…"}: native objects in JSON files, JSON text in
SQL columns.
$record->localized('de')->get('title'); // value with fallback (de → fallback → default) $record->localized('de')->isFallback('title'); $record->availableLocales(); // from the _locales system key; the default is always included $dm->queryType('page')->locale('de')->where('slug', 'ueber-uns')->first();
locale()makeswhere,searchandorderBycompare a translatable field's value in that locale. It works on JSON files, SQLite (json_extract) and MySQL (JSON_EXTRACT).- Without
locale(), the default locale is used. - A plain value stored before the field became translatable counts as the default locale's value.
LocaleValuehas the helpers:map,pick(exact),resolve(with fallback),defaultandresolveTree(nested data).- Validation:
requiredbinds the default locale; other rules run for every locale with a value, keyedfield.locale.
Model classes as types
Content without code is a JSON file; the app's own models are PHP classes; Folio edits both. A class is the single definition of its model: there is no second file that repeats its fields.
Opt in with a type key on the entity. Nothing changes for a class without one (like the
skeleton's Post), and an existing JSON type keeps working as it is:
use Preflow\Data\Attributes\{Entity, Field, Id, Relation}; use Preflow\Data\Model; use Preflow\Validation\Attributes\Validate; #[Entity(table: 'jobs', storage: 'sqlite', type: 'job')] final class Job extends Model { #[Id] public ?string $uuid = null; #[Field(label: 'Title', help: 'Shown in the list', translatable: true, type: 'string')] #[Validate('required')] public array|string|null $title = null; #[Field(type: 'select', config: ['options' => ['open' => 'Open', 'closed' => 'Closed']], default: 'open')] public ?string $state = null; #[Relation(to: 'company', onDelete: 'restrict', labelField: 'name')] public ?string $company = null; }
The registry finds marked classes in app/Models (set data.model_paths in config/data.php for
other directories; a relative entry is under the app's base path). preflow optimize stores the
list in the boot manifest, so a class costs a request no more than a JSON type. A class and a JSON
file that both define the schema of one key (fields, table, storage, id field, matrixable,
views) is a boot error (the registry checks it when it is built), never a silent override. A
class marked with a key the registry does not know (it is outside data.model_paths) is a
LogicException on its first read or write, never a silent fall back to a plain model.
Tables. Folio creates a type's tables (and its published copies) for the types it lists, which for a class means one with an admin file. A class-backed type without an admin file gets no table from Folio: the app creates it, for example with a migration.
Field kinds come from the property type (?string is a string, ?bool a boolean, int and
float a number, #[Relation] a relation) or from #[Field(type: …)]. A property that needs a
kind and has none (an array, an object, an untyped one) is an error naming it. Dates are strings
with type: 'date'. #[Field] takes type, label, help (text or a locale map), config
(kind settings written at field level, as in a JSON file: options, accept, time, blocks,
fields, multiple), default, translatable, unique (or a scope list), index, length,
searchable and transform. #[Relation(to:, onDelete:, onSelfDelete:, labelField:, metaField:)] writes the
relation the JSON file would, and #[Index('state', 'updated_at')] on the class its "indexes".
ModelSchema::of(Job::class) shows the type derived from a class.
What goes where. On the class: everything with data meaning, plus labels and help, which forms
outside Folio use too. In the admin-only file config/models/job.json: presentation (the type's
label, icon, section, list columns, filters, sort, publishing; per field readonly, hidden,
placeholder, …). The file may not set schema: table, storage, id_field, matrixable,
views, indexes, a field's type, validate, translatable, unique, index, length, searchable,
transform, default, relation, options, accept, time, blocks, fields or multiple
is refused with a message naming the file. A site without Folio never sees these files.
Folio's columns (_version, _publish_at, …) are invisible to the class.
Typed reads and writes do what findType(), queryType() and saveType() do for the type:
find()andquery()read as the type reads:query(Job::class)->locale('de')compares translatable fields in that locale,searchLoose()reaches related records. They giveJobobjects, with stored numbers and yes/no values (text in JSON files) as the properties' types.save(),insert()andupdate()validate and store assaveType()does (the type's rules, per locale, unique fields, bounded columns; the class'son:rules andrules()on top), and write only the id and the type's fields: columns the class does not declare (_version,_publish_at, …) stay as stored, also in JSON files.- A new object with an empty string id gets the id Folio gives a record
(
bin2hex(random_bytes(16))) before it is written. - Reads and writes report the same page cache tags as the type's.
A translatable field's property holds the locale map, ['en' => '…', 'de' => '…'], on
a multilingual site (a plain value on a single-locale one, or one stored before the field
became translatable); give it the type array|string|null and the field type explicitly. The
union names no single kind (an array could be blocks, a string text or a date), so the kind
can't be read from the property:
#[Field(type: 'string', translatable: true)] public array|string|null $title = null;
Another table of the same shape (a published copy) is read with findAs() and
queryAs(). The objects are read-only: every write of one throws a LogicException.
$job = $dm->findAs(Job::class, $id, 'job__published'); $dm->queryAs(Job::class, 'job__published')->where('open', true)->get(); $dm->isReadOnly($job); // true
Array fields (several assets, blocks, a matrix, a multiple relation) are ?array
properties: the JSON text SQL storage holds is read as the array. Values that cannot be read
as the property's type (an unreadable stored value) leave the property's default in place,
and a save keeps the stored value until the code sets that property. Public readonly properties are never filled. Ids are the type's:
the id field keeps its name and kind, strings are generated on insert, integers auto-increment.
Not yet on classes: a type-level matrixable or views. The generator in
preflow/devtools refuses a type that has them.
Type families (since 0.75)
Preflow\Data\TypeFamily names what kind of content a type or a block holds: page, reference
(lookup data: categories, companies), asset, member (accounts), shop or live (tools that act
at once). A type declares it as "family" at the top of its model file (or of a model class's admin
file); a block declares it in its attribute, #[Block('quote', family: TypeFamily::Reference)]
(see the preflow/blocks README).
{ "key": "company", "table": "company", "family": "reference", "fields": { … } }
The enum only names the kinds: nothing in this package maps a family to a colour or an icon, which
is an admin's business. Folio tints its type tiles with it, takes page for a type with a
"frontend" and member for a user type when none is declared, and stops at boot on a value that
is not a case (see Type tiles and families).
TypeFamily::tryFrom($value) reads one.
Display texts and their keys
A model file's display texts (the type's label, its singular, field labels and help, select
options, a sidebar section) can be translated with keys in a language file
(lang/{locale}/{group}.php: site for a site's own types, a package's group for the types it
ships). Preflow\Data\ModelLabelKeys names them; a key wins over the file's text, which is a plain
string or a map per language:
| Key | Text |
|---|---|
{group}.types.{type}.label |
the type's label ("Apps"; the file's "label") |
{group}.types.{type}.singular |
one record ("App"; the file's "label_singular") |
{group}.fields.{type}.{field}.label / .help |
a field's label and help |
{group}.options.{type}.{field}.{value} |
a select option's label |
{group}.option_helps.{type}.{field}.{value} |
(since 0.75) the description of an option given as {"label": …, "help": …} |
{group}.sections.{section} |
a sidebar section a type names |
Since 0.75 a select option may carry a description: "options": {"301": {"label": "Moved for good", "help": "Search engines move the ranking"}} (each a text or a map per language).
ModelLabelKeys::isDescribedOption($option) tells such an option (only label and help keys) from
a label map, and ModelLabelKeys::optionHelp($group, $type, $field, $value) gives its key (null for
a value a key can't name: empty, or with a dot). ModelLabelKeys::derive($schema, $group) lists
every key of a model file with the file's text, the descriptions included (Folio registers them
with i18n's DerivedKeys, so preflow translations:check reports the missing ones).
Usage
Model:
use Preflow\Data\{Model, ModelMetadata}; use Preflow\Data\Attributes\{Entity, Id, Field, Timestamps}; #[Entity(table: 'posts', storage: 'sqlite')] #[Timestamps] final class Post extends Model { #[Id] public string $id; #[Field(searchable: true)] public string $title; #[Field(searchable: true)] public string $body; public string $status = 'draft'; public ?string $created_at = null; // set by #[Timestamps] (createdAt / updatedAt work too) public ?string $updated_at = null; }
Querying:
// Find by ID $post = $dm->find(Post::class, 'abc-123'); // Filtered query $posts = $dm->query(Post::class) ->where('status', 'published') ->orderBy('created_at', SortDirection::Desc) ->paginate(perPage: 10, currentPage: 1); // Full-text search across searchable fields $results = $dm->query(Post::class)->search('preflow')->get();
Saving:
$post = new Post(); $post->fill(['id' => 'abc-123', 'title' => 'Hello', 'body' => '...', 'status' => 'published']); $dm->save($post);
Migration:
use Preflow\Data\Migration\{Migration, Schema}; final class CreatePostsTable extends Migration { public function up(Schema $schema): void { $schema->create('posts', function (Table $t) { $t->uuid('id')->primary(); $t->string('title')->index(); $t->text('body'); $t->string('status'); $t->timestamps(); // created_at, updated_at }); } public function down(Schema $schema): void { $schema->drop('posts'); } }
Multi-storage setup:
$dm = new DataManager([ 'default' => new JsonFileDriver(basePath: '/storage/data'), 'sqlite' => new SqliteDriver(new \PDO('sqlite:/storage/db.sqlite')), ]);
Models with #[Entity(storage: 'sqlite')] use SQLite; all others fall back to 'default'.