debuss-a / flora
A FaunaDB-like document store on PostgreSQL, MySQL, MariaDB and SQLite: JSON documents, indexes, sets, history and FQL-style queries in PHP.
Requires
- php: ^8.4
- ext-json: *
- ext-mbstring: *
- ext-pdo: *
Requires (Dev)
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.3
- phpstan/phpstan-phpunit: ^2.1
- phpunit/phpunit: ^12
Suggests
- ext-intl: Required by Casefold().
- ext-pdo_pgsql: Required to use the PostgreSQL driver.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-16 19:07:01 UTC
README
A FaunaDB-like document store on top of SQL, queried with functions modelled on FQL.
use function Flora\{Collection, Get, Ref}; $client->query(Get(Ref(Collection('users'), '9203420348023942')));
- A collection is a table:
id BIGINT PRIMARY KEY,ts BIGINT,data JSON. - An index is built on JSON paths of
data(indexed virtual columns on MySQL, MariaDB and SQLite, an expression index on PostgreSQL), so searching never scans the JSON. - A query is a tree of expressions run by
Client::query()in a single transaction: all its writes happen, or none.
Status: proof of concept, for PostgreSQL, MySQL, MariaDB and SQLite.
Setup
use Flora\Client; use Flora\Driver\{MariadbDriver, MysqlDriver, PostgresDriver, SqliteDriver}; $driver = new PostgresDriver(new PDO('pgsql:host=localhost;dbname=app', 'user', 'password')); // or $driver = new MysqlDriver(new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', 'user', 'password')); $driver = new MariadbDriver(new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', 'user', 'password')); $driver = new SqliteDriver(new PDO('sqlite:/path/to/app.sqlite')); $driver->install(); // creates the flora_collections, flora_indexes and flora_functions tables, once $client = new Client($driver);
Using Flora with an AI assistant
AI.md is a guide for AI coding assistants: the rules, shapes, renamed FQL functions and recipes they need to
write correct queries. No assistant reads vendor/ on its own: point yours to it from your project.
-
Claude Code: add this line to the
CLAUDE.mdof your project, which imports the guide in every session:@vendor/debuss-a/flora/AI.md -
Other assistants (
AGENTS.md, Cursor or Copilot rules...): add an instruction such as "Before writing database queries with Flora, readvendor/debuss-a/flora/AI.md."
Tests keep the guide in step with the library. tests/Unit/AiGuideTest.php checks that it names every function, that
its examples call none that does not exist, and that its table of renamed FQL functions gives names that exist.
tests/Integration/AiGuideTestCase.php runs its examples as they are written, in order, on each database, and checks
what it says of the results: their shapes, the order of pages, bounds, joins, history.
Collections and documents
use function Flora\{Collection, Create, CreateCollection, Delete, Exists, Get, Ref, Replace, Update}; $client->query(CreateCollection(['name' => 'users'])); $ada = $client->query(Create(Collection('users'), ['data' => ['name' => 'Ada', 'email' => 'ada@example.com']])); // ['ref' => Ref(users, "1068...") , 'ts' => 1791480000000000, 'data' => ['name' => 'Ada', ...]] $client->query(Create(Ref(Collection('users'), '42'), ['data' => [...]])); // at a chosen id $client->query(Get($ada['ref'])); $client->query(Update($ada['ref'], ['data' => ['age' => 36, 'email' => null]])); // deep merge, null removes a key $client->query(Replace($ada['ref'], ['data' => ['name' => 'Ada Lovelace']])); // replaces all data $client->query(Exists($ada['ref'])); $client->query(Delete($ada['ref'])); // returns the deleted document
Refs can be stored in documents (['author' => $ada['ref']]), indexed and matched like any other value.
Data is JSON written as PHP arrays, plus refs, instants and dates. Objects are arrays with string keys, so an empty
object comes back as an empty list, and stdClass is refused: decode JSON with json_decode($json, true). Data comes
back as it was written, fields starting with @ included: Flora tags its own values as {"@ref": ...}, {"@ts": ...}
and {"@date": ...}, and stores a field of the application starting with @ with another one ("@type" as
"@@type"). Index paths name fields as they are written (['data', '@type']).
Ids are Snowflake ids: 64-bit, sorted by creation time, generated in PHP.
Snowflake::createdAt($id) tells when an id was made. NewId() gives one without creating a document, for a document
whose ref is needed first.
Delete(Collection('users')) deletes a collection with its documents, their history and its indexes, and returns it
as it was. The name can then be given to another one.
use function Flora\{Abort, IfElse, NewId}; // Abort() stops the query: its writes are rolled back, and the client throws Flora\Exception\Aborted. $client->query([ Update($account, ['data' => ['balance' => Subtract(Select(['data', 'balance'], Get($account)), 100)]]), IfElse(LT(Select(['data', 'balance'], Get($account)), 0), Abort('Insufficient funds.'), null), ]);
Indexes
use function Flora\{CreateIndex, Get, Index, MatchIndex, Paginate}; $client->query(CreateIndex([ 'name' => 'users_by_email', 'source' => Collection('users'), 'terms' => [['field' => ['data', 'email']]], 'unique' => true, ])); $client->query(Get(MatchIndex(Index('users_by_email'), 'ada@example.com'))); // first match $client->query(CreateIndex([ 'name' => 'users_by_role', 'source' => Collection('users'), 'terms' => [['field' => ['data', 'role']]], 'values' => [['field' => ['data', 'age'], 'reverse' => true], ['field' => ['data', 'name']], ['field' => ['ref']]], ])); $page = $client->query(Paginate(MatchIndex(Index('users_by_role'), 'admin'), ['size' => 20])); // $page->data: [[85, 'Grace', Ref], [36, 'Ada', Ref], ...] $next = $client->query(Paginate(MatchIndex(Index('users_by_role'), 'admin'), ['size' => 20, 'after' => $page->after])); $prev = $client->query(Paginate(MatchIndex(Index('users_by_role'), 'admin'), ['size' => 20, 'before' => $next->before]));
- terms are what
MatchIndex()searches on, one argument per term. No terms: the whole collection. - values are what each entry returns (the ref when there are none, the value itself when there is one) and what entries are sorted by. Entries with equal values are then sorted by ref.
- Fields are
['data', ...path],['ref']or['ts']. Values keep their JSON type:36and"36"are different terms, and numbers sort as numbers. Missing values arenulland sort first. - unique rejects writes that would give two documents the same terms and values (
NotUnique). - Cursors (
before,after) are lists of values and refs.Flora\Cursor::encode($page->after)turns one into a URL-safe string for the clients of an application, andCursor::decode($string)reads it back, throwingInvalidArgumentfor anything else than a cursor (both passnullthrough). The string is not signed: a client may change it, but only to start a page elsewhere in the set the application paginates. Documents(Collection('users'))is the set of all documents of a collection, sorted by ref.
$client->query(Update(Index('users_by_email'), ['name' => 'emails'])); // renames it $client->query(Update(Index('emails'), ['unique' => false])); // or makes it unique, or not $client->query(Delete(Index('emails'))); // the documents stay
Updaterenames an index or changesunique; its terms, values and source stay, as in FQL: create another index for others. The index is built again: the new one before the old one is deleted, so that when it cannot be (a name taken, documents breaking it) the old one is still there, even on databases committing schema changes.Deleteremoves an index: its definition, and its columns, database index or table of entries.
Unrolling lists
$client->query(CreateIndex([ 'name' => 'posts_by_tag', 'source' => Collection('posts'), 'terms' => [['field' => ['data', 'tags'], 'unroll' => true]], ])); // A post tagged ['php', 'sql'] has an entry for each tag: it matches both. $client->query(Paginate(MatchIndex(Index('posts_by_tag'), 'php'))); // Posts tagged php and sql: entries of the same index, combined like any sets. Intersection(MatchIndex(Index('posts_by_tag'), 'php'), MatchIndex(Index('posts_by_tag'), 'sql'));
- A field with
'unroll' => truegives an entry per item of the list it holds, terms and values alike, and every combination of items when several fields unroll. A single value is one item; a missing field or an empty list gives no entry. A document repeating an item has one entry for it, and a unique index rejects an item another document has. - Such an index cannot be computed from each document as one entry: it has a table of entries,
flora_entries_<index>, filled when the index is created and updated by every write to the collection (one more statement per write and per such index). Reading it works like any index: matching a term or paginating seeks in the index of that table. The table only holds current entries: in the past (At,Events), entries are computed from the versions of the documents, unrolling their lists in SQL (jsonb_array_elements,JSON_TABLE,json_each). - Unlike FaunaDB, which unrolled any list it met, fields are unrolled when the index says so: the index is stored differently.
Combining sets
use function Flora\{Difference, Intersection, Union}; // Admins or Parisians, each once, sorted by ref. $client->query(Paginate(Union( MatchIndex(Index('users_by_role'), 'admin'), MatchIndex(Index('users_by_city'), 'Paris'), ))); // Admins living in Paris. $client->query(Get(Intersection( MatchIndex(Index('users_by_role'), 'admin'), MatchIndex(Index('users_by_city'), 'Paris'), ))); // Admins not living in Paris. $client->query(Paginate(Difference( MatchIndex(Index('users_by_role'), 'admin'), MatchIndex(Index('users_by_city'), 'Paris'), ))); // Sets nest: (admins or editors) in Paris, except the viewers. Difference(Intersection(Union($admins, $editors), $parisians), $viewers); // Lists too. $client->query(Union([1, 2], [2, 3])); // [1, 2, 3] $client->query(Intersection([1, 2], [2, 3])); // [2] $client->query(Difference([1, 2], [2, 3])); // [1]
- Entries are compared by their tuple, the index values followed by the ref: an intersection keeps the documents found in all the sets with the same values, a difference those of the first set found in none of the others with the same values, a union keeps each entry once. Entries are sorted like their indexes. A document missing the terms of a set is not in it.
- The indexes of the sets of an operation have as many values, of the same kind (data, ref or ts), sorted the same way. Indexes without values (entries are refs) always combine.
- Sets can point to several collections:
Union(MatchIndex(Index('users_by_tag'), 'x'), MatchIndex(Index('teams_by_tag'), 'x'))holds users and teams, sorted by values, then by ref (id, then collection). An intersection of sets of different collections is empty, since their refs differ, and a set of another collection excludes nothing from a difference. - Paginating a union reads one page of each index from the cursor and merges them; an intersection or a difference
reads the index of its first set and checks the terms of the others on each document. Nested sets are read as a
union of branches:
(A ∪ B) ∩ Cas(A ∩ C) ∪ (B ∩ C), up to 32 of them; the sets a difference excludes are checked whole, however nested.
Joins
use function Flora\Join; // Posts written by admins: the posts_by_author index (terms: data.author) matched with the refs of the admins. $client->query(Paginate(Join(MatchIndex(Index('users_by_role'), 'admin'), Index('posts_by_author')))); // Projects of the teams of the admins: teams_by_role holds a value, data.team, matched with projects_by_team. Join(MatchIndex(Index('teams_by_role'), 'admin'), Index('projects_by_team')); // Joins nest and combine: comments on the posts of admins, except those of the web team. Join(Difference(Join($admins, Index('posts_by_author')), $webPosts), Index('comments_by_post')); // A lambda receives the items of each source entry (destructured when it holds several values) and returns a set. Join($admins, Lambda('ref', MatchIndex(Index('posts_by_author'), Variable('ref')))); Join($ada, Lambda(['team', '_'], MatchIndex(Index('grants_by_team_and_role'), Variable('team'), 'editor'))); // Posts written and reviewed by the same admin: all the terms of the result refer to the same entry. Join($admins, Lambda('ref', Intersection( MatchIndex(Index('posts_by_author'), Variable('ref')), MatchIndex(Index('posts_by_reviewer'), Variable('ref')), )));
Join(source, Index('detail'))is the set of the entries of the detail index whose terms equal an entry of the source set: its ref when the source index has no values, its value when it has one, its values (one per term) when it has several. It is sorted like the detail index, and combines withUnion,IntersectionandDifference.Join(source, Lambda(...))is the union of the sets the lambda returns for each source entry. The lambda is evaluated once, its parameters standing for the items of all the entries at once: they can only be terms ofMatchIndex()in the set it returns (combined withUnion,Intersection,Differenceand other joins), not in the sets aDifference()excludes, nor in the source of a nested join. Comparing or reading them throws, rather than deciding once for all the entries.- The detail index has as many terms as the source entries hold items, on data fields; refs stored in documents (see above) match the refs of the source.
- A page of a join reads the detail entries matching the source, through a subquery the database runs once, then sorts them: it costs about the number of matching entries.
- On MySQL and MariaDB, join terms are compared on their index keys: values longer than 128 characters match on their beginning, and numbers as doubles.
Ranges
use function Flora\Between; // Admins aged 40 to 65, both included, in the order of the index. $client->query(Paginate(Between(MatchIndex(Index('ages_by_role'), 'admin'), 40, 65))); // October's orders, from an index whose first value is the date: the bounds are prefixes. Between(MatchIndex(Index('orders_by_date')), [Timestamp('2026-10-01T00:00:00Z')], [Timestamp('2026-10-31T23:59:59.999999Z')]); // From 40 on, up to 65: an empty list leaves a side open. Between(MatchIndex(Index('ages_by_role'), 'admin'), 40, []); Between(MatchIndex(Index('ages_by_role'), 'admin'), [], 65);
Between(set, from, to)(FQL'sRange) keeps the entries of a set from one bound to the other, both included. A bound is a value or a list: a prefix of the tuple of the entries, their values then their ref. An entry is within[80]and[90]when its first value is, whatever the others:[90, 'Zoe']is in. A ref at the end of a bound tells apart entries with the same values, and bounds a set without values, likeDocuments().- Bounds follow the order of the index: for a
reversevalue,fromis the larger one. Values of different types compare like the index sorts them. - The bounds are conditions on the index, read like the cursor of a page: a page of the range seeks into the index
and reads no more than the page, and
Sizecounts the range in the index. Betweenbounds any set: matches, unions, intersections, differences, joins (bound the source to join part of it, or the result of the lambda), other ranges (both apply), filters (the range is read, then filtered). Ranges read the past withAtand haveEvents: a document enters the range, or leaves it, when its value does.
Filtering
use function Flora\{All, Any, Filter, GT, GTE, LT, LTE, Not}; // Admins living in Paris: the lambda receives each entry (a ref here) and returns whether to keep it. $client->query(Paginate(Filter( MatchIndex(Index('users_by_role'), 'admin'), Lambda('ref', Equals(Select(['data', 'city'], Get(Variable('ref'))), 'Paris')), ))); // On the values of an index: admins aged 40 or more (Between(..., 40, []) reads them from the index). Filter(MatchIndex(Index('ages_by_role'), 'admin'), Lambda(['age', 'ref'], GTE(Variable('age'), 40))); // Lists and pages too (a page keeps its cursors). $client->query(Filter([1, 2, 3, 4], Lambda('n', GT(Variable('n'), 2)))); // [3, 4]
- A filtered set is read like any set, with
Paginate(both ways),GetandExists. The lambda can do anything a query does, so it runs for each entry: the set is read from the cursor in chunks, growing while the lambda rejects entries, until the page is full. A page costs about the number of entries the lambda looks at. - Filters nest, and filter any set (unions, joins...), but a filtered set cannot be combined with
Union,Intersection,Differenceor be the source of aJoin, which databases read with SQL: filter the combination. LT,LTE,GTandGTEcompare numbers, or strings (by code point):LT(1, 2, 3)is true.Notnegates,AllandAny(FQL'sAndandOr) evaluate their conditions in order until the result is known.
Counting and reducing
use function Flora\{Add, Append, Concat, Greatest, Reduce, Size}; $client->query(Size(MatchIndex(Index('users_by_role'), 'admin'))); // 2 $client->query(Size(Union($admins, $parisians))); // each entry once $client->query(Size(Filter(Documents(Collection('users')), $inParis))); $client->query(Size([1, 2, 2])); // 3 // The reducer receives the value so far and each item: the total age of the admins, and the oldest. $ages = MatchIndex(Index('ages_by_role'), 'admin'); // values: age, ref $client->query(Reduce(Lambda(['total', 'entry'], Add(Variable('total'), Select(0, Variable('entry')))), 0, $ages)); $client->query(Reduce(Lambda(['oldest', 'entry'], Greatest(Variable('oldest'), Select(0, Variable('entry')))), 0, $ages)); // Their names, as a list, then a string. $names = Reduce(Lambda(['names', 'entry'], Append([Select(['data', 'name'], Get(Select(1, Variable('entry'))))], Variable('names'))), [], $ages); $client->query(Concat($names, ', ')); // "Margaret, Ada, Grace"
Size(FQL'sCount) returns how many items a list or a page has, or how many entries a set has, each counted once (an entry found in several branches of a union, or several times by a join, counts once). FQL returned a page for a page; Flora returns an integer.- A set is counted by the database in a single query (
SELECT COUNT(*), through the index when it is a single match); a filtered set by running its lambda on every entry of the set it filters. Reduce(reducer, initial, collection)folds a list, a page or a set. The reducer can do anything a query does, so it runs in PHP: a set is read whole, in chunks of 1,024 entries.Add,Subtract,Multiply,Divide,Modulo,Greatest,LeastandAbsolutework on numbers, folded from left to right. Dividing integers gives an integer, truncated, like FQL:Divide(7, 2)is 3.Append(elements, base)adds elements at the end of a list, in FQL's order of arguments:Append([4, 5], [1, 2, 3])is[1, 2, 3, 4, 5], andPrepend(elements, base)at its start:Prepend([1, 2], [3, 4])is[1, 2, 3, 4].Concat(strings, separator)joins strings.
Lists, objects and sets
use function Flora\{ContainsField, ContainsPath, ContainsValue, Distinct, Drop, IsEmpty, IsNonEmpty, Mean, Merge, Reverse, Sum, Take, ToArray, ToObject}; $client->query(Take(2, [1, 2, 3])); // [1, 2] $client->query(Drop(2, [1, 2, 3])); // [3] $client->query(Paginate(Reverse(MatchIndex(Index('ages'))))); // the oldest first $client->query(Get(Reverse(MatchIndex(Index('ages_and_refs'))))); // the oldest user $client->query(Paginate(Distinct(MatchIndex(Index('tags'))))); // each tag once $client->query(IsEmpty(MatchIndex(Index('users_by_role'), 'guest'))); // true $client->query(Sum(MatchIndex(Index('ages_by_role'), 'admin'))); // their total age $client->query(Mean([1, 2, 3, 4])); // 2.5 $client->query(ContainsValue(41, MatchIndex(Index('ages')))); // is someone 41? $client->query(ContainsPath(['data', 'address', 'city'], Get($ref))); $client->query(Merge(['a' => 1, 'b' => 2], ['b' => null, 'c' => 3])); // ['a' => 1, 'c' => 3] $client->query(ToArray(['a' => 1])); // [['a', 1]], and back with ToObject()
Take(n, list)andDrop(n, list)keep or drop the first items of a list or a page; a page keeps its cursors, like withFilterandMap.Reversereverses a list or a page, or a set: the set is read backward, through its index, so its pages cost what they cost forward. It is paginated with the cursors of the set;Betweenon it takes its bounds in its order.Distinctremoves the items repeated in a list or a page, keeping the first, or gives one entry per distinct values of a set (its item, the values). A set sorts by values, so repeated values follow each other: they are skipped while reading, and a page reads the entries it skips.Sizeof it reads the whole set.IsEmptyandIsNonEmptyread one entry of a set.SumandMeanadd the numbers of a list, a page or a set, read whole;Meanof nothing throws.ContainsValue(value, in)looks for an item of a list or a page, a value of an object, or an item of a set: the set is searched in its index, likeBetween(set, value, value).ContainsPath(path, in)tells whetherSelectfinds a value at a path, andContainsField(field, object)whether an object has a field.Merge(object, values, resolver)merges objects one level deep (unlikeUpdate, which merges data at any depth):valuesis an object or a list of them, anullvalue removes the field, and the optional resolver,Lambda(['field', 'left', 'right'], ...), gives the value of a field both have.ToObjectandToArrayturn[field, value]pairs into an object, and back.- Reversed, distinct and filtered sets are read through the set they come from: they cannot be combined with
Union,Intersection,Differencenor be the source of aJoin; apply them to the combination.
Strings and types
use function Flora\{Casefold, ContainsStr, Format, LowerCase, ReplaceStrRegex, SplitStr, SubString, ToInteger, ToString, TrimStr, IsString}; $client->query(LowerCase('ÉCOLE')); // "école" $client->query(Casefold('Straße')); // "strasse", to compare $client->query(SubString('Ada Lovelace', -8, 4)); // "Love" $client->query(SplitStr('a,b,c', ',')); // ['a', 'b', 'c'] $client->query(ReplaceStrRegex('One Fish Two Fish', '(\w+) Fish', '$1 Cat')); // "One Cat Two Cat" $client->query(Format('%s is %d years old', 'Ada', 36)); // "Ada is 36 years old" $client->query(ToInteger('12')); // 12 $client->query(IsString(Select(['data', 'name'], Get($ref))));
- The functions on strings are FQL's:
LowerCase,UpperCase,TitleCase,Casefold(with a normalizer, NFKC by default),Length,SubString(from the end when the start is negative),ContainsStr,StartsWith,EndsWith,FindStr(-1 when not found),ReplaceStr,SplitStr,Repeat,Space, andTrimStr,LTrimStr,RTrimStr(FQL'sTrim,LTrim,RTrim). Strings are UTF-8: positions and lengths count characters (code points), not bytes. - Regular expressions are PCRE, written without delimiters:
ContainsStrRegex,FindStrRegex(each match with its start, end included, and text),ReplaceStrRegex($1is a group; only the first match when told),SplitStrRegex, andRegexEscape, which escapes a string to match it as it is. FQL's were Java's, which PCRE mostly reads alike. Format(format, ...values)takes Java's conversions:%s(%Sin upper case),%d,%f,%e,%x,%o,%b(a boolean),%@(any value as JSON),%%and%n, with the flags-,+, space and0, a width, a precision and positions (%2$s). Numbers are rounded as PHP'ssprintf()does, which may differ from Java on the last digit (%.1fof 2.25 is2.2).Repeat,Spaceand the widths ofFormatmake strings of at most 16 MB: beyond, PHP would run out of memory, a fatal error no query could catch.IsString,IsNumber,IsInteger,IsDouble,IsBoolean,IsNull,IsArray,IsObject,IsRef(of a document, a collection or an index),IsCollection,IsIndex,IsDoc,IsLambda,IsTimestampandIsSetRef(FQL'sIsSet:issetis a word of PHP) check types. PHP does not tell an empty list from an empty object:[]is both.ToStringwrites strings, numbers (doubles keep their fraction:"1.0"), booleans, null and instants;ToNumberreads numbers ("42"is an integer,"4.2"and"1e3"doubles);ToIntegertruncates;ToDouble.- They need the mbstring extension of PHP, and
Casefoldthe intl extension too.
Stored functions
use function Flora\{Call, CreateFunction, FunctionRef, Functions, Query}; $client->query(CreateFunction([ 'name' => 'transfer', 'body' => Query(Lambda(['from', 'to', 'amount'], IfElse( LT(Select(['data', 'balance'], Get(Variable('from'))), Variable('amount')), Abort('Insufficient funds.'), [ Update(Variable('from'), ['data' => ['balance' => Subtract(Select(['data', 'balance'], Get(Variable('from'))), Variable('amount'))]]), Update(Variable('to'), ['data' => ['balance' => Add(Select(['data', 'balance'], Get(Variable('to'))), Variable('amount'))]]), ], ))), ])); $client->query(Call(FunctionRef('transfer'), $from, $to, 100)); // or Call('transfer', ...) $client->query(Update(FunctionRef('transfer'), ['body' => Lambda(...)])); // or ['name' => ...] $client->query(Delete(FunctionRef('transfer')));
- A function is a
Lambda(), stored in the database: its body is kept as written, inQuery()as in FQL, or given directly. It sees none of the variables of the query creating or calling it, and runs in the query calling it: its writes are part of it, and insideAtit reads the past. Call(function, ...arguments)takes aFunctionRef()(FQL'sFunction, a word of PHP) or a name. A function taking one parameter receives the argument, one destructuring several (Lambda(['a', 'b'], ...)) the list of the arguments. Functions call functions, themselves too, at most 64 calls deep.- The body is stored as JSON: each expression with its class and properties, refs and instants tagged
(
Support\Serializer). It can hold any expression and value written in a query, but not what a query makes while it runs (sets, pages, evaluated lambdas): those are built inside the function. Reading a body back only builds expressions of Flora. - A query loads a function once, however many times it calls it.
The schema
use function Flora\{Collections, Functions, Indexes}; $client->query(Paginate(Collections())); // [Collection('accounts'), Collection('users')] $client->query(Map(Paginate(Indexes()), Lambda('index', Get(Variable('index'))))); // the definitions $client->query(Get(Index('users_by_email'))); // name, source, terms, values, unique, ts $client->query(Exists(FunctionRef('transfer')));
Collections(),Indexes()andFunctions()are the sets of the refs of the schema, sorted by name: paginated, filtered, counted like other sets. The schema has no history: insideAt, they are those of now.GetandExiststake the refs of collections, indexes and functions;Select('name', $ref)reads their name.
Writing in bulk
use function Flora\Each; // Delete the guests, a page at a time: each page in a query, and so in a transaction. do { $page = $client->query(Each( Paginate(MatchIndex(Index('users_by_role'), 'guest'), ['size' => 100]), Lambda('ref', Delete(Variable('ref'))), )); } while ($page->after !== null); // A year older each: the items of a page of ages_by_role are its values, the age and the ref. Each(Paginate($admins), Lambda(['age', 'ref'], Update(Variable('ref'), ['data' => ['age' => Add(Variable('age'), 1)]])));
Each(FQL'sForeach) runs a lambda on each item of a list or a page, for what it does, and returns the list or the page. Like any query, it writes all or nothing.- Like FQL, it takes a list or a page, not a set: writing to the documents of a set while reading it would move their
entries in its indexes, and some would be seen twice and others not at all. Paginate, and run
Eachon each page.
Time
use function Flora\{Epoch, Now, TimeAdd, TimeDiff, Timestamp, TimeSubtract, ToMicros}; $client->query(Now()); // the time of the query, a DateTimeImmutable in UTC $client->query(Timestamp('2026-10-11T14:00:00+02:00')); // 2026-10-11 12:00:00 UTC $client->query(Create(Collection('people'), ['data' => ['born' => Timestamp('1815-12-10T00:00:00Z')]])); $client->query(At(Timestamp('2026-10-01T00:00:00Z'), Paginate(MatchIndex(Index('users_by_role'), 'admin')))); // Documents written in the last hour: the ts of a document counts microseconds. $client->query(Paginate(Filter(Documents(Collection('people')), Lambda('ref', GT(Select('ts', Get(Variable('ref'))), ToMicros(TimeAdd(Now(), -1, 'hour'))), )))); $client->query(Epoch($document['ts'], 'microseconds')); // the ts of a document, as an instant $client->query(TimeDiff(Epoch($document['ts'], 'microseconds'), Now(), 'days')); // its age, in whole days
Now()is the time of the query, the same everywhere in it: the ts of its writes.Timestamp(text)(FQL'sTime) reads an ISO 8601 date and time with an offset;Timestamp('now')isNow(). Both areDateTimeImmutablein UTC, to the microsecond, and impossible dates (2026-02-30T...) throw rather than roll over.- Documents store instants, from these functions or
DateTimeInterfaceof PHP, as{"@ts": "...Z"}, and give them back asDateTimeImmutable. Their fixed width makes indexes sort them by time and match them exactly. Attakes an instant or microseconds;LT,LTE,GT,GTEcompare instants,Equalscompares them whatever their time zone.ToMicros(time)gives the microseconds since 1970-01-01T00:00:00Z of an instant,Epoch(amount, unit)the instant that many units after it, andTimeAdd(time, offset, unit)moves an instant (backward when the offset is negative),TimeSubtract(time, offset, unit)backward.TimeDiff(start, finish, unit)counts the whole units fromstarttofinish, truncated toward zero: negative whenfinishcomes first. Units are FQL's, singular or plural:day,half day,hour,minute,second,millisecond,microsecond,nanosecond(truncated to microseconds). Months and years are not units: their length varies.
Dates
use Flora\Value\Date; use function Flora\{DayOfWeek, Month, ToDate, ToTime, Year}; $client->query(ToDate('2026-10-11')); // a Flora\Value\Date $client->query(ToDate(Now())); // today, in UTC $client->query(Create(Collection('people'), ['data' => ['birthday' => new Date('1815-12-10')]])); $client->query(Paginate(Between(MatchIndex(Index('birthdays')), ToDate('1900-01-01'), ToDate('1949-12-31')))); $client->query(TimeDiff(ToDate('2026-01-01'), ToDate('2026-10-11'), 'days')); // 283 $client->query([DayOfWeek($time), Month($time), Year($time)]); // of an instant or a date, in UTC
- A date is a day of the calendar, without time:
Flora\Value\Date, whosevalueis2026-10-11.ToDate(also FQL'sDate, which would take the place of PHP'sdate()) reads one from text, or gives the day of an instant in UTC;ToTimegives the instant a date starts at, in UTC, or reads an instant from text. - Documents store dates as
{"@date": "2026-10-11"}: indexes sort and match them, andBetweenreads ranges of them. DayOfMonth,DayOfWeek(1 is Monday, 7 Sunday),DayOfYear,Month,Year,Hour,MinuteandSecondread instants and dates, in UTC; a date starts at midnight.TimeAdd,TimeSubtractandTimeDifftake dates too, in days. Dates compare with dates, not with instants: convert one withToDateorToTime.ToString,FormatandIsDateknow them.
History
use function Flora\{At, Events, Insert, Remove}; $created = $client->query(Create(Collection('users'), ['data' => ['name' => 'Ada', 'role' => 'guest']])); $client->query(Update($created['ref'], ['data' => ['role' => 'admin']])); // Every write of a document is an event: {ts, action (create, update or delete), document, data}. $client->query(Paginate(Events($created['ref']))); // Every change of a set is an event: {ts, action (add or remove), document, data (the values of the entry)}. $client->query(Paginate(Events(MatchIndex(Index('users_by_role'), 'admin')))); // Rewrites history: an event is an action and the whole document after it. The document becomes what its last event // says, so rewriting the last one changes it. $client->query(Insert($created['ref'], $created['ts'] + 1, 'update', ['data' => ['name' => 'Ada Byron', 'role' => 'guest']])); $client->query(Remove($created['ref'], $created['ts'] + 1, 'update')); // Reads as the database was at a time, in microseconds like the ts of documents. $client->query(At($created['ts'], Get($created['ref']))); // role: guest $client->query(Get($created['ref'], $created['ts'])); // the same $client->query(At($created['ts'], Paginate(MatchIndex(Index('users_by_role'), 'guest'))));
- Each collection has a history,
flora_history_<collection>: every write adds an event, at the time of its query. Several writes of a document in a query are one event: creating and updating it is creating it, creating and deleting it leaves no trace. History costs one more statement per write. - History is kept for ever, unless the collection says otherwise:
CreateCollection(['name' => 'logs', 'history_days' => 30]), orUpdate(Collection('logs'), ['history_days' => 7])later;Get(Collection('logs'))reads it.$client->expireHistory(), run regularly (a scheduled task), removes the events older than that, keeping the version each document had then: documents, and reads in the past within those days, do not change. Unlike FaunaDB, which kept 30 days by default and expired history in the background, nothing expires unless asked. Events(ref)is the history of a document, read by time like a set:Paginate(both ways),Size,Filter,Reduce. InsideAt, it ends at that time.Events(set)is the history of a set: anaddwhen an entry entered it, aremovewhen it left, sorted by time, document, removals first. Changing the values of an entry removes it and adds the new one at the same time. It is computed as each page is read:- for a set of one collection without joins, from the versions of its documents: a page costs about the size of the history of the set (75 ms for a set of 200 documents with 3 versions each, 0.5 to 0.7 s for a whole collection of 60,000 versions);
- for a set with joins, whose entries also depend on other documents (a post leaves the posts of admins when its author stops being one), or of several collections, from the set as it was each time a collection it reads was written: a page costs a read of the whole set per write (0.2 to 0.7 s for 300 writes and a set of 40 posts).
Insert(ref, ts, action, params)adds an event (create,updateordelete, with the whole document after it) to the history of a document, replacing the event at that time;Remove(ref, ts, action)removes one. The document then becomes what its last event says, with its indexes: inserting an event after the others changes it, removing the last one brings back the previous version, removing a deletion brings the document back, and without events it does not exist.At(ts, expr)evaluatesexpras the database was atts: documents are read from their history, and sets from a snapshot of their collections (the last event of each document untilts), withPaginate,Size,Get,Exists, unions, joins, indexes unrolling fields... The snapshot has no index: reading a set in the past costs more as history grows.Atonly reads: writing inside it throws.
Composing queries
use function Flora\{Equals, IfElse, Lambda, Let, Map, Select, Variable}; $client->query(Map( Paginate(MatchIndex(Index('users_by_role'), 'admin')), Lambda(['age', 'name', 'ref'], Select(['data', 'email'], Get(Variable('ref')), 'none')), )); $client->query(Let( ['user' => Get(MatchIndex(Index('users_by_email'), 'ada@example.com'))], IfElse( Equals(Select(['data', 'role'], Variable('user')), 'admin'), Update(Select('ref', Variable('user')), ['data' => ['last_seen' => time()]]), null, ), ));
Arrays are evaluated too, so $client->query([Get($a), Get($b)]) reads both documents in one transaction.
use function Flora\{SelectAll, Sequence, Singleton}; $client->query(Sequence(Update($ref, ['data' => ['seen' => true]]), Get($ref))); // the value of the last one $client->query(SelectAll(['data', 'tags', 'name'], Get($post))); // the name of each tag $client->query(Paginate(Difference(MatchIndex(Index('users_by_role'), 'admin'), Singleton($me))));
Sequence(FQL'sDo, a word of PHP) evaluates expressions in order and returns the value of the last one, where an array returns them all.SelectAll(path, from)returns every value at a path: a field of the path is read in each item of a list it meets, an index in the list itself, and missing values are skipped.SelectAll(['foo', 0], [['foo' => [0, 1]], ['foo' => [2, 3]]])is[0, 2].Singleton(ref)is the set of one document, while it exists: it combines with other sets, and itsEventsare the document entering the set when created and leaving it when deleted.
Differences with FQL
| FQL | Flora | Why |
|---|---|---|
Match |
MatchIndex |
match is a PHP keyword |
Var |
Variable |
var is a PHP keyword |
If |
IfElse |
if is a PHP keyword |
And |
All |
and is a PHP keyword |
Or |
Any |
or is a PHP keyword |
Foreach |
Each |
foreach is a PHP keyword |
Count |
Size |
count() is a PHP function |
Max |
Greatest |
max() is a PHP function |
Min |
Least |
min() is a PHP function |
Abs |
Absolute |
abs() is a PHP function |
Time |
Timestamp |
time() is a PHP function |
Date |
ToDate |
date() is a PHP function |
Range |
Between |
range() is a PHP function |
Trim, LTrim, RTrim |
TrimStr, LTrimStr, RTrimStr |
trim(), ltrim() and rtrim() are PHP functions |
IsSet |
IsSetRef |
isset is a PHP keyword |
Function |
FunctionRef |
function is a PHP keyword |
Do |
Sequence |
do is a PHP keyword |
Function names are case-insensitive in PHP: a function imported with use function takes the place of the PHP
function of the same name in that file, and would silently return an expression. Flora's functions therefore avoid the
names of PHP's, except Join: importing it hides join(), the alias of implode(). Frameworks define global
functions too: in a Laravel application, importing Now hides the now() helper in that file.
Not there yet: other databases.
Errors
All exceptions implement Flora\Exception\FloraException: NotFound, AlreadyExists, NotUnique,
InvalidArgument, Aborted and Contended. A failing query rolls back all of its writes.
Contended means that the query ran into other transactions: it waited too long for a lock (SQLite's busy timeout,
innodb_lock_wait_timeout, lock_timeout), or the database stopped it to break a deadlock. It wrote nothing and can
be run again, or answered with a 503.
NotUnique::$index names the unique index the write broke, to tell which value is taken:
try { $client->query(Create(Collection('users'), ['data' => ['username' => 'grace', 'email' => 'ada@example.com']])); } catch (NotUnique $e) { $e->index; // "users_by_email" }
Drivers
A database is supported by implementing Flora\Driver\Driver: collection and index
creation, reads and writes by id, and an index scan with cursors. The query language does not depend on it, and all
drivers pass the same integration tests.
| PostgreSQL (12+) | MySQL (8.0.17+) | MariaDB (10.11+) | SQLite (3.38+) | |
|---|---|---|---|---|
data column |
JSONB |
JSON |
JSON (checked text) |
TEXT (checked JSON) |
| Index | expression index on the paths | indexed virtual columns | indexed virtual columns | indexed virtual columns |
36 and 36.0 |
the same term | the same term | the same term | the same term |
| Object terms | key order ignored | key order ignored | key order ignored | key order matters |
| String order | the database collation | code point | code point | code point |
| Lists, objects | by size, then items; [] first |
by JSON text | by JSON text | by JSON text |
| Large integers | exact | sorted as doubles | sorted as doubles | exact |
| Nesting depth | 512 levels (PHP's JSON) | 100 levels | 31 levels | 512 levels (PHP's JSON) |
| Long values | index entries up to ~2 KB | keys cut at 128 chars | keys cut at 128 chars | no limit |
| Object keys | sorted by JSONB | sorted by MySQL | kept as written | kept as written |
| Schema changes | transactional | commit the transaction | commit the transaction | transactional |
Virtual columns: MySQL, MariaDB and SQLite
MysqlDriver, MariadbDriver and
SqliteDriver share AbstractVirtualColumnsDriver,
and differ only in the SQL that declares tables and indexes and reads JSON. Each driver checks in install() that the
database is the right one, and recent enough.
- Each term is a virtual column holding a key of the JSON value. Each value is three virtual columns (JSON type,
number, string), so entries sort by type then value, as on PostgreSQL. Lists and objects sort by their JSON text,
where PostgreSQL sorts them by size, then item by item, and puts the empty list before
null: index values holding lists or objects sort differently from one database to another, documents with them are found the same. - Pages are read with keyset pagination on the index: a page far in a set costs about the same as the first one. When
the values of an index go different ways (one
reverse, one not), a page also reads the entries sharing its first value.
MySQL and MariaDB (AbstractMysqlDriver):
- Keys are cut at 128 characters; the JSON values are compared too, so matches are always exact. Values whose first 128 characters are equal sort as equal (by the values after them in the index, then by ref), and collide in a unique index.
- Both commit the current transaction before
CREATE TABLE,ALTER TABLEandDROP TABLE: in a query that runsCreateCollection(),CreateIndex(), orUpdate()orDelete()on an index or a collection, the writes made before are committed even if the query fails afterwards. The writes made after are still rolled back. A deleted index or collection stays deleted: its definition is deleted before its tables and columns, and committed with them. - An index holds at most 16 columns: count one per term, three per value, plus the ref.
- Numbers sort as doubles: integers beyond 2^53 that differ by little sort as equal, by ref. They are stored and matched exactly.
- Documents nest lists and objects at most 100 levels deep on MySQL, 31 on MariaDB: deeper ones are refused with
InvalidArgument. - Prepared statements work emulated (PDO's default) or native.
SQLite:
- Keys are never cut, and integers keep their exact value.
- Objects used as terms are compared as written:
{"a": 1, "b": 2}and{"b": 2, "a": 1}are different terms (refs are always written the same way). - SQLite has a single writer: a query holds the database until its transaction ends. Transactions begin
IMMEDIATE: a query takes the write lock before it reads, waiting for the others up to the busy timeout of the connection (PDO::ATTR_TIMEOUT, 60 seconds by default), rather than failing when it writes after reading. Queries that only read wait as well.
Tests
docker compose up -d # PostgreSQL on port 54329, MySQL on 33069, MariaDB on 33079 composer test
The integration tests in tests/Integration/*TestCase.php run on each driver (tests/Integration/Postgres,
tests/Integration/Mysql, tests/Integration/Mariadb, tests/Integration/Sqlite); a driver's tests are skipped when
its database is not reachable. SQLite runs in memory, without Docker.
Two tests run on data made to catch what databases do differently (tests/Integration/AuditFixture.php: numbers
near the limits of integers and doubles, strings in several scripts, null and missing fields, lists, objects, refs,
instants, dates, as the values of indexes):
SetInvariantsTestCase, on each database: every kind of set (matches, unions, joins, ranges, distinct, filtered...) read page by page, forward and backward, with pages of several sizes, gives the whole set once and in order;Size,ReverseandIsEmptyagree with it.CrossDatabaseTest, on the four databases at once: the same queries give the same entries, in the same order, the same matches and the same events; where they differ, it is as the table of the Drivers section says, which it checks too.
composer analyse # PHPStan
PHPStan checks the library at its strictest level (phpstan.dist.neon), and the tests at level 8
(phpstan.tests.neon): what queries return is mixed by nature.
The GitHub Actions workflow (.github/workflows/ci.yml) runs PHPStan, and the tests on
PHP 8.4 and 8.5 against the databases of compose.yaml, as services. There, a skipped test fails the build: a
database that did not start would otherwise go unnoticed.
License
Flora is released under the MIT License.