Skip to content

Extending Rangefront Waste ​

Rangefront Waste is sold as a one-off licence with source code, self-hosted or hosted by us, and white-labelled per organization. This guide is for developers who customise it without forking core, so a core upgrade never overwrites their work.

Concepts ​

  • Hooks. WordPress-style actions and filters fired by core at meaningful points (a job completed, an invoice built, a PDF rendered). Plugins listen to them.
  • Plugins. A folder on disk with a plugin.json and a PHP class. A plugin can listen to hooks, add API routes, tables, settings, permissions, PDF blocks, scheduled commands and console pages. It is installed once per deployment and switched on per organization.
  • Themes. A folder with a theme.json of design tokens, and optional Twig templates for emails, PDFs and public pages, and an optional parent theme. Each organization picks one and may override individual tokens.

Where things live:

Folder (in apps/backend-laravel)OwnerCore upgrades
plugins/<slug>/the customernever touched
themes/<slug>/the customernever touched
bundled-plugins/core (the two examples)replaced
bundled-themes/core (default, slate)replaced

If a slug exists in both a customer folder and a bundled folder, the customer folder wins. To change a bundled example, copy it into plugins/ (or themes/) first.

Upgrade-safety rules ​

  1. Put your code in plugins/ and themes/. Do not edit files under app/ for customisation.
  2. Hook into core through the catalogue below. Hook names are a stable contract: they are not renamed or removed without a major version.
  3. Plugin tables must carry organization_id (use PluginMigration::createTable()). Install refuses a plugin whose migration creates a table without it.
  4. Declare the core versions you support in requires.core. A plugin that does not match is listed as incompatible and is not booted.
  5. After a core upgrade run php artisan migrate (it also runs pending migrations of installed plugins) and php artisan rangefront:plugin:list to see anything that needs attention.

Security model ​

  • Plugins are trusted code installed on disk by the operator or developer. There is no upload or install-from-UI path, on purpose: on multi-tenant hosting an uploaded plugin would run with the privileges of every tenant. Administrators can only switch an installed plugin on or off for their own organization and edit its settings.
  • A plugin only runs for organizations that enabled it. Its hook listeners are skipped for every other organization, and its routes answer 404 for them.
  • Hooks never bypass policies. They run inside flows that are already authorised and nothing in the extension system grants access. Plugin routes use the normal tenant stack (Sanctum, the X-Organization-Id header, membership check) and you must add a permission: middleware to each.
  • Always scope plugin queries by organization_id.
  • A listener that throws is logged and skipped, so one plugin cannot break a core flow. Set EXTENSIBILITY_RETHROW=true in development and tests to see the exception (phpunit.xml does). To veto an action on purpose, throw a ValidationException, an authorization exception or call abort(): those always propagate.
  • Secret settings are encrypted at rest, never returned by the API and never written to the audit log.
  • Theme values are validated (hex colours, bounded sizes, bundled fonts only). No external font CDN is ever loaded and free-form CSS is not accepted.

Hooks ​

php
use App\Extensibility\Facades\Hooks;

Hooks::addAction('job.completed', fn (object $job) => ..., priority: 10, acceptedArgs: 1);
Hooks::addFilter('invoice.lines', fn (array $lines, object $job) => $lines, 10, 2);
Hooks::removeAction($hook, $callback, $priority);
Hooks::hasAction($hook);   // bool, or the priority when given a callback
  • Lower priority numbers run first (default 10). Equal priorities run in registration order.
  • A listener receives the first acceptedArgs arguments. For filters the value is the first argument and the listener must return it (changed or not). A filter must return the same kind of value it was given (array in, array out), otherwise the result is discarded and logged.
  • Inside a plugin use $api->addAction() / $api->addFilter(). They record the plugin as the owner so the listener only runs for organizations that enabled it.
  • Every dispatch carries an organization id. Inside a listener $api->organizationId() (or Hooks::currentOrganizationId()) is the organization of the event.
  • Actions named *.completed, *.sent and similar are fired after the database commit so a rolled back change never announces itself. Use them for HTTP calls and notifications.
  • Hooks your plugin fires for other plugins are named plugin.<slug>.<name> and need no catalogue entry.

Naming convention ​

<area>.<event>, lower snake case. Actions are past-tense events (job.created) or *.before_* guards that may veto (job.before_transition). Filters name the value they shape (invoice.lines, pdf.html, validation.rules).

Adding a hook point in core ​

Fire it at the call site, add it to App\Extensibility\HookCatalogue, then run php artisan rangefront:hooks:docs:

php
use App\Extensibility\Facades\Hooks;

