Prefer content/extensions/{slug}/ for features. Edit core/ only for framework changes.
Each extension is one folder that contains everything it needs:
content/extensions/notes/
notes.php # bootstrap + headers (required)
database/
migrations/
mysql/ # MySQL/MariaDB DDL
sqlite/ # SQLite DDL (same basenames)
templates/
admin/ # admin shell pages
index.hbs
frontend/ # public-facing pages
emails/ # optional: emails/{name}.hbs for Mailer
src/ # optional PHP classes (PSR-4: Dltn\Ext\Notes\)
assets/ # optional static files → /ext/notes/...
css/
js/
The main file must declare headers (same idea as WordPress plugins). Prefer
{slug}/{slug}.php. Plugin Name is accepted as an alias for Extension Name.
<?php
/**
* Extension Name: Notes
* Description: Simple notes admin page.
* Version: 1.0.0
* Author: You
* Author URI: https://example.com
*/
declare(strict_types=1);
// register hooks below…Without a valid Extension Name / Plugin Name, the folder is ignored.
Admin → Extensions (/admin/extensions, requires manage_extensions). Disabled
extensions stay on disk but are not booted (no templates, autoload, or bootstrap).
State is an opt-out list in Settings (extensions.disabled). Enabling runs that
extension's pending migrations. Schema is not rolled back on disable.
Force enable/disable in code (must register before extensions load):
add_filter('extension_enabled', function (bool $enabled, string $slug): bool {
if ($slug === 'experimental') {
return false;
}
return $enabled;
}, 10, 2);If templates/ exists, it is registered automatically (when the extension is enabled). Prefer:
| Folder | Use for |
|---|---|
templates/admin/ |
Authenticated admin pages |
templates/frontend/ |
Public-facing pages |
Template names are relative to templates/ (admin/index → templates/admin/index.hbs). Global overrides in content/templates/ still win over extension templates.
<?php
/**
* Extension Name: Notes
* Description: Simple notes admin page.
* Version: 1.0.0
* Author: You
* Author URI: https://example.com
*/
declare(strict_types=1);
use Dltn\Core\Router;
use Dltn\Http\Controllers\Admin\AdminController;
use Dltn\Http\Request;
use Dltn\Http\Response;
final class NotesController extends AdminController
{
public function index(Request $request): Response
{
return $this->adminPage('admin/index', [
'heading' => 'Notes',
], '/admin/notes', 'Notes');
}
}
add_filter('admin_menu', function (array $nav): array {
$nav[] = [
'link' => [
'href' => '/admin/notes',
'label' => 'Notes',
'cap' => 'view_dashboard',
'icon' => 'sticky-note', // Lucide name — see Icons below
'order' => 20, // after Dashboard (10); default is 50
],
];
return $nav;
});
add_action('routes', function (Router $router): void {
$router->get('/admin/notes', [NotesController::class, 'index'], ['auth', 'can:view_dashboard']);
});Companion template: content/extensions/notes/templates/admin/index.hbs
For larger extensions, put PHP classes in src/ under the PSR-4 namespace
Dltn\Ext\{StudlySlug}\ (e.g. slug hello-world → Dltn\Ext\HelloWorld\).
The loader registers that autoload automatically — no manual require.
Admin UI icons use Lucide (the set shadcn/ui uses) — not Font Awesome.
Browse names: https://lucide.dev/icons
// In admin_menu — pass the Lucide name (resolved to SVG automatically):
'icon' => 'flask-conical',
// Or render SVG yourself:
icon('users', ['class' => 'size-4']);
\Dltn\Support\Icon::svg('settings');Raw <svg>…</svg> markup is still accepted for admin_menu icons.
add_action('routes', function (\Dltn\Core\Router $router): void {
$router->get('/hello', function ($request) {
return \Dltn\Http\Response::html('<h1>Hello</h1>');
});
});Core mounts /api and fires api_routes with a router already prefixed. Use auth.api + csrf + can:* for session-authenticated mutations (send X-CSRF-Token from GET /api/csrf). The same auth.api middleware also accepts Authorization: Bearer dltn_… (Super Admins mint tokens in System Settings → API tokens and pick which capabilities the token may use). CSRF is skipped when a Bearer header is present.
add_action('api_routes', function (\Dltn\Core\Router $router): void {
$router->get('/notes', function ($request) {
return \Dltn\Http\Response::json(['notes' => []]);
}, ['auth.api', 'can:view_dashboard']);
$router->post('/notes', function ($request) {
$body = $request->json();
if ($body === null) {
return \Dltn\Http\Response::jsonError('invalid_json', 400);
}
return \Dltn\Http\Response::json(['ok' => true], 201);
}, ['auth.api', 'csrf', 'can:view_dashboard']);
});Paths above become /api/notes. Prefer [Controller::class, 'method'] for larger handlers under Dltn\Ext\{StudlySlug}\.
Bearer example:
curl -H "Authorization: Bearer dltn_…" https://example.test/api/meConfigure SMTP via .env (SMTP_* → config/mail.php), then:
$mailer = $container->get(\Dltn\Mail\Mailer::class);
$mailer->send('user@example.com', 'Name', 'Subject', 'invite', [
'name' => 'Name',
'org_name' => 'DLTN Framework',
'accept_url' => 'https://example.org/...',
'role' => 'Member',
]);Templates resolve as emails/{name} via View: content/templates → extension
templates/ → core/templates. Ship extension emails at
content/extensions/{slug}/templates/emails/{name}.hbs. Filter payload with
mail.before_send.
Put CSS/JS/images in content/extensions/{slug}/assets/. They are served at
/ext/{slug}/... (PHP controller; never exposes .php).
// In a template or controller:
ext_asset('notes', 'css/app.css'); // → /ext/notes/css/app.cssadd_action('init', function (\Dltn\Core\Container $c): void {
add_action('queue.send_digest', function (array $payload): void {
// heavy work…
}, 10, 1);
});
// Somewhere later:
$c->get(\Dltn\Queue\Queue::class)->push('send_digest', ['user_id' => 1]);Workers: php bin/queue.php or the built-in drain inside php bin/cron.php.
$v = \Dltn\Support\Validator::make($request->all(), [
'email' => 'required|email',
'password' => 'required|min:10|confirmed',
]);
if ($v->fails()) {
// $v->firstError() / $v->errors()
}Feature tables live inside the extension, not in core/database/migrations/. Ship both driver trees with the same basenames:
content/extensions/posts/
posts.php
database/
migrations/
mysql/
0001_create_posts.sql
0001_create_posts.php # optional PHP backfill: return function (Database $db): void {}
sqlite/
0001_create_posts.sql
src/
PostRepository.php
templates/
admin/
Example MySQL (mysql/0001_create_posts.sql):
CREATE TABLE posts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
body TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;Example SQLite (sqlite/0001_create_posts.sql):
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(255) NOT NULL,
body TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);Assume an empty table prefix in migration DDL (or bake the prefix into the SQL if you use one). Prefer $db->insert() / $db->upsert() / $db->table() in PHP.
Core applies the tree for the active Database::driver() via MigrationRunner / Migrator. Versions are recorded as ext:{slug}/{basename} (e.g. ext:posts/0001_create_posts) so they never collide with core.
Run pending migrations:
php bin/migrate.php
php bin/migrate.php --pending # list onlyInstall also runs core + all extension migrations found on disk.
In the extension, use core Database (and your own repository) — do not put Post models or SQL in core/:
add_action('init', function (\Dltn\Core\Container $container): void {
// Repositories / services that call $container->get(Database::class)
});Framework tables only: add matching files under core/database/migrations/mysql/ and core/database/migrations/sqlite/ (same basename). Same runner; versions stay unprefixed (0002_my_framework_change).
- Add the string to
Dltn\Team\Permission::CAPABILITIES(Admins get it automatically unless you also add it toPermission::SUPER_ADMIN_ONLY). For Editor access, also add it toPermission::EDITOR_CAPABILITIES, or - Filter
admin_capabilitiesand enforce with custom middleware / checks in your controller.
Protect routes with middleware alias can:your_cap.
add_filter('admin_page_data', function (array $data): array {
$data['extra_banner'] = 'Staging';
return $data;
});Add a core hook when multiple extensions need the same seam and you would otherwise fork core. Document it in HOOKS.md.