Search by

rmb32 / barncept

rogerbarnfather

A concept graph manager

Package info

bitbucket.org/rmb32/barncept

pkg:composer/rmb32/barncept

Statistics

Installs: 268

Dependents: 14

Suggesters: 0

v5.1.0 2026-10-26 21:17 UTC

This package is auto-updated.

Last update: 2026-10-28 19:48:35 UTC


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.

The Barncept GUI editing the House concept: a tree of thoughts on the left, and the selected thought, Living room, with its meaning and the Room concept it borrows.

Requirements

  • PHP 8.5+
  • The ds extension, ^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: Friday fills Dinner 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

ConceptPart (always there)Slot (has to be filled)Choice (fills a slot)
A layered applicationApplication, Domain, InfrastructureAggregateBasket is an Aggregate, with the three layers beneath it
A package's source treesrc, testsFeatureBilling is a Feature
An organisationBudget, Hiring planTeamThe platform team is a Team
A school courseAssessmentModule, Lesson"Fractions" is a Lesson in the "Number" Module
A novelSettingCharacter, ChapterThe detective is a Character
A gardenCompost heapBedThe 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:

KindLabelWhat you getFor
textTreeone .txt file: the indented tree abovea quick look
markdownDocumentone .md file: a heading per thought, its meaning beneathpeople
folderFoldersa folder per thought, each with a README.mddevelopers and tools
mermaidDiagramone .mmd file: a Mermaid flowcharta 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 one FolderNameException whose problems() 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 init to set up a project, barncept check to check every concept in CI, barncept export to print or write a projection, and barncept gui to 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.