Hooks::doAction('quote.accepted', $organizationId, $quote);                      // event
Hooks::doActionAfterCommit('quote.accepted', $organizationId, $quote);           // event after commit
$lines = Hooks::applyFilters('quote.lines', $organizationId, $lines, $quote);    // filter

The organization id is always the second argument (null resolves it from the request). Tests fail when code fires a name that is not in the catalogue, when a listed hook is never fired, and when this document is out of date.

Catalogue ​

<!-- hooks:start -->

Jobs ​

  • job.created (action). A job was created through the API. Fires after the row is saved. Arguments: array $job, string $source.
  • job.before_transition (action). A job status change is about to be applied. Throw a ValidationException to veto it. Arguments: string $jobId, string $fromStatus, string $toStatus, array $options.
  • job.status_changed (action). A job status change was committed. Arguments: object $job, string $fromStatus, string $toStatus, array $options.
  • job.completed (action). A job finished on site: it entered completed_pending_review, or completed without passing through review. Fires after commit. Arguments: object $job, array $options.
  • schedule.jobs (filter). The serialised jobs returned by the schedule API. Add or remove keys per job. Arguments: array $jobs. Return the first argument.
  • route.optimization_result (filter). The result of a route optimisation run before it is returned. Arguments: array $result, string $routePlanId. Return the first argument.

Invoices ​

  • invoice.created (action). An invoice was created from a job. Fires after commit. Arguments: array $invoice, string $jobId.
  • invoice.lines (filter). Charge lines generated for a job invoice, before they are priced and saved. Arguments: array $lines, object $job, object $customer. Return the first argument.
  • invoice.sent (action). An invoice was marked sent. Fires after commit. Arguments: object $invoice, array $options.
  • invoice.payment_recorded (action). A payment was recorded against an invoice. Fires after commit. Arguments: array $invoice, array $payment.

Compliance reports ​

  • report.generated (action). A compliance report moved to generated. Arguments: object $report.
  • report.issued (action). A compliance report was issued. Arguments: object $report.
  • report.submitted (action). A compliance report was submitted to a council. Arguments: object $report, object $submission.
  • defect.logged (action). A defect was logged against a site or system. Fires after commit. Arguments: array $defect.
  • defect.resolved (action). A defect was resolved. Fires after commit. Arguments: array $defect.

Messages ​

  • message.sending (filter). A customer message is about to be queued. Change recipients, subject or body; set "cancel" to true to suppress it. Arguments: array $message, string $event. Return the first argument.
  • message.sent (action). A customer message was marked as sent. Arguments: array $message.
  • notification.placeholders (filter). The values available to {{ placeholders }} in notification templates. Arguments: array $context, string $event. Return the first argument.

Documents ​

  • pdf.snapshot (filter). The immutable data snapshot a PDF is rendered from. Whatever you add is stored with the document. Arguments: array $snapshot, string $documentType. Return the first argument.
  • pdf.blocks (filter). Extra Twig blocks appended to a PDF. Each block is a template name (such as "@my-plugin/pdf/block.twig") or [view, data]. Arguments: array $blocks, string $documentType, array $snapshot. Return the first argument.
  • pdf.html (filter). The final HTML before it is turned into a PDF. Arguments: string $html, string $documentType, array $snapshot. Return the first argument.
  • document.template (filter). The view that renders a document, after the theme hierarchy has picked one. Return another view name to take over; names that do not exist are ignored. Arguments: string $view, string $documentType, array $context. Return the first argument.

API ​

  • api.resource (filter). A core resource as serialised by the API (index, show). Add or hide keys. Arguments: array $resource, string $type. Return the first argument.
  • validation.rules (filter). Validation rules of a core Form Request. Add rules for plugin fields. Arguments: array $rules, string $request, object $formRequest. Return the first argument.
  • navigation.items (filter). Navigation items for plugin pages the console shows for this organization. Arguments: array $items, array $permissions. Return the first argument.
  • permissions.catalog (filter). The list of known permission keys. Plugins add theirs here (also read from plugin.json). Arguments: array $keys. Return the first argument.

Mobile ​

  • mobile.sync.event (action). A field app sync event was applied. Fires inside the sync transaction. Arguments: array $event, string $deviceId, string $userId.
  • mobile.sync.event_rules (filter). Validation rules for one pushed sync event payload. Rules are keyed "events.<i>.payload.<field>". Arguments: array $rules, string $eventType, int $index, array $event. Return the first argument.
  • mobile.pull.payload (filter). The payload the field app downloads. Add form field metadata under "extensions". Arguments: array $payload, string $deviceId. Return the first argument.
  • mobile.fields (filter). The field app fields a plugin declares for this organization (from "mobile.fields" in plugin.json). Change labels, options or whether a field is required per organization. Arguments: array $fields, string $slug. Return the first argument.
  • mobile.field_values (filter). Field values the field app pushed for one record, before they are saved. Throw a ValidationException to veto them. Arguments: array $values, string $slug, string $entityType, string $entityId. Return the first argument.
  • mobile.fields_saved (action). Field app values were saved for a record. Fires inside the sync transaction. Arguments: string $slug, string $entityType, string $entityId, array $values, string $userId.

