Everything you need to build a Kopling extension: how it gets discovered, what one file is required, and every path convention that wires itself up for free. Revised openly as the extension system grows — check the date below.
An extension is PHP and templates. Full stop.
No build step, no JavaScript framework to learn, no upgrade roulette. If you've ever built a small Laravel package, you already know almost everything you need — Kopling's extension system leans on ordinary Composer and Laravel conventions wherever it can, and only introduces something new where the alternative would be worse.
Concretely: an extension is a normal Composer package. It gets discovered, its directories get auto-wired into Laravel, and it can declare a small number of specific capabilities — that's the whole shape of the system this page documents.
Two things in your extension's composer.json are what make it a Kopling extension at all:
"type": "kopling-extension" — this is how Kopling finds your package among everything else installed via Composer. Nothing else looks for a Kopling extension any other way.
A normal PSR-4 mapping, e.g. "YourVendor\\YourExtension\\": "src/" — the same block every Composer package already needs for its classes to load.
That's the entire registration step. There's no separate file to list your extension in, no id to remember and repeat elsewhere — your entry-point class is derived automatically from that same PSR-4 namespace (see the next section), and every directory convention is found by presence, not by declaration.
One file is required: src/Extension.php, always named Extension, always in the namespace your autoload.psr-4 block declares.
// src/Extension.php namespace YourVendor\YourExtension; use Kopling\Core\Extension\AbstractExtension; class Extension extends AbstractExtension { public static function name(): string { return 'Your Extension'; } public static function description(): string { return 'One sentence describing what it does.'; } }
Everything below works by a directory simply existing in your extension, next to src/. Nothing to declare, no interface to implement, no method to call — put files where they belong and Kopling registers them for you.
| Path | What it does | Notes |
|---|---|---|
| views/ | Blade templates | Auto-registered under a namespace built from your full package name, vendor included, hyphenated — yourvendor/example becomes yourvendor-example::, so a template renders as view('yourvendor-example::hello'). |
| lang/ | Translations | Same auto-registered namespace as views — __('yourvendor-example::messages.hello'). |
| migrations/ | Database migrations | Auto-registered, no database/ wrapper directory needed — just migrations/ directly. |
Routes, plain hand-written css, and plain js aren't directory conventions — each always needs a target Portal to attach to (which prefix/middleware a route inherits, which page's <head> a stylesheet renders on), so a bare "the directory exists" rule can't express them the way it can views/lang/migrations. See ExtendsPortals in Section 7.
Directory conventions (Section 4) cover everything Kopling can infer from your extension's filesystem. For anything else — a storage driver, a theme, a permission, a Portal, a UI placement, a load-order constraint — implement one of Kopling's contract interfaces on your Extension class. Implement only what your extension needs; each is checked independently. HasPermissions, HasPortals, and ChangesUx get their own sections below (6, 7, 8); everything else is documented here.
RequestsStorageDriver: declare a storage driveruse Kopling\Core\Extension\Contract\RequestsStorageDriver; use Kopling\Core\Storage\StorageRequest; class Extension extends AbstractExtension implements RequestsStorageDriver { // ...name(), description() as above /** @return array<StorageRequest> */ public function storage(): array { return [new StorageRequest()]; } }
ChangesTheme: ship a named themeA theme is an ordinary extension implementing ChangesTheme, overriding whichever CSS custom properties it wants on top of the compiled default:
use Kopling\Core\Extension\Contract\ChangesTheme; use Kopling\Core\Ux\Theme\Token; class Extension extends AbstractExtension implements ChangesTheme { // ...name(), description() as above -- this is what shows up as the theme's own name /** @return array<string, string> */ public function theme(): array { return [ Token::ColorPrimary->value => '#5b7fd6', Token::ColorBase100->value => '#0b1220', ]; } }
Token is the fixed, finite set of CSS custom properties the compiled theme defines (colors and radii) — override only what you want changed, everything you leave out keeps the compiled default.
CannotBeDisabled: block the (not yet built) admin disable toggleuse Kopling\Core\Extension\Contract\CannotBeDisabled; class Extension extends AbstractExtension implements CannotBeDisabled { // ...name(), description() as above -- nothing else to implement }
No admin-facing disable toggle exists yet — this marker guarantees whatever eventually builds one must refuse for any Extension implementing it (a bundled tool a hosting provider requires stay on is the motivating case). kopling/admin implements it for exactly this reason. It only blocks disabling at runtime — removing the package from composer.json before install is unaffected.
LoadsAfter/LoadsBefore: require a specific package before or after yoursCore always loads first; every other extension otherwise loads alphabetically. Declare an explicit constraint when your extension needs a specific package loaded first — placing something into a Portal another extension registers, for instance. These are two separate single-method interfaces, not one — implement only the direction you actually need (usually just one) instead of declaring a no-op for the other:
use Kopling\Core\Extension\LoadOrder\LoadsAfter; class Extension extends AbstractExtension implements LoadsAfter { // ...name(), description() as above /** @return array<string> */ public function loadAfter(): array { return ['kopling/admin']; } }
A package that isn't installed is silently ignored, never an error.
InfluencesLoadOrder: require load order for anything implementing a contract you ownUse this instead of LoadsAfter/LoadsBefore when you own a contract and need every extension implementing it ordered relative to you, without knowing which packages will implement it:
use Kopling\Core\Extension\LoadOrder\Directive; use Kopling\Core\Extension\LoadOrder\InfluencesLoadOrder; class Extension extends AbstractExtension implements InfluencesLoadOrder { /** @return array<class-string, Directive> */ public function loadOrderRules(): array { return [YourContract::class => Directive::After]; } }
Matched via instanceof against every discovered extension. A matched extension's own explicit LoadsAfter/LoadsBefore wins if the two disagree.
If your extension needs to gate an action behind something an admin can grant or take away, declare a permission — don't hardcode a check against "is this person an admin." Kopling has no such flag: every permission is its own named, granular capability, and "Admin"/"Moderator" are just conventional bundles of them an admin assigns, never anything the code treats specially.
use Kopling\Core\Authorization\Permission; use Kopling\Core\Extension\Contract\HasPermissions; class Extension extends AbstractExtension implements HasPermissions { // ...name(), description() as above /** @return array<Permission> */ public function permissions(): array { return [ new Permission( id: 'manage-things', label: __('yourvendor-example::permissions.manage-things.label'), description: __('yourvendor-example::permissions.manage-things.description'), ), ]; } }
Write only the local part of the id — manage-things, not yourvendor-example::manage-things. Kopling prefixes it with your extension's own id before it's ever registered, joined with :: — the same separator, and the same reason, as your view and translation namespaces (Section 4) — so you never have to worry about another extension picking the same name.
label/description go through your extension's own lang/ (Section 4) with Laravel's __() helper, the same as any other user-facing string — never a hardcoded string in whatever language you happened to write the extension in.
Verb-noun, lowercase, hyphenated — manage-things, view-reports, delete-things — matching the shape of core's own permissions (kopling-core::manage-people), so an admin's permission list reads consistently across every installed extension instead of as a patchwork of each author's own style.
Most permissions need nothing beyond a name — Kopling checks whether the signed-in person's groups have been granted it, and that's the whole decision. For the rare case that needs more (e.g. "can edit this specific reply," not any reply), Permission takes an optional callback that runs in addition to the grant check, never instead of it — it can only narrow access down further, never grant it on its own.
A Portal is a named UI surface — a route prefix paired with the Blade layout its routes render inside. Core registers one, kopling-core::community (path '', the default site); the Admin surface, kopling-admin::admin (path admin), is registered by kopling/admin — an ordinary extension, bundled by default, not part of Core itself. Neither is special-cased — a Portal is a registrable pattern any extension can add its own instance of.
Most extensions never need to register a Portal at all — putting a link into an existing Portal (Section 8, UI slots) is the common case. Reach for HasPortals only when you're building a genuinely separate surface — its own route, its own layout, its own optional gate on entry — not just another page inside the Community or Admin portal.
Registering a Portal never replaces checking permissions on your own routes. Kopling has no "is this person an admin" flag anywhere — every action still checks its own granular Permission, exactly as it would outside a Portal. A Portal's own optional gate (below) covers only that Portal's single root route; anything else your extension registers under that same path prefix gates itself independently, the normal way.
use Kopling\Core\Extension\Contract\HasPortals; use Kopling\Core\Portal\Portal; class Extension extends AbstractExtension implements HasPortals { // ...name(), description() as above /** @return array<Portal> */ public function portals(): array { return [ new Portal( id: 'moderation', label: 'Moderation', path: 'moderation', layout: 'yourvendor-example::layouts.moderation', permission: 'moderate', ), ]; } }
As with permission ids (Section 6), write only the local part — moderation, not yourvendor-example::moderation. Kopling prefixes it the same way, for the same collision-safety reason: your Portal ends up registered and routable as yourvendor-example::moderation, at /moderation.
| Field | What it's for |
|---|---|
| id | Local id, auto-prefixed with your extension's id — becomes the route name. |
| label | Display name, shown in the Portal's header and anywhere Kopling lists Portals. |
| path | The route path your Portal is registered at, e.g. 'moderation' → /moderation. '' registers at the root. |
| layout | A Blade view name — conventionally a thin wrapper delegating to <x-k::portal.layout> (below). |
| permission | Optional. A local permission id, auto-prefixed, that gates every route attached to this Portal (see ExtendsPortals below) — leave it null to leave the Portal open. |
| icon | Optional. Reserved for representing the Portal visually; not yet wired up anywhere. |
| description | Optional. A one-line description of what the Portal is for. |
HasPortals only ever declares a Portal's identity — nothing renders at path yet. A Portal carries no routes, css, or js of its own: registering one is the identity, attaching to it (below) is a separate step, done through exactly one mechanism regardless of whether you're the extension that declared it or a different one entirely — no shortcut for the owner, no special case for either of Kopling's own Portals.
ExtendsPortalsImplement ExtendsPortals to attach routes and/or plain hand-written css/js to a Portal — any Portal, yours or another extension's:
use Kopling\Core\Extension\Contract\ExtendsPortals; use Kopling\Core\Portal\PortalExtension; class Extension extends AbstractExtension implements ExtendsPortals { // ...name(), description() as above /** @return array<PortalExtension> */ public function extendsPortals(): array { return [ new PortalExtension('yourvendor-example::moderation') ->routes(__DIR__.'/../routes/web.php') ->css(__DIR__.'/../css/app.css') ->js(__DIR__.'/../js/app.js'), ]; } }
The target is the Portal's already fully-qualified id — 'yourvendor-example::moderation', not 'moderation' — same convention as after()/before() (Section 8): a reference to something else, never auto-prefixed. Targeting a Portal that isn't actually installed is a silent no-op, the same graceful-degradation rule a dangling after()/before() gets.
A route attached this way is grouped under its target Portal's prefix and name, and gets that Portal's middleware for free — web, plus can:{permission} if the Portal declared one — so the routes file itself needs no Route::middleware(...)->group(...) wrapper:
// yourvendor-example/routes/web.php use Illuminate\Support\Facades\Route; Route::get('/hello', fn () => view('yourvendor-example::hello'))->name('hello');
css/js are plain, hand-written files — no Tailwind, no build step; for anything beyond a safelisted layout-utility subset (flex/grid/gap/spacing), reach for a <x-k::*> component instead of a custom style. They live inside your own package, not under public/ — Kopling serves them itself through a small, key-based route it generates, nothing of yours to publish — and get linked onto the page automatically whenever the current request resolves to the Portal they're attached to.
Wrap <x-k::portal.layout> — the one truly shared piece, just html/head/body chrome, nothing else — and compose whichever named regions your Portal actually needs inside it:
<!-- yourvendor-example/views/layouts/moderation.blade.php --> <x-k::portal.layout> <aside> <x-k::portal.slot name="yourvendor-example::moderation.sidebar" /> </aside> @yield('content') </x-k::portal.layout>
Layouts aren't required to share one fixed shape — arrange your own regions and slot names however fits your Portal. <x-k::portal.slot name="..."> (Section 8) is the one primitive that resolves and renders whatever's registered into a given slot name.
Putting your own UI into a Portal you don't own — a link in the Admin portal's side navigation, for instance — is the ChangesUx contract, not HasPortals. This is the common case: most extensions place a few things into an existing Portal and never register one of their own.
use Kopling\Core\Extension\Contract\ChangesUx; use Kopling\Core\Ux\Portal\Navigation\Item; use Kopling\Core\Ux\Ux; class Extension extends AbstractExtension implements ChangesUx { // ...name(), description() as above public function ux(): Ux { return Ux::make() ->add(Item::class, ['label' => 'Hello', 'route' => 'yourvendor-example::moderation/hello']) ->in('kopling-core::side-navigation') ->as('hello') ->when('manage-things'); } }
Read as a sentence: place an Item component, with this data, into the kopling-core::side-navigation slot, visible only to people with manage-things. If something else is already registered in that slot, after($id)/before($id) (below) can anchor this entry's position relative to it, by that entry's own already-prefixed id. route here is a Portal-qualified route name (Section 7) — 'yourvendor-example::moderation/hello', not bare 'hello', since every route now lives under whichever Portal it's attached to.
Ux::make() chain| Method | What it's for |
|---|---|
| add($component, $data) | Starts a new entry. $component is either an already-valid Blade tag ('k::portal.navigation.item') or the component's own class (Item::class, as above) — Kopling resolves a class back to its tag for you, so reach for whichever reads better. $data is the array it receives as its one data prop. |
| in($slot) | The slot to place it in. Write the full, already-namespaced slot name yourself — 'kopling-core::side-navigation', not 'side-navigation'. Unlike every other id on this page, Kopling never prefixes this one: a slot is a public rendezvous point every extension needs to be able to name exactly, the same way, regardless of which extension is naming it. |
| as($id) | Optional. Names this entry so other extensions can anchor before/after it, or replace/remove it. Defaults to the component string if omitted. Auto-prefixed with your extension's id, same as permissions. |
| after($id) / before($id) | Optional. Anchors this entry's position relative to another entry's already-prefixed id (e.g. 'yourvendor-example::hello'). |
| when($condition) | Optional. A local permission id, auto-prefixed — the entry only renders for people who pass it. Leave it off and the entry always renders. |
| edit($id) | Re-selects an entry you already added earlier in this same chain so you can configure it further — for when you're not done with an entry but have since moved on to add()ing others. Throws if nothing in this chain has that id; this is always a typo, never an install-order concern. |
| replace($id, $component, $data) | Overwrites another entry's component/data by its already fully-qualified id — Core's own, or anything registered by an extension that loads before yours. Anything you don't also chain here (in()/when()/etc.) keeps the original entry's value; a missing target is a no-op, same as a dangling after(). |
| remove($id) | Removes another entry outright, by the same already fully-qualified id rule as replace(). Also a no-op if the target doesn't exist. |
You can only replace()/remove() something registered by an extension that loads before yours (Core always loads first, everything else alphabetically by default) — never one that loads after, since it hasn't registered anything yet when yours runs. Use LoadsAfter/LoadsBefore/InfluencesLoadOrder (Section 5) if you need a specific extension ordered relative to yours.
Slot names are entirely up to whichever Portal layout renders them (Section 7) — there's no fixed registry of slot names to pick from. kopling-core::side-navigation (Admin) exists today; Community defines its own separate set (kopling-core::community.sidebar, .topbar, .rail, .content-top, .composer) — some of those are still empty, nothing has registered into them yet. More slots on other Portals arrive the same way: a layout author decides a region is worth exposing and gives it a name.
Core owns the /login and /register pages, their routes, and the whole request lifecycle around them — throttling, session handling, the redirect after a successful sign-in. What it deliberately has no opinion about is what "credentials" even means. That's a login-method extension's job: username/password is the obvious first one, but a magic-link, a passkey, or an OAuth provider are exactly as valid, and none of them are special-cased. Instead of a form Core expects you to fill in, you get two events.
Dispatched first, before Core's own throttle check, carrying only the request:
namespace Kopling\Core\Authentication\Event; readonly class ValidateLogin { public function __construct(public Request $request) {} }
A listener that wants to reject the attempt outright — a captcha check is the motivating case — throws directly from inside itself. There's nothing else for it to communicate, so Core discards whatever a listener returns here; the only signal is whether something throws.
Dispatched once ValidateLogin passes. Unlike it, this one has to hand back a real result — who logged in, or a reason it didn't:
namespace Kopling\Core\Authentication\Event; class AttemptLogin { public ?Person $person = null; public ValidationException $e; public function __construct(readonly public Request $request) { $this->e = ValidationException::withMessages([]); } public function succeeded(Person $person): self { $this->person = $person; return $this; } public function failed(ValidationException $e): self { $this->e = $e; return $this; } }
Your listener reads whatever fields it cares about off $event->request — Core never inspects the request body itself, so whether that's email+password, a one-time token, or nothing at all is entirely your extension's decision. Resolve a real Person and call $event->succeeded($person), or call $event->failed($validationException) with your own message, keyed however your own form's fields are actually named — Core has no field name of its own to key against, so it never could have built this message for you. Core calls Auth::login() itself once $event->person is set; a listener never establishes the session by hand.
The default, un-set $e is an empty ValidationException::withMessages([]) — chosen so a login attempt with no login-method extension installed at all degrades to a silent redirect back rather than a hard error. It's a known gap, not a feature: there's no visible message telling anyone why nothing happened. Install a real login-method extension and this doesn't come up.
ListensToEventsImplement ListensToEvents on your Extension class, mapping an event class to a listener class — Kopling resolves and calls it through the container, the normal Laravel way:
use Kopling\Core\Authentication\Event\AttemptLogin; use Kopling\Core\Extension\Contract\ListensToEvents; class Extension extends AbstractExtension implements ListensToEvents { // ...name(), description() as above /** @return array<class-string, class-string> */ public function listen(): array { return [ AttemptLogin::class => AttemptPasswordLogin::class, ]; } }
The listener class itself is a plain invokable handling the event:
use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; use Kopling\Core\Authentication\Event\AttemptLogin; use Kopling\Core\People\Person; class AttemptPasswordLogin { public function handle(AttemptLogin $event): AttemptLogin { $person = Person::where('email', $event->request->input('email'))->first(); if ($person && Hash::check($event->request->input('password'), $person->password)) { return $event->succeeded($person); } return $event->failed(ValidationException::withMessages([ 'email' => trans('auth.failed'), ])); } }
A real, shipped example: kopling/auth-email-password implements exactly this, for both AttemptLogin and its registration counterpart.
An icon/ directory with a square PNG represents your extension anywhere Kopling lists installed extensions.
Required if you want an icon at all — this is the default, shown at 2048×2048.
Optional. Extensions can ship additional named sizes for smaller display contexts; only lg.png is required.
k-extensions/example in the kopling/development monorepo is a dummy extension — not meant to do anything real — kept specifically so every convention on this page has a working, verified example behind it: every path, the Extension class, the RequestsStorageDriver, HasPermissions, ChangesUx, LoadsAfter, and ExtendsPortals contracts, and both icon sizes. Copy it as a starting point rather than assembling one from this page alone.
HasPortals (Section 7) and ChangesTheme (Section 5) aren't demonstrated by k-extensions/example itself, but are real, working examples elsewhere in the monorepo: kopling/admin registers the Admin portal, kopling/theme-delft and kopling/theme-midnight each ship a full palette. ListensToEvents (Section 9) is demonstrated by kopling/auth-email-password, for both login and registration. CannotBeDisabled (Section 5) now has two implementors, Core and kopling/admin itself — see Section 5 for what that guarantees and what it doesn't. Ux::replace()/remove() and InfluencesLoadOrder (Section 5, 8) aren't demonstrated anywhere shipped yet — kopling/admin is the intended first InfluencesLoadOrder implementor, once it has a HasSettings-style contract worth requiring load order against. If this page and the monorepo ever disagree, the code is right and this page is stale — please tell us.