Concepts
Modules
Package reusable application behavior, templates and resources.
A module is a self-contained feature that bundles its own routes, templates, libraries, and install logic.
Use a module when:
- A feature has both a library (shortcode) and a dedicated route that belong together - e.g. a Stripe integration with a webhook endpoint and a
stripe-webhookshortcode. - A feature ships as a reusable unit - e.g. an event system, a shop, a blog - rather than a loose set of pages.
- A feature needs an install script to create resources or register routes.
Use ext/lib/ and ext/uri/ directly (without a module) for things that don't naturally group together - a standalone utility shortcode, or a one-off page with no associated library.
Module directory structure
ext/modules/<name>/
.install.inc # Install script - runs once when the module is installed
lib/ # Shortcode libraries scoped to this module
tpl/ # Templates scoped to this module
uri/ # Routes scoped to this module (merged into the URL space)
Module templates follow the same index.tpl convention as ext/tpl/. A template at ext/modules/member/tpl/member-status/index.tpl is callable as [#member-status#]. Flat .tpl files inside the folder are partials - only index.tpl is the public shortcode entry point.
Routes inside modules/<name>/uri/ are served at the same paths as if they were in ext/uri/. A route at ext/modules/event/uri/event/(slug)/index.tpl is accessible at /event/<slug>/.
Module auto-discovery
All modules in ext/modules/ and core/modules/ are discovered automatically on the first template lookup of any request. There is no need to declare or load a module explicitly - its templates, libraries, and routes are always available.
The [#module name#] shortcode is a no-op and should not be used. Use [#nop module event#] or a comment if you want to document a dependency inline.
.install.inc
The install script runs when ./nimbly module:install <name> (or the admin install button) is executed. Use it to create the resource(s) and register any dynamic routes the module needs.
<?php
load_library("data");
// Create the resource if it doesn't exist yet
$result = data_create_resource("events", [
"fields" => [
"title" => ["name" => "Title", "type" => "text", "required" => true],
"title_slug" => ["name" => "URL slug", "type" => "slug", "source" => "title"],
"published" => ["name" => "Published", "type" => "boolean"],
"date" => ["name" => "Date", "type" => "date"],
"body" => ["name" => "Body", "type" => "html",
"buttons" => "h2,h3,bold,italic,anchor",
"media" => true, "media_sizes" => "sm-90,md-70,lg-60",
"admin_col" => false],
"sort_order" => ["name" => "Sort order", "type" => "text", "admin_col" => false]
],
"index" => ["title_slug"],
"sort" => ["field" => "sort_order", "flags" => "numeric", "order" => "asc"]
]);
// Register any dynamic routes this module needs
$route = 'events/(slug)';
$result &= data_exists(".routes", md5($route))
|| data_create(".routes", md5($route), ["route" => $route, "order" => 200]);
return $result;
PHP data API (available in .install.inc and route.inc)
| Function | Description |
|---|---|
data_create_resource($name, $meta) |
Creates a new resource with the given .meta definition |
data_create($resource, $uuid, $data) |
Creates a new record |
data_read($resource, $uuid) |
Reads a single record by UUID |
data_exists($resource, $uuid) |
Returns true if a record exists |
load_library($name) |
Loads a shortcode library so its PHP functions are available |
Slug-to-UUID routing
Use the index system to resolve a slug from the URL to a record. Add title_slug to the index array in .meta, then look it up in route.inc:
<?php
$parts = router_match(__FILE__);
if ($parts === false || count($parts) !== 1) return;
$slug = $parts[0];
load_library('data');
load_library('util');
$records = data_read_index('events', 'title_slug', md5_uuid($slug));
if (empty($records)) return;
$record = reset($records);
set_variable_dot('record', $record);
router_accept();
The record UUID is stable and random - slugs are stored as regular fields and indexed for fast lookup. If the slug field changes, the index updates automatically on next save.