Quotes ​

  • quote.lines (filter). Priced quote lines before they are saved on an option. Add, remove or reprice lines. Arguments: array $lines, string $customerId. Return the first argument.
  • quote.sent (action). A quote was sent or its link was issued. Arguments: object $quote, array $options.
  • quote.accepted (action). A customer or the office accepted a quote. Fires after commit, once per quote. Arguments: object $quote, object $job, object|null $depositInvoice.

Customer contact ​

  • sms.received (action). A customer text arrived and was stored in its conversation. Arguments: object $message, array $conversation.
  • booking.request_received (action). The public booking form created a booking request. Arguments: string $requestId, string $customerId, array $data.
  • booking.request_accepted (action). The office accepted a booking request. A job now exists for it. Fires after commit, once per request. Arguments: string $requestId, array $job, string $customerId, string $propertyId.
  • booking.request_declined (action). The office declined a booking request. Arguments: string $requestId, string $reason.
  • call.ended (action). A phone agent call ended. Arguments: object $call.

Staff ​

  • team.message_sent (action). A staff member posted in a team thread. Arguments: array $message, object $thread.
  • time.entry_saved (action). A time entry was added or changed by hand (not the automatic job entries). Arguments: object $entry, bool $created.

Plugins and themes ​

  • plugin.installed (action). A plugin was installed (migrations ran). Arguments: string $slug.
  • plugin.enabled (action). A plugin was enabled for an organization. The plugin sees it; other plugins do not. Arguments: string $slug, string $organizationId.
  • plugin.disabled (action). A plugin was disabled for an organization. Data is kept. Arguments: string $slug, string $organizationId.
  • theme.tokens (filter). The final design tokens for an organization after the theme and its overrides. Values are validated again afterwards. Arguments: array $tokens, string $themeSlug. Return the first argument.
  • theme.template_hierarchy (filter). The candidate template names for a document, email or public page, most specific first. Add names (for example "documents/invoice-wholesale") that a theme can provide. Only plain names or registered view names are kept. Arguments: array $candidates, string $surface, string $name, array $context. Return the first argument.

<!-- hooks:end -->

Plugin anatomy ​

plugins/fuel-levy/
  plugin.json                 manifest
  src/Plugin.php              entry class
  database/migrations/        tables (with organization_id)
  resources/views/            Twig templates (PDF blocks, emails)
  web/index.ts                optional console module

plugin.json:

json
{
  "name": "Fuel levy",
  "slug": "fuel-levy",
  "version": "1.0.0",
  "description": "What it does.",
  "author": "Acme",
  "requires": { "core": "^1.0" },
  "entry": "Rangefront\\Plugins\\FuelLevy\\Plugin",
  "autoload": { "psr-4": { "Rangefront\\Plugins\\FuelLevy\\": "src/" } },
  "permissions": [{ "key": "fuel_levy.manage", "label": "Manage fuel levy", "default_roles": ["dispatcher"] }],
  "settings": [{ "key": "rate", "label": "Levy per km", "type": "number", "default": 0.4 }],
  "navigation": [{ "page": "overview", "label": "Fuel levy", "icon": "i-dollar", "permission": "fuel_levy.manage" }],
  "web": { "entry": "web/index.ts" },
  "mobile": { "entry": null }
}
  • slug equals the folder name. Permission keys must start with the slug (dashes become underscores). They join the permission catalogue and are granted to the owner role and to the default_roles when the plugin is enabled for an organization.
  • Setting types: text, textarea, url, secret, number, boolean, select (with options). The Admin page renders the form from this schema. secret is encrypted and write-only. Read settings with $api->setting('rate').
  • mobile.entry is reserved. A plugin adds fields to the field app by declaring them under mobile.fields; see docs/developer/plugin-fields.md. The mobile.pull.payload and mobile.sync.event_rules filters remain for anything else.
  • A Composer package of type rangefront-plugin whose root holds a plugin.json is discovered too.

The entry class extends App\Extensibility\Plugin and gets a PluginApi:

MethodDoes
addAction, addFilterlisten to hooks, for enabled organizations only
routes(fn (Router $r) => ...)routes under /api/v1/plugins/<slug>/ with the tenant middleware
views() / viewName('pdf/x.twig')Twig namespace @<slug>, e.g. @disposal-dockets/pdf/docket.twig
pdfBlock($documentType, $view, $data)append a Twig block to a PDF; $view is a name from viewName()
schedule(fn (Schedule $s) => ...)scheduled work (loop forEachEnabledOrganization)
commands([...])Artisan commands
settings(), setting($key)this organization's settings
organizationId()organization of the current event

