Extending Trakli
The Trakli backend keeps its core small and free, and grows through plugins. A plugin is a self-contained module that adds routes, models, and behaviour without changing core code. The same mechanism powers both community add-ons and Trakli’s own paid integrations.
How plugins load
Section titled “How plugins load”A plugin is a directory with a plugin.json manifest and a service provider. The backend discovers it, autoloads its namespace, and boots its provider only when the plugin is enabled. Everything the plugin needs (routes, migrations, bindings) is wired from that provider. Enabling and disabling is a manifest flag, so a plugin can ship dormant and be turned on per deployment.
The deeper reference, including the manifest fields and the Artisan commands, lives in the backend repository under docs/PLUGIN_SYSTEM.md.
Plugin dependencies
Section titled “Plugin dependencies”A plugin declares its own dependencies in its composer.json, and Trakli resolves them in one of two ways depending on how the plugin ships.
Trakli’s first-party plugins are embedded: each lives in its own repository, checked out under plugins/ and ignored by the app’s own git. To resolve their dependencies alongside the app’s, Trakli enables wikimedia/composer-merge-plugin in the app’s composer.json, pointed at the plugins directory:
// webservice composer.json"extra": { "merge-plugin": { "include": ["plugins/*/composer.json"] }},"config": { "allow-plugins": { "wikimedia/composer-merge-plugin": true }}Each plugin keeps its own dependency list, and a single composer install resolves them all into the app’s vendor/.
A plugin fetched on demand instead of embedded, from a repository URL or a local path, is installed with plugin:install, which reads that plugin’s composer.json and installs its require on its own. Either way the plugin owns its dependency list; nothing is copied into the app’s root composer.json.
Core extension points
Section titled “Core extension points”Beyond routes and migrations, the core exposes a few contracts a plugin can hook into. Resolve them from the container inside your provider’s boot().
Feature gating
Section titled “Feature gating”App\Contracts\Entitlements answers whether an owner may use a feature, what limits apply, and how much of a metered allowance is left. It is keyed on the resource owner, not the user directly, so a shared owner is gated as one unit. The core binds a permissive default that allows everything, so the open install stays free; a billing plugin may replace it to enforce a plan.
if (! app(\App\Contracts\Entitlements::class)->allows($owner, 'your-feature')) { abort(403);}Feature keys are plain strings, and the billing plugin maps them to plans. With no plugin overriding the default, every check passes, which is what keeps the open core free and self-hostable.
Integration registry
Section titled “Integration registry”Register a descriptor so your integration appears in the backend’s integrations listing:
app(\App\Services\IntegrationRegistry::class)->register($yourIntegration);The descriptor (App\Contracts\Integration) carries a key, a name, a category, the feature key it is gated by, and whether it is configured; the connect and sync behaviour stays in your plugin. The listing also reports an entitled flag per integration, which Trakli resolves server-side from the entitlements contract above, so the client never decides paid versus free itself. An integration that also wants to say where it appears in the client implements App\Contracts\IntegrationUi and returns a ui block, covered next.
Describing your plugin’s UI
Section titled “Describing your plugin’s UI”Implement App\Contracts\IntegrationUi (it extends Integration) to tell the client where and how the integration surfaces. The ui() method returns a plain descriptor that the listing endpoint includes; integrations that skip it list with ui: null, so nothing existing breaks.
public function ui(): array{ return [ 'slots' => ['settings.integrations', 'onboarding.steps'], 'card' => [ 'title' => 'Import bank statements', 'cta' => 'Import', 'description' => 'Upload a CAMT.053 statement to add its transactions.', 'href' => '/imports', ], 'onboarding' => ['step' => 'import-statement', 'optional' => true, 'order' => 60], 'component' => null, ];}The client reads this and renders it, so a plugin lands in the right place with no per-plugin frontend code. The named slots are settings.integrations, settings.tabs, onboarding.steps, dashboard.widgets, and sidebar.nav. A card renders an integration card; an onboarding block inserts a step, ordered by order; the icon from the base Integration (a Lucide icon name such as file-text) gives the surface its glyph. By default a contribution renders only once the integration is configured; set show_when_unconfigured to surface a needs-setup state instead of hiding.
For UI the built-in primitives cannot express, a plugin ships a thin Nuxt layer (the trakli/ui-kit pattern) that registers a real component under a slot key and sets component to that key. The common cases need no plugin JavaScript at all.
Document processors
Section titled “Document processors”To handle a document type, or to take precedence over the engine the operator has configured, register a processor with a priority:
app(\App\Services\DocumentProcessorManager::class)->register($yourProcessor, priority: 100);The highest-priority processor that supports the file type wins. Equal priority keeps registration order, so existing behaviour is unchanged.
Paid and private plugins
Section titled “Paid and private plugins”Paid integrations live in their own private repositories and never ship in the open image. A build step stacks them onto the public base image to produce a private hosted image with the plugins enabled. Because they gate their features through the entitlements contract above, the same code path stays inert on a free, self-hosted install: the feature is simply never granted. This is how Trakli stays open at the core while offering paid integrations on the hosted product.