rmb32 / barntree
Keep a tree of things valid against the kinds of thing it may hold
Requires
- php: ^8.5
- ext-ds: ^2.0
- ext-mbstring: *
- php-ds/php-ds: ^2.0
- rmb32/barn-paths: ^0.1@dev
- rmb32/typed-json: ^1.0@dev
Requires (Dev)
- deptrac/deptrac: ^4.7
- infection/infection: ^0.35.4
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.3
- rector/rector: ^2.6
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-11 00:05:27 UTC
README
Early, version 0.x. Not ready for real projects yet; the minor version is the breaking one.
Keep a tree of things valid against the kinds of thing it may hold.
A Tree names the types of thing in one subject, and holds a tree of items that follow them. A type is a leaf, a list of one type or a map of several. A dinner party is a map of guests, courses and a shopping list. The guests are a list, and each guest is a leaf. Barntree builds the tree from the types, and refuses any document or edit that would break them.
A Tree can be about anything: the layers of a software system, the rooms of a house, the modules of a course or the courses of a dinner. It is kept as one plain JSON document, so any language can read it.
Templates, Constructions and saved revisions
Use a Template to define a reusable structure, and a Construction for a named tree of real members made from it. Every explicit save creates an immutable revision; unchanged content creates none. Templates and Constructions each have their own history. A Construction retains its source revision and its own copy of the definitions, so later Template edits cannot alter it.
use Rmb32\Barntree\Api\Factory\RevisionWorkspaceFactory;
use Rmb32\Barntree\Api\Factory\TemplateHistoryStoreFactory;
use Rmb32\Barntree\Api\ValueObject\DocumentKind;
use Rmb32\BarnPaths\Api\ValueObject\BarnPaths;
$paths = BarnPaths::at('/my/project');
$templates = new TemplateHistoryStoreFactory()->forBarn($paths);
$opened = $templates->checkout('My template');
$saved = $templates->saveTemplate('My template', $opened->content, $opened->token);
$template = $saved->revision;
$workspace = new RevisionWorkspaceFactory()->forBarn($paths);
$constructionDraft = $workspace->newConstruction($template->documentId, 'My project');
$construction = $workspace->save($constructionDraft);
$history = $workspace->history(DocumentKind::Construction, $construction->documentId);
Starting and editing drafts writes nothing. Edit draft->content with the existing
immutable Tree operations and pass the result to draft->withContent($edited).
Call save() when ready. Dropping a draft cancels it; revision->draft() starts again
from a saved state. A stale base is refused instead of overwriting a newer save.
A save may carry a reason, saveTemplate($name, $content, $token, 'Why'), which the
revision returns as reason. To keep a catalogue apart from the barn's, open it with
inDirectory('/my/folder') on either factory instead of forBarn().
TemplateHistoryStoreFactory::lockFiles() lists the lock files a catalogue writes, as
patterns relative to its folder (template-history/save.lock, ...), to keep out of version control.
Existing Template files and saved item identities are preserved. New Constructions
start from their source’s Skeleton with empty lists. Construction edits keep their captured
root, type definitions and tags while allowing valid members.
The GUI gives Templates and Constructions their own Tree, Overview and History views. Selecting a building block opens its details; explicit edits use Save and Cancel. Templates support structural editing, while Constructions add named list members within their captured rules. The CLI currently provides init, check, export and the optional GUI launcher; it does not yet expose Construction or revision-management commands.
Existing current-file readers continue to work. Checking out an existing Template through the history store records its saved baseline once, including when opened in the GUI. A later changed save records a new revision; unchanged saves add none. See the worked example and storage design.
Requirements
- PHP 8.5+
- The
dsextension,^2.0 - The
mbstringextension
Installation
composer require rmb32/barntree
Sixty seconds
Name the types. Barntree builds the tree of items that follows them, and edits
it only in ways that keep every rule
(examples/quickstart.php, run by this package's
own tests):
use Rmb32\Barntree\Api\Factory\TreeEditorFactory;
use Rmb32\Barntree\Api\Factory\TreeFactory;
use Rmb32\Barntree\Api\Factory\TextProjectorFactory;
use Rmb32\Barntree\Api\ValueObject\ItemName;
use Rmb32\Barntree\Api\ValueObject\ItemPath;
$tree = new TreeFactory()->create([
'formatVersion' => 1,
'name' => 'Shop',
'description' => 'A shop organised around its business.',
'root' => 'Project',
'types' => [
'Project' => [
'name' => 'src',
'meaning' => 'The whole codebase',
'kind' => 'map',
'of' => ['Kernel', 'Contexts'],
],
'Kernel' => [
'name' => 'Kernel',
'meaning' => 'What every context shares',
'kind' => 'leaf',
],
'Contexts' => [
'name' => 'Context',
'meaning' => 'The bounded contexts, one per area of the business',
'kind' => 'list',
'of' => 'Context',
],
'Context' => [
'name' => 'Context',
'meaning' => 'One bounded context',
'kind' => 'map',
'of' => ['Domain', 'Application'],
],
'Domain' => [
'name' => 'Domain',
'meaning' => 'The rules of the business',
'kind' => 'leaf',
],
'Application' => [
'name' => 'Application',
'meaning' => 'What the context can be asked to do',
'kind' => 'leaf',
],
],
]);
$editor = new TreeEditorFactory()->create();
$text = new TextProjectorFactory()->create();
$contexts = new ItemPath('Context');
$tree = $editor->addMember($tree, $contexts, new ItemName('Payment'));
$tree = $editor->addMember($tree, $contexts, new ItemName('Marketing'));
echo $text->project($tree);
$marketing = new ItemPath('Context', 'Marketing');
$tree = $editor->removeMember($tree, $marketing);
echo PHP_EOL, $text->project($tree);
src
|
+--Context
|
+--Payment
|
+--Application
+--Domain
+--Marketing
|
+--Application
+--Domain
+--Kernel
src
|
+--Context
|
+--Payment
|
+--Application
+--Domain
+--Kernel
The model
Types
Each type has a key, which other types refer to it by, a name for people to
read, a meaning that explains what it is, and a kind. A meaning should be
clear and concise, with enough detail for humans and AI to understand the tree.
Use as many sentences or paragraphs as needed.
The kinds are:
- a leaf is of nothing. A kitchen is a leaf.
- a list is
ofexactly one type. Bedrooms are a list of Bedroom. - a map is
ofa set of types, each at most once. A house is a map of Garden, Ground floor and Upstairs. Map children are alphabetical by display name, ignoring case, with the type key breaking ties. List members keep their sequence.
Existing documents remain readable. Reading, editing and writing a Tree puts map references and entries into alphabetical order without changing item IDs or list-member sequences. The document format remains version 1.
The Tree names its root type, which can be any kind. Types refer to each
other only by key, and one type can be used in many places. A type that holds
itself through maps alone is refused, because an item of it could never end.
Tags
A Tree can declare tags, each with a name and a meaning, and a type can
carry some of them. A tag says that types in different places belong together,
which the tree on its own can't say: the House tags its Kitchen and Bathroom
Plumbed, one on each floor. A type names only declared tags, each once, and
every item of the type carries them. A declared tag no type carries is kept.
Tags came in 6.2: an older Barntree refuses a document that has them.
Items
An item follows one type, and has an id that Barntree generates and
nobody types. What it holds depends on its type's kind:
- a map item holds one item for each type of its map;
- a list item holds its members, any number of them, each with a name you give it;
- a leaf item holds nothing.
Every item is an item of its own, list items included, so a list can be the root and each item is one level of the tree. The root item and the members have names. Any other item takes its type's name. Members of one list have different names, and so do the entries of one map, so a path of names from the root finds at most one item.
The skeleton
A document can leave its tree out, and most hand-written ones do. Reading it
then builds the skeleton: the root, and every map entry below it, down to
empty lists and leaves, each with a fresh id. Save the Tree once and its ids
stay the same from then on.
trees/house.barntree.json is written as types only. Read it, add two bedrooms, and
here is the tree, then each item as the document writes it
(examples/house.php; it counts ids so the output stays
the same, where the default makes 16 random hex characters):
use Rmb32\Barntree\Api\Contract\ItemIdGeneratorInterface;
use Rmb32\Barntree\Api\Factory\TreeEditorFactory;
use Rmb32\Barntree\Api\Factory\TreeSerializerFactory;
use Rmb32\Barntree\Api\Factory\TreeStoreFactory;
use Rmb32\Barntree\Api\Factory\TextProjectorFactory;
use Rmb32\Barntree\Api\ValueObject\ItemName;
use Rmb32\Barntree\Api\ValueObject\ItemPath;
use Rmb32\Barntree\Api\ValueObject\Key\ItemId;
$ids = new class () implements ItemIdGeneratorInterface {
private int $count = 0;
public function next(): ItemId
{
return ItemId::new('h' . ++$this->count);
}
};
$house = new TreeStoreFactory()
->create(__DIR__ . '/../trees', writable: false, ids: $ids)
->read('House');
$editor = new TreeEditorFactory()->create($ids);
$bedrooms = new ItemPath('Upstairs', 'Bedrooms');
$house = $editor->addMember($house, $bedrooms, new ItemName('Front bedroom'));
$house = $editor->addMember($house, $bedrooms, new ItemName('Back bedroom'));
echo new TextProjectorFactory()->create()->project($house), PHP_EOL;
$tree = new TreeSerializerFactory()->create()->serialize($house)['tree'];
echo 'root: ', $tree['root'], PHP_EOL;
foreach ($tree['items'] as $id => $item) {
echo $id, ': ', json_encode($item), PHP_EOL;
}
$found = $house->find(new ItemPath('Upstairs', 'Bedrooms', 'Back bedroom'));
echo PHP_EOL, 'Upstairs/Bedrooms/Back bedroom is ', $found->id(), PHP_EOL;
House
|
+--Garden
|
+--Beds
+--Shed
+--Ground floor
|
+--Hallway [Shared]
+--Kitchen [Shared, Plumbed]
+--Living room [Shared]
+--Upstairs
|
+--Bathroom [Shared, Plumbed]
+--Bedrooms
|
+--Front bedroom
+--Back bedroom
root: h1
h1: {"type":"House","name":"House","containing":{"Garden":"h9","Ground floor":"h2","Upstairs":"h6"}}
h2: {"type":"Ground floor","containing":{"Hallway":"h3","Kitchen":"h4","Living room":"h5"}}
h3: {"type":"Hallway"}
h4: {"type":"Kitchen"}
h5: {"type":"Living room"}
h6: {"type":"Upstairs","containing":{"Bathroom":"h7","Bedrooms":"h8"}}
h7: {"type":"Bathroom"}
h8: {"type":"Bedrooms","containing":["h12","h13"]}
h9: {"type":"Garden","containing":{"Beds":"h11","Shed":"h10"}}
h10: {"type":"Shed"}
h11: {"type":"Beds","containing":[]}
h12: {"type":"Bedroom","name":"Front bedroom"}
h13: {"type":"Bedroom","name":"Back bedroom"}
Upstairs/Bedrooms/Back bedroom is h13
A leaf item writes no containing, an empty list writes [], and only the root
and the members write a name.
Editing
TreeEditorInterface edits a Tree by the path of names to an item, and
returns a new Tree. The one you passed in is unchanged.
addMember($tree, $list, $name)adds a member to the list item at$list, with its own skeleton, so you name only the member.removeMember($tree, $member)removes a member and everything below it.renameMember($tree, $member, $name)renames a real list member while keeping its ID, kind and children. Roots and required map children cannot be renamed.changeTypes($tree, $root, $types)gives the Tree new types and fits its tree to them. An item whose type is still the one expected where it sits is kept, with its id and name. A map entry the new types call for is built, with its own skeleton. A member whose type its list no longer holds, and an entry whose type has left its map, are dropped with everything below them. A new root type rebuilds the root under the same name.$tree->find($path)returns the item at a path, andnew ItemPath()is the root.
changeTypes decides nothing for you: compare the trees before and after to
see what would be dropped, and keep the result only if that's what you want.
Projecting
A projector turns a Tree into something to read, without touching the
disk. Four are built in, each a ProjectorInterface with a kind and a label:
| Kind | Label | What you get |
|---|---|---|
text | Tree | the indented tree, each item's tags after its name (TextProjectorFactory makes this one alone) |
markdown | Document | a heading per item, with its tags after its name and its type's meaning beneath |
folder | Folders | a folder per item, each with a README.md |
mermaid | Diagram | a Mermaid flowchart, which GitHub draws as it is |
ProjectorRegistryFactory holds them in that order, and with() adds your own.
Every projection reads as text: a document as its content, folders as their
tree (examples/projections.php):
use Rmb32\Barntree\Api\Factory\TreeEditorFactory;
use Rmb32\Barntree\Api\Factory\TreeStoreFactory;
use Rmb32\Barntree\Api\Factory\ProjectorRegistryFactory;
use Rmb32\Barntree\Api\ValueObject\ItemName;
use Rmb32\Barntree\Api\ValueObject\ItemPath;
$editor = new TreeEditorFactory()->create();
$party = new TreeStoreFactory()
->create(__DIR__ . '/../trees', writable: false)
->read('Dinner Party');
$party = $editor->addMember($party, new ItemPath('Guests'), new ItemName('Ann'));
$party = $editor->addMember(
$party,
new ItemPath('Courses'),
new ItemName('Tomato soup'),
);
foreach (new ProjectorRegistryFactory()->create()->projectors() as $projector) {
if ($projector->kind()->value() !== 'text') {
echo '--- ', $projector->label(), PHP_EOL;
echo $projector->project($party);
}
}
--- Document
# Dinner Party
One evening meal for guests.
## Courses
What is served, in order.
### Tomato soup
One dish, served to everyone at once.
#### Recipe
How the dish is made, and how long it takes.
## Guests
Everyone invited.
### Ann
One person coming to eat.
## Shopping list
Bought the day before.
--- Folders
Dinner Party/
├── README.md
├── Courses/
│ ├── README.md
│ └── Tomato soup/
│ ├── README.md
│ └── Recipe/
│ └── README.md
├── Guests/
│ ├── README.md
│ └── Ann/
│ └── README.md
└── Shopping list/
└── README.md
--- Diagram
flowchart TD
n1["Dinner Party"]
n2["Courses"]
n1 --> n2
n3["Tomato soup"]
n2 --> n3
n4["Recipe"]
n3 --> n4
n5["Guests"]
n1 --> n5
n6["Ann"]
n5 --> n6
n7["Shopping list"]
n1 --> n7
A name a filesystem won't take as a folder (Why?, a trailing dot, two
siblings that differ only in case) is refused with one FolderNameException
listing every one. ProjectionWriterFactory writes a projection to a path that
doesn't exist yet, or folders into an empty folder. It never overwrites and
never merges.
Storage
TreeStoreFactory keeps Trees in a directory, one .barntree.json file
each, found by the name inside it. A store can be made read-only, for a catalogue that
ships with your code:
use Rmb32\Barntree\Api\Factory\TreeStoreFactory;
$store = new TreeStoreFactory()->create('trees', writable: false);
$store->names(); // Seq of every name, sorted
$party = $store->read('Dinner Party');
$store->delete('Dinner Party');
Storage has the whole story, with a worked example. If you
keep Trees somewhere else, TreeSerializerFactory turns a Tree back
into its document: json() writes it as text, and serialize() returns the
same document as PHP arrays.
When something is refused
A document that doesn't fit the shape is refused with one
TreeNotWellFormedException, whose faults lists every problem at once,
each with the path that caused it. A document that fits the shape but breaks a
rule, or an edit that would break one, is refused with the exception for that
rule. Each one extends TreeException, is checked, and carries what it is
about as public readonly properties
(examples/refusals.php):
use Rmb32\Barntree\Api\Exception\Tree\TreeNotWellFormedException;
use Rmb32\Barntree\Api\Exception\Tree\DuplicateMemberNameException;
use Rmb32\Barntree\Api\Exception\Tree\NotAListException;
use Rmb32\Barntree\Api\Exception\Tree\UnknownTypeReferenceException;
use Rmb32\Barntree\Api\Factory\TreeEditorFactory;
use Rmb32\Barntree\Api\Factory\TreeFactory;
use Rmb32\Barntree\Api\Factory\TreeStoreFactory;
use Rmb32\Barntree\Api\ValueObject\ItemName;
use Rmb32\Barntree\Api\ValueObject\ItemPath;
$trees = new TreeFactory();
try {
$trees->create(['name' => 'Garden', 'root' => 7]);
} catch (TreeNotWellFormedException $e) {
foreach ($e->faults as $fault) {
echo $fault, PHP_EOL;
}
}
try {
$trees->create([
'formatVersion' => 1,
'name' => 'Garden',
'description' => 'Ground outside the house.',
'root' => 'Garden',
'types' => [
'Garden' => [
'name' => 'Garden',
'meaning' => 'Ground outside the house.',
'kind' => 'list',
'of' => 'Pond',
],
],
]);
} catch (UnknownTypeReferenceException $e) {
echo PHP_EOL, $e->getMessage(), PHP_EOL;
echo 'Unknown: ', $e->typeKey, ', in: ', $e->referencedBy, PHP_EOL;
}
$editor = new TreeEditorFactory()->create();
$party = new TreeStoreFactory()
->create(__DIR__ . '/../trees', writable: false)
->read('Dinner Party');
$guests = new ItemPath('Guests');
$party = $editor->addMember($party, $guests, new ItemName('Ann'));
try {
$editor->addMember($party, $guests, new ItemName('Ann'));
} catch (DuplicateMemberNameException $e) {
echo PHP_EOL, 'Refused: a second ', $e->name, PHP_EOL;
}
try {
$editor->addMember(
$party,
new ItemPath('Shopping list'),
new ItemName('Bread'),
);
} catch (NotAListException $e) {
echo $e->getMessage(), PHP_EOL;
}
[formatVersion] Value *missing* is not a valid integer.
[description] Value *missing* is not a valid string.
[root] Value 7 is not a valid string.
[types] Cannot be empty and must be filled with a value matching `array<string, array{name: string, meaning: string, kind: 'leaf'|'list'|'map', of?: string|list<string>|null, tags?: list<string>}>`.
Type 'Garden' is of 'Pond', which is not one of the tree's types.
Unknown: Pond, in: Garden
Refused: a second Ann
The item at 'Shopping list' follows 'Shopping list', a leaf, so it can't hold members: only a list item can.
The document
{
"formatVersion": 1, the only version there is; any other is refused
"name": string, the Tree's name; not blank
"description": string, what it is and what it is for; not blank
"root": string, the key of the root type
"tags": { optional, in order: the tags types may carry
"<tag name>": { not blank
"meaning": string
}
},
"types": { at least one, by key
"<type key>": {
"name": string, not blank
"meaning": string,
"kind": "leaf" | "list" | "map",
"of": string | [string, …] a list's one type, or a map's types;
left out for a leaf
"tags": [string, …] optional: declared tags, each once
}
},
"tree": { optional: the skeleton is built without it
"root": string, the root item's id
"items": {
"<item id>": {
"type": string,
"name": string, the root item and members only
"containing": {"<type key>": "<item id>", …} a map item
| ["<item id>", …] a list item
left out for a leaf
}
}
}
}
What it does not do
It knows nothing of what your Tree is about. A Tree can say that a dinner party has courses; whether tomato soup is a good starter in August is yours to decide.
It keeps no history. An edit returns a new Tree, and when to save it, and whether to keep the old one, is up to you.
It writes nothing you didn't ask it to. A projector returns a projection, the
writer writes one only where nothing is, and the store writes only on
write() and deletes only on delete().
It also keeps data documents: plain values checked against a small schema. They are separate from Trees (data documents).
Used in Barn
This package is used in Barn, where Barnark keeps a project's folders in step with an architecture template built on it. Learn about Barn.
More docs
Storage · Data documents · Internals · History · Known issues
License
Proprietary. See LICENSE. Copyright (c) Roger Barnfather.