Lifecycle commands:

php artisan rangefront:plugin:list
php artisan rangefront:plugin:make "Fuel levy"
php artisan rangefront:plugin:install fuel-levy          # runs migrations, safe to re-run
php artisan rangefront:plugin:enable fuel-levy --org=rangefront-demo
php artisan rangefront:plugin:disable fuel-levy --org=rangefront-demo   # data is kept
php artisan rangefront:plugin:uninstall fuel-levy [--purge]             # --purge deletes data

Disabling and uninstalling never drop data. --purge rolls the plugin migrations back.

Example plugins ​

bundled-plugins/disposal-dockets and bundled-plugins/job-webhook-slack are small, commented templates and are covered by tests. Docket numbers: a validation filter, field app fields declared in plugin.json, actions that save the value, a veto guard, schedule/pdf filters, a PDF block, a route and a migration. Slack: one job.completed action and a secret setting.

Walkthrough: build a plugin ​

Goal: log every completed job in a table and show the count on an endpoint.

  1. Scaffold and install:
    php artisan rangefront:plugin:make "Completion log"
    php artisan rangefront:plugin:install completion-log
    php artisan rangefront:plugin:enable completion-log --org=rangefront-demo
  2. Edit plugins/completion-log/database/migrations/..._create_completion_log_items_table.php: add $table->uuid('job_id'); inside createTable() and re-run rangefront:plugin:install (migrations only run once, so for an applied migration add a new migration file instead).
  3. Edit src/Plugin.php:
    php
    $api->addAction('job.completed', function (object $job): void {
        DB::table('completion_log_items')->insert([
            'id' => (string) Str::uuid(),
            'organization_id' => $job->organization_id,
            'job_id' => $job->id,
            'created_at' => now(), 'updated_at' => now(),
        ]);
    });
  4. Complete a job in the console. The row appears for organizations that enabled the plugin and for no one else. GET /api/v1/plugins/completion-log/items lists them (needs completion_log.manage).
  5. Add a setting, a PDF block or a console slot as needed (see below).
  6. Test it. Copy the pattern in tests/Feature/Extensibility/ExamplePluginsTest.php.

Console extension points ​

The console (Aurelia) loads plugin web modules at build time from the plugin folders and code-splits each one. A Vite plugin in apps/web-aurelia/vite.config.ts scans plugins/*/web/ and bundled-plugins/*/web/ (override with RANGEFRONT_PLUGIN_DIRS, colon separated) and emits a virtual module with one lazy import per plugin. Build-time loading is the simplest robust option for a Vite app: no runtime code loading from a server, nothing to upload, normal type-checking and tree-shaking. At runtime GET /api/v1/extensions returns the plugins the organization enabled; only their bundles are downloaded and registered.

A web module default-exports register(api):

ts
import { customElement } from 'aurelia';

@customElement({ name: 'fuel-levy-fact', template: '<div if.bind="model">${model.job_number}</div>' })
class Fact { activate(model) { this.model = model; } }

export default function register(api) {
  api.slot('job-sheet.after-facts', Fact, { priority: 10 });
  api.page('overview', { title: 'Fuel levy', component: OverviewPage });   // /ext/fuel-levy/overview
  api.adminSection({ id: 'fuel-levy', label: 'Fuel levy', icon: 'i-dollar', component: Settings });
}

Keep templates inline. Pages are reached at /ext/<slug>/<page>; add a navigation entry in plugin.json to put one in the sidebar (it is filtered by the member's permissions and by the navigation.items filter).

Slots are rendered by <plugin-slot name="..." context.bind="record">. The component gets the record as model (activate(model)). Available slots:

SlotWhere
job-sheet.after-factsjob sheet, after the facts list (context: the job)
customer-sheet.after-factscustomer sheet (context: the customer)
invoice-sheet.after-factsinvoice sheet (context: the invoice)

Admin sections appear in the Admin rail under "Plugins". Admin has three built-in pages for extensions: Plugins (enable per organization, settings form generated from the schema), Branding (theme and token overrides with contrast warnings) and Hooks (read-only catalogue).

Themes ​

Themes, child themes, the template hierarchy, template parts and tenant templates are documented in developer/themes.md. In short: a theme is a folder with a theme.json of design tokens and optional Twig templates, it can name a parent, and every document, email and customer page is rendered through one template hierarchy. Business admins make their own templates in the console; see admin/document-templates.md.

A$125 a month, or A$9,450 once with the source code. Contact us.