rmb32 / barncept
A concept graph manager
Requires
- php: ^8.5
- ext-ds: ^2.0
- php-ds/php-ds: ^2.0
- rmb32/typed-json: ^1.0
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
README
A concept graph manager. Come up with a concept, fill it with your thoughts, then reuse those concepts in bigger concepts.
A Concept is a broad, structured collection of thoughts about one thing, kept
as plain PHP or JSON: what each part contains, which slots have to be filled,
and what fills them. Resolve it into a concrete, named tree that you can print,
turn into folders, or project onto anything else. Borrow it whole inside a
bigger Concept: a House can borrow the Room concept three times.
A concept can be about anything: the layers of a software system, the modules of a course, the teams in an organisation or the courses of a dinner.
The easiest way to write and browse Concepts is Barncept GUI, in your browser. This package is the library underneath it, for PHP code, scripts and AI agents.

Requirements
- PHP 8.5+
- The
dsextension,^2.0
Installation
composer require rmb32/barncept
To edit Concepts in the browser as well, add the CLI and the GUI:
composer require --dev rmb32/barncept-cli rmb32/barncept-gui
vendor/bin/barncept gui # opens the GUI in your browser
Sixty seconds
Write a plan, say what fills each slot, and resolve it
(examples/quickstart.php, run by this package's
own tests):
use Rmb32\Barncept\Api\Factory\ConceptFactory;
use Rmb32\Barncept\Api\Factory\ProjectorRegistryFactory;
use Rmb32\Barncept\Api\Factory\ResolverFactory;
use Rmb32\Barncept\Api\ValueObject\Projection\FileProjection;
use Rmb32\Barncept\Api\ValueObject\Projection\ProjectionKind;
$concept = new ConceptFactory()->create([
'name' => 'Dinner Party',
'description' => 'An evening meal for guests.',
'root' => [
'kind' => 'slot',
'slots' => ['Starter', 'Main'],
],
'parts' => [],
'slots' => [
[
'key' => 'Starter',
'name' => 'Starter',
'meaning' => 'Served first',
'parts' => [],
'slots' => [],
],
[
'key' => 'Main',
'name' => 'Main',
'meaning' => 'The centre of the meal',
'parts' => [],
'slots' => [],
],
],
'choices' => [
[
'key' => 'Friday',
'name' => 'Friday with the Hendersons',
'meaning' => 'Six guests, one of them vegetarian',
'slot' => 'Dinner Party',
'choices' => [
'Starter' => ['Soup'],
'Main' => ['Risotto'],
],
],
[
'key' => 'Soup',
'name' => 'Tomato soup',
'meaning' => 'Made the day before',
'slot' => 'Starter',
'choices' => [],
],
[
'key' => 'Risotto',
'name' => 'Mushroom risotto',
'meaning' => 'Vegetarian, so everyone eats it',
'slot' => 'Main',
'choices' => [],
],
],
]);
$entry = $concept->entryPoints()->first();
$friday = new ResolverFactory()->create()->resolve($concept, $entry);
$tree = new ProjectorRegistryFactory()->create()
->get(ProjectionKind::new('text'))
->project($friday);
if ($tree instanceof FileProjection) {
echo $tree->content();
}
Friday with the Hendersons
|
+--Tomato soup
+--Mushroom risotto
That is the whole shape of the library: a Concept says what a thing is made of, an entry point says where to start, and resolving gives you a tree you can print, or project as a document, folders or a diagram (projecting).
What it does and does not do
It checks the shape of a concept file — every field, every type, every unknown key, reported all at once with the path that caused it — and then the graph: that every reference points at something, that nothing loops, that nothing is orphaned, and that every binding names a slot that is offered and a choice that fills it.
It is not a validator for your domain's rules. A Concept says a dinner party needs a starter; whether tomato soup is a sensible starter in August is yours to decide.
Concept edits are immutable: the edit operations return a changed copy, leaving the original Concept available to its caller. Applications decide whether and how to persist that copy and whether changes should create a history; Barncept does not impose a revision policy.
It writes nothing you did not ask for. It reads and writes concept files through its own store (storage), and writes a projection only when you hand it to the writer, which never overwrites or merges. Folders laid out as a PHP codebase, with namespaces and class files, are Barnscaff's.
It ships no architecture templates. Domain Driven Design, Feature Based
Architecture, Model View Controller, Library and Package App belong to
Barnark, which registers its
own catalogue as a read-only source beneath yours. What ships here is a few
examples in concepts/: the dinner party above, a Pet / House Cat pair
that shows borrowing, and a larger House built from several borrowed
concepts.
The model
A Concept is a broad, structured collection of thoughts about one thing. It is kept as a file, has a name and a description, and other Concepts can borrow it whole. A Concept is made of thoughts: each one is a named piece of the Concept with a plain meaning, and there are three kinds:
- Part: a part that is always there. Every dinner party has a shopping list. A Part can hold other Parts and Slots beneath it.
- Slot: a slot that has to be filled. A dinner party needs a starter, a main and a dessert. A Slot can also hold Parts and Slots beneath it.
- Choice: a concrete thing that fills one slot. Tomato soup is the choice for Starter. A Choice also names the choices that fill the slots beneath it.
Every thought has a key that other thoughts refer to it by, a name for
people to read and a meaning, which is one sentence on what it is.
A Concept is a plan. One with every slot still open is a blank plan: a
reusable vocabulary nothing can be resolved from yet. One whose slots are all
filled is settled. There is no flag for this — openSlots() being empty is
what settled means.
Resolving starts from an entry point: a Choice nothing else in
the Concept binds. $concept->entryPoints() lists them — zero for a blank
plan, one for most, and several where a root offers several slots. Resolving
from one produces an Instance tree.
Every Concept has a name, which identifies it in a registry, and a description: what it is and what it is for, so someone choosing between Concepts knows why each one exists. Neither can be blank.
A Concept is its root. The root is written inline, without a key, name
or meaning of its own: the Concept's name is its key and name, and its
description is its meaning. It lists what hangs directly off the Concept, and
every other Part and Slot must be reachable from it, so a Concept
can never quietly contain an orphan. A root is one of two kinds:
- a Part (
"kind": "part", the default): fixed structure that other Concepts borrow as a part. A Room, a Garden, an architecture. - a Slot (
"kind": "slot"): the Concept is itself an open slot, and a Choice that fills it — written"slot": "<the Concept's name>"— is a whole, resolvable instance of it. The dinner party above is one:FridayfillsDinner Party.
Other thoughts refer to the root by the Concept's name, so no other thought
may be keyed with it. Read them back with $concept->name(),
$concept->description(), $concept->root() (a PartKey or a
SlotKey) and $concept->rootThought().
Edit a Concept by copy
Concept::withChoice() returns a new Concept with a Choice added or replaced
by its key. Choice::withChoice() returns a new parent Choice with another
Choice bound to one of its slots. Removing a bound Choice is two explicit
steps: remove its binding from the parent first, then call
Concept::withoutChoice(). Use Concept::withChoices() when adding a nested
Choice and its parent binding together; the final graph is validated as one
edit. The original objects remain unchanged.
use Ds\Map;
use Rmb32\Barncept\Api\ValueObject\Choice;
use Rmb32\Barncept\Api\ValueObject\Key\ChoiceKey;
use Rmb32\Barncept\Api\ValueObject\Key\SlotKey;
$billing = $concept->choice(ChoiceKey::new('Billing'));
$project = $concept->choice(ChoiceKey::new('Project'));
$sales = new Choice(
ChoiceKey::new('Sales'),
'Sales',
'The sales feature',
SlotKey::new('Feature'),
new Map(),
);
$withSales = $concept->withChoice($sales);
$withSales = $withSales->withChoice(
$project->withChoice(SlotKey::new('Feature'), $sales->key()),
);
$withoutBilling = $withSales->withChoice(
$project->withoutChoice(SlotKey::new('Feature'), $billing->key()),
);
$withoutBilling = $withoutBilling->withoutChoice($billing->key());
$newMember = new Choice(
ChoiceKey::new('Reports'),
'Reports',
'The reporting feature',
SlotKey::new('Feature'),
new Map(),
);
$projectWithReports = $project->withChoice(
SlotKey::new('Feature'),
$newMember->key(),
);
$withNestedMember = $concept->withChoices($projectWithReports, $newMember);
The same model elsewhere
| Concept | Part (always there) | Slot (has to be filled) | Choice (fills a slot) |
|---|---|---|---|
| A layered application | Application, Domain, Infrastructure | Aggregate | Basket is an Aggregate, with the three layers beneath it |
| A package's source tree | src, tests | Feature | Billing is a Feature |
| An organisation | Budget, Hiring plan | Team | The platform team is a Team |
| A school course | Assessment | Module, Lesson | "Fractions" is a Lesson in the "Number" Module |
| A novel | Setting | Character, Chapter | The detective is a Character |
| A garden | Compost heap | Bed | The herb bed is a Bed |
concepts/ holds the examples this package ships: example.json (the
dinner party above), pet.json and house-cat.json, and the larger
house.json with the room.json, garden.json and kitchen.json it
borrows. Architecture templates live in
Barnark.
Building one Concept on another
A Part or a Slot can borrow another whole Concept in place of naming its own children, so common structure and common choices are written once:
['key' => 'Cat', 'name' => 'Cat', 'meaning' => 'The family cat', 'type' => 'borrowed', 'concept' => 'House Cat'],
A borrow listed under parts names a Concept whose root is a
Part: a fixed part, whose slots stay open for the borrower to fill. One
listed under slots names a Concept whose root is a Slot: an
open slot, which the borrowed Concept's own Choice of its root fills
unless the borrower fills it. A mismatch throws
BorrowedConceptKindMismatchException when the Concept is composed.
The root can borrow too ("root": {"kind": "slot", "type": "borrowed",
"concept": "Pet"}): the Concept then is the borrowed one, extended. Its
thoughts arrive with no extra path segment, so House Cat binds Size, not
Pet::Size. A borrowed key that clashes with one the borrower already has
throws BorrowedKeyClashException; nothing is silently replaced.
Concepts are found by name through a registry over one or more folders of
*.json Concepts, and compose() flattens the borrows into one self-contained
Concept:
use Rmb32\Barncept\Api\Factory\ConceptComposerFactory;
use Rmb32\Barncept\Api\Factory\ConceptRegistryFactory;
$registry = new ConceptRegistryFactory()->create('/path/to/concepts');
$composed = new ConceptComposerFactory()->create($registry)->compose($concept);
Every borrowed thought's key becomes its borrow-site path: the ::-joined
site keys it was reached through, then its own key. So one Concept can be
borrowed any number of times, and a key says where in this plan a thought
sits rather than which file it came from. The composed Concept's own keys,
and those of a borrowed root, are unchanged. :: is therefore reserved: a name or key containing it is
rejected when the file is read.
Choices ride along with the structure, and are locked: a borrower fills
only what is still open, and re-filling a settled choice throws
Api\Exception\Concept\SlotAlreadyBoundException, naming the borrow
site that locked it. One place per decision:
"Pet" Size ?, Coat ?, Diet ? a blank plan
"House Cat" Size Small, Coat Fur borrows Pet
"Whiskers" Diet Fish borrows House Cat — settled
Whiskers resolves; change House Cat's Coat and every cat follows. Every
one of those three is a first-class Concept you can open, compose and (if it is
settled) resolve on its own. Sub-concepts has the
whole story, with a runnable example.
Checking a change
A borrow is read afresh each time its borrower is composed, so a change to one Concept reaches every Concept that borrows it, directly or through another borrow. That is intended, and it means a change can break a borrower: remove a slot a borrower fills, and the borrower no longer composes. The checker says so before the change is written:
use Rmb32\Barncept\Api\Factory\ConceptCheckerFactory;
$checker = new ConceptCheckerFactory()->create($registry);
$checker->statuses(); // every Concept, composed: broken or not, entry points, open slots
$checker->borrowersOf('Room'); // ['House', 'Kitchen']
$checker->impactOfSaving($room); // what saving this Room would change
$checker->impactOfDeleting('Room');
Each impact is a list of Api\ValueObject\ConceptChange: the Concept changed
and each of its borrowers whose status would change, with its status before and
after. breaks() is true for one that composes now and would not after,
mends() for the reverse, and newlyOpenSlots() lists slots that would open.
Nothing is written, and nothing is pinned: whether to go ahead is the caller's
choice. A broken status carries the first problem found in that Concept.
barncept check in Barncept CLI
prints every status, for CI.
Projecting a resolved tree
A resolved tree is projected for whoever will read it. Four projections are
built in, each a ProjectorInterface with a kind and a label:
| Kind | Label | What you get | For |
|---|---|---|---|
text | Tree | one .txt file: the indented tree above | a quick look |
markdown | Document | one .md file: a heading per thought, its meaning beneath | people |
folder | Folders | a folder per thought, each with a README.md | developers and tools |
mermaid | Diagram | one .mmd file: a Mermaid flowchart | a README, a pull request or a slide |
A projection starts from an entry point, so what you project is one
realisation (Friday with the Hendersons), never the plan with its slots
still open; Resolver::resolve() refuses an entry point with open slots
before any projector sees it
(examples/projections.php):
use Rmb32\Barncept\Api\Factory\ConceptFactory;
use Rmb32\Barncept\Api\Factory\ProjectorRegistryFactory;
use Rmb32\Barncept\Api\Factory\ResolverFactory;
use Rmb32\Barncept\Api\ValueObject\Projection\FileProjection;
use Rmb32\Barncept\Api\ValueObject\Projection\Folder;
use Rmb32\Barncept\Api\ValueObject\Projection\FolderProjection;
use Rmb32\Barncept\Api\ValueObject\Projection\ProjectionKind;
$thought = static fn (string $key, string $meaning): array => [
'key' => $key,
'name' => $key,
'meaning' => $meaning,
'parts' => [],
'slots' => [],
];
$choice = static fn (
string $key,
string $name,
string $meaning,
string $slot,
array $bindings = [],
): array => [
'key' => $key,
'name' => $name,
'meaning' => $meaning,
'slot' => $slot,
'choices' => $bindings,
];
$concept = new ConceptFactory()->create([
'name' => 'Dinner Party',
'description' => 'An evening meal for guests.',
'root' => [
'kind' => 'slot',
'parts' => ['Shopping list'],
'slots' => ['Starter', 'Main'],
],
'parts' => [$thought('Shopping list', 'Bought the day before.')],
'slots' => [
$thought('Starter', 'Served first.'),
$thought('Main', 'The centre of the meal.'),
],
'choices' => [
$choice(
'Friday',
'Friday with the Hendersons',
'Six guests.',
'Dinner Party',
['Starter' => ['Soup'], 'Main' => ['Risotto']],
),
$choice('Soup', 'Tomato soup', 'Made the day before.', 'Starter'),
$choice('Risotto', 'Mushroom risotto', 'Everyone eats it.', 'Main'),
],
]);
$friday = new ResolverFactory()->create()
->resolve($concept, $concept->entryPoints()->first());
$projectors = new ProjectorRegistryFactory()->create();
foreach (['markdown', 'mermaid'] as $kind) {
$file = $projectors->get(ProjectionKind::new($kind))->project($friday);
if ($file instanceof FileProjection) {
echo '--- ', $file->mediaType(), PHP_EOL, $file->content();
}
}
$folders = $projectors->get(ProjectionKind::new('folder'))->project($friday);
$paths = static function (Folder $folder, string $at) use (&$paths): void {
foreach ($folder->files() as $file) {
echo $at, $file->name(), PHP_EOL;
}
foreach ($folder->folders() as $child) {
$paths($child, $at . $child->name() . '/');
}
};
if ($folders instanceof FolderProjection) {
echo '--- folders', PHP_EOL;
$paths($folders->root(), $folders->root()->name() . '/');
}
--- text/markdown
# Friday with the Hendersons
Six guests.
## Shopping list
Bought the day before.
## Starter: Tomato soup
Made the day before.
## Main: Mushroom risotto
Everyone eats it.
--- text/vnd.mermaid
flowchart TD
n1["Friday with the Hendersons"]
n2["Shopping list"]
n1 --> n2
n3["Tomato soup"]
n1 -->|"Starter"| n3
n4["Mushroom risotto"]
n1 -->|"Main"| n4
--- folders
Friday with the Hendersons/README.md
Friday with the Hendersons/Shopping list/README.md
Friday with the Hendersons/Tomato soup/README.md
Friday with the Hendersons/Mushroom risotto/README.md
project() never touches the disk. A single-file projection is a
FileProjection (content(), mediaType(), extension()); the folders are a
FolderProjection, whose root() is a Folder of Files and Folders.
To put either on disk, use the writer:
use Rmb32\Barncept\Api\Factory\ProjectionWriterFactory;
new ProjectionWriterFactory()->create()->write($projection, 'docs/friday.md');
It never overwrites and never merges: a file must go to a path that does not
exist yet, and folders to one that does not exist or is an empty folder,
otherwise it throws ProjectionTargetExistsException. For folders, the path
is the root thought's folder.
- Document. The entry point is the
#title and its meaning the opening paragraph; each child is a heading one level deeper, down to######, and below that a nested list. A child that fills a slot is headed<slot>: <choice>("Starter: Tomato soup"). Meanings are written as they are, so they may use Markdown; names are escaped. - Folders. A folder name is a thought's name, checked rather than
changed: a name a filesystem will not take (empty,
.or.., one of/ \ : * ? " < > |, a control character, or a trailing dot or space), or two siblings that differ only in case, throws oneFolderNameExceptionwhoseproblems()lists every offending thought by its path. Rename the thought; nothing is renamed for you. - Diagram. Node ids are
n1,n2, … in walk order, never derived from a name, and a name is written so it can neither break out of its label nor be read as maths ($becomes#36;); an edge to a choice is labelled with the slot it fills, and meanings are left out. GitHub, GitLab and most Markdown viewers draw Mermaid as it is; for an SVG, pipe it into mermaid-cli (mmdc).
barncept export in Barncept CLI
prints or writes any of them, and Barncept GUI
shows and downloads them.
Adding a projection
Implement Api\Contract\ProjectorInterface and add it to the registry. A kind
is an open value object, so any package can name a new one:
final readonly class SlidesProjector implements ProjectorInterface
{
public function kind(): ProjectionKind
{
return ProjectionKind::new('slides');
}
public function label(): string
{
return 'Slides';
}
public function project(Instance $instance): ProjectionInterface
{
return new FileProjection($this->kind(), '…', 'text/markdown', 'md');
}
}
$projectors = new ProjectorRegistryFactory()->create()
->with(new SlidesProjector());
with() returns a new registry, with the projector added or put in place of
the one of the same kind. Return a FileProjection or a FolderProjection and
every host (the writer, the CLI, the GUI) handles it with no more work.
Saving a Concept
Turn a Concept back into the shape a concept file holds, for example to save edits:
use Rmb32\Barncept\Api\Factory\ConceptSerializerFactory;
$data = new ConceptSerializerFactory()->create()->serialize($concept);
A store does the writing for you, chooses the filename, and lets a read-only catalogue sit beneath the folder you write to — see storage.
Set up a project
Each project that keeps its own Concepts needs a folder for them. Add the CLI
and run init once from the project root:
composer require rmb32/barncept-cli
vendor/bin/barncept init # prompts for a concept folder (default: concepts/custom)
vendor/bin/barncept init --concepts=concepts/my # non-interactive
vendor/bin/barncept init --concepts=concepts/my \
--borrow-from=vendor/rmb32/barnark/resources/templates
This writes a barncept.json at the project root. Commit it. Read it back
from PHP:
use Rmb32\Barncept\Api\Factory\ProjectConfigLoaderFactory;
use Rmb32\Barncept\Api\Factory\ConceptRegistryFactory;
$config = new ProjectConfigLoaderFactory()->create()->load($projectRoot);
$config?->conceptDirectory(); // absolute path, or null if `init` hasn't been run
$config?->borrowFrom(); // the read-only sources, in order
$registry = new ConceptRegistryFactory()->fromConfig($config);
Handling errors
There are two kinds of problem, and they arrive differently.
A file that is the wrong shape throws one
Api\Exception\Validation\ConceptNotWellFormedException carrying every
fault at once, so an editor can show them all. Each fault has the dotted path
that caused it, a message and a stable code:
use Rmb32\Barncept\Api\Exception\Validation\ConceptNotWellFormedException;
try {
$concept = new ConceptFactory()->create($data);
} catch (ConceptNotWellFormedException $e) {
foreach ($e->faults() as $fault) {
echo $fault->path(), ' ', $fault->message(), ' (', $fault->code(), ')';
}
}
name Value 7 is not a valid string. (invalid_string)
parts.1.type Expected the thought type to be "local" or "borrowed" but got: "widget" (invalid_thought_type)
slots.0.extra Unexpected key `extra`. (unexpected_key)
A problem in the graph genuinely fails one at a time, and throws a leaf of
Rmb32\Barncept\Api\Exception\ConceptException: a thought keyed with the
Concept's own name throws
Api\Exception\Concept\ThoughtNamedAfterConceptException; a thought the root
does not reach, UnreachableThoughtException; a loop,
CyclicalDependencyException or CyclicalChoiceDependencyException; a
binding for a slot nothing offers, SlotNotOfferedException; a binding filled
by a choice that fills another slot, ChoiceFillsAnotherSlotException.
Resolver::resolve() throws OpenSlotsException when slots are still open
beneath the entry point it was asked for — it lists them.
Catch the abstract root to handle a whole family, or a leaf for one case; each leaf exposes its detail (the key involved, the cycle path) through getters.
Concept shape reference
[
'name' => string, // which Concept this is; unique in a registry; no '::'
'description' => string, // what the Concept is and what it is for; not blank
'root' => [ // the Concept itself, as a thought: no key, name or meaning
'kind' => 'part' | 'slot', // optional; 'part' by default
'parts' => string[], 'slots' => string[], // optional
'metadata' => [string => bool|int|float|string], // optional
// ... or, being another Concept of the same kind, extended:
// 'type' => 'borrowed', 'concept' => string, with no children
],
'parts' => [
['key' => string, 'name' => string, 'meaning' => string, 'parts' => string[], 'slots' => string[], 'metadata' => [string => bool|int|float|string]], // 'parts', 'slots', 'metadata' and 'type' => 'local' all optional
// ... or, borrowing a Part-rooted Concept whole in place of its own children:
['key' => string, 'name' => string, 'meaning' => string, 'type' => 'borrowed', 'concept' => string], // 'metadata' optional
],
'slots' => [
[same shape as a part; a borrowed one names a Slot-rooted Concept],
],
'choices' => [
[
'key' => string,
'name' => string,
'meaning' => string,
'slot' => string, // the Slot this fills; the Concept's name for its root
'choices' => [ // the choices that fill the slots beneath it,
string => string[], // keyed by the Slot they fill
],
'metadata' => [string => bool|int|float|string], // optional
],
],
'group' => string, // optional; a heading related Concepts are listed under; not blank
]
A group only gathers related Concepts under one heading, so a reader such as
Barncept GUI can list House, Room and Kitchen together. Leave it out for no
group. It has no effect on borrowing: a Concept that borrows another keeps its
own group.
Related packages
- Barnscaff: turn a resolved tree into a PHP codebase's folders and files, or check a folder against it (
barnscaff scaffold/barnscaff scan). - Barncept CLI:
barncept initto set up a project,barncept checkto check every concept in CI,barncept exportto print or write a projection, andbarncept guito open the GUI. - Barncept GUI: browse and edit Concepts in the browser, and view and download each projection.
Used in Barn
This package is used in Barn, where Barnark builds its architecture templates on it and Barnspec exports a project's story vocabulary as a Barncept Concept. Learn about Barn.
More docs
Sub-concepts · Storage · Data documents · Internals · History · Known issues
License
Proprietary. See LICENSE. Copyright (c) Roger Barnfather.