Skip to content

How Nafinity is extended

Nafinity has two extension mechanisms and no others. Not a hook manager beside an event system beside a filter chain — two, used for everything.

Registry   what exists          a plugin adds to a list the application renders
Event      what is happening    a listener takes part in something already running

Whether a third is ever needed is a question worth keeping closed. Before proposing one, the answer has to be: what can this do that a registry and an event cannot express? A second API for the same problem is not a reason.

Providers

A plugin contributes by implementing one interface and being registered once. Everything below happens inside register(), which runs after the board's own defaults and before the installation's extensions.php gets the last word.

use Naf\Board\Contracts\ExtensionProviderInterface;
use Naf\Board\ExtensionContext;

final class AcmeProvider implements ExtensionProviderInterface
{
    public function register(ExtensionContext $context): void
    {
        $context->priorities()->add(new PriorityDefinition('acme.blocker', 'Blocker', …));
    }
}

The context carries the container and the registries, never a current user: definitions are code, and user data is read in the request that needs it.

The registries

Every one of them takes definitions of one type, keys them by id, orders them by an index with the id as tie-breaker, and refuses a duplicate unless you pass replace: true. Learning one is learning all sixteen.

Registry What a plugin puts in it
permissions() Project actions, which then appear in the role editor
priorities() A fifth priority beside the board's four
ticketFields() Fields on a ticket, stored as ticket metadata
fieldTypes() How a kind of value is validated, stored and drawn
boardFilters() A filter, with its own SQL condition
estimationScales() A scale a project can pick
activityTypes() An entry kind for the history
exporters() A format a board can be written in
ui() Contributions into named slots and panels
navigation() Entries in the sidebar
views() A replacement for a named view
settings(), settingSections() Settings and the panels they live in
assets(), assetPackages() Stylesheets and modules, and where they are published from
aiTools() Tools offered to a local model

A plugin can also do everything any NAF plugin can: routes, controllers, commands, jobs, migrations, translations, and replacing a core service through the container.

The events

Six, for the whole application, and each of them is a class: dispatch(new Change(…)), listen(Change::class, …). A misspelled class is an error where it is written, while a misspelled event name used to be a listener that never ran and never said so.

Event Carries When
Change project, ticket, actor, type, payload Anything was written — 24 kinds, from ticket.moved to account.created
GrantsChanged actor, subject, scope, before, after Roles or permissions moved (from naf/rbac)
SignIn email, provider, outcome, account Somebody tried to sign in, successfully or not
ExportStarted format, project, columns An export is about to write its first record
ExportLine format, project, ticket, row One record, before it is written
ExportFinished format, project, columns, count An export wrote its last record

One write event rather than twenty-four is deliberate. The listeners that exist mostly want everything — the audit log and the live updates do — and a plugin that wants one kind writes one line:

event()->listen(Change::class, function (Change $change): void {
    if ($change->type !== 'ticket.moved') {
        return;
    }

});

SignIn exists because signing in writes no row, so Change structurally cannot carry it. Both outcomes travel on it, because the interesting one is usually the failure, and it carries the address as typed even when no account answers to it.

Splitting it would make the two listeners that want everything register twenty-four times to get it.

A listener can refuse

Change is dispatched inside the transaction that did the work. Throwing from a listener rolls the whole thing back:

event()->listen(Change::class, function (Change $change): void {
    if ($change->type === 'ticket.moved' && $this->isFriday()) {
        throw new Failure(t('Freitags wird nichts nach Fertig geschoben.'), 422);
    }
});

The ticket does not move, its version does not advance, and the person is told why. This is how a rule that no permission can express — one that depends on the data, the time or another system — gets to stop something.

It costs doing the work and undoing it, which for a rule engine is the right trade: the listener sees the finished state rather than a proposal, and that is usually what a rule needs to judge.

SignIn is the exception that cannot refuse, and is announced after its transaction on purpose. By then the session is published and the person is in; rolling that back would leave them signed in with no record of it. Refusing a sign-in is the authentication provider's job.

A worked example: changing an export

The case both mechanisms were sharpened on. A plugin keeps a state of its own on tickets, and one external system needs that state reported as done — without the board ever saying anything different.

Register the format:

$context->exporters()->add(new ExporterDefinition(
    id: 'acme.external',
    label: 'External system',
    extension: 'txt',
    mimeType: 'text/plain; charset=utf-8',
    writer: ExternalSystemExporter::class,
));

Then say what it reports:

event()->listen(ExportLine::class, static function (ExportLine $line): void {
    if (!$line->isFor('acme.external')) {
        return;
    }

    if (($line->data['acme.reviewed'] ?? false) === true) {
        $line->data['status'] = 'done';
    }
});

Three things that are not accidents.

$line->ticket is readonly, and PHP enforces it: reassigning it, editing a key, taking a reference and unset all raise an error. An export that edited the tickets it was reading would be the worst possible way to find out. What a listener changes is $line->data, a row that exists for the length of one download.

isFor() comes first. A mapping that was true of every format would also rewrite the spreadsheet the team reads, which is rarely what anybody means by "the external system needs done".

And the columns needed no registration. Every field a plugin registered through ticketFields() is a column in every format, with the field's own read permission still deciding whether this reader sees it.

What is deliberately not extensible

A ticket's status is open or closed, and no registry offers a third value.

It is not a vocabulary. A column marked as closing sets it, the board counts by it, and the schema has a constraint on it. The workflow states a team actually works in are the board's columns, which are project data — a team adds "Waiting for approval" in the interface, without a plugin.

A plugin that needs a state of its own puts it on the ticket as a field:

$context->ticketFields()->add(new TicketFieldDefinition(
    key:     'acme.approval',
    label:   'Approval',
    type:    'select',
    options: ['choices' => ['pending' => 'Pending', 'granted' => 'Granted']],
));

Stored, typed, permission-guarded, filterable and exportable — and, crucially, inert when the plugin goes away. Values of an absent plugin stay where they are and the application can name them. A value in status could not be inert: every count of open tickets is WHERE status = 'open', so tickets carrying a status nothing explains would silently vanish from the board's own arithmetic.

That is the test any proposed extension point has to pass. Not "can a plugin write this", but "what happens to the data when the plugin is gone".

The full reference

Every definition's parameters, the contracts, storage, the browser lifecycle for contributed widgets, and the negative cases — an unknown key, a reserved id, a value the type refuses — are documented with the code that implements them, in docs/Extensibility.md of naf/board.