Modules

Modules are installable Composer packages that add functionality to your Rhapsody site — event listeners, routes, Twig helpers, admin-configurable settings. You can build them for your own use, share them privately, or sell them on the Rhapsody Marketplace.

Key concept: a module's code never runs freely. Instead, it receives a ModuleContext object that acts as a controlled gateway. Every action you take through this gateway is checked against your module's module.json manifest — if you haven't declared a capability, the action is denied immediately.

This page goes into a lot of detail on purpose. If you've never built anything for Rhapsody before, read it top to bottom — every concept it uses (the container, facades, the manifest, permissions) is explained the first time it comes up, and every code example is real, working code, most of it lifted directly from modules that actually ship with this framework.

In a hurry? Jump to the Quick Reference at the bottom — it's the same information as this whole page, compressed to a dozen bullet points.

On this page


1. Concepts You Need First

If you already know what a service container, a Composer package, and PSR-4 autoloading are, skip to Module Structure. If any of those are new to you, read this section — everything else on this page assumes you understand them.

What is the "container"?

Rhapsody, like most modern PHP frameworks, is built around a service container (sometimes called a "dependency injection container," or just "the container"). It's a single object, built once per request, that knows how to construct and hand out every major piece of the framework — the Router, the EventDispatcher, the Twig Environment, your database connection, and so on.

The reason this exists: without it, every class that needs a Router would have to know how to build one itself — including all of its dependencies, and their dependencies, all the way down. The container does that wiring once, centrally, and anything that needs a piece of the framework just asks the container for it by type. Framework code (controllers, core services) does this constantly. For example, deep inside the framework, something like this happens on every request:

$router = $container->resolve(Router::class);
$twig   = $container->resolve(Environment::class);

That's normal, everyday framework code. It is not something your module ever does directly, and that's deliberate — read on.

Why modules don't get the container

If a module could call $container->resolve(...) for anything it wanted, it could reach the database connection, read $_SERVER, register global middleware that intercepts every request on the site, or call exec(). At that point, reviewing a module before it goes on the marketplace means reading every single line of its code, by hand, forever — there's no way to reason about what it might do just by reading its manifest.

So modules don't get the container. Instead, every module's entry point receives a ModuleContext object — a small, deliberately limited stand-in. ModuleContext hands out exactly five focused tools (we call them facades — a facade here just means "a small wrapper object that stands between your code and a real framework service, and checks your permissions before doing anything on your behalf"). You'll meet all five in section 5.

This is the single most important idea on this page: your module's code never touches the container, the database, or the filesystem directly — it only ever talks to $context, and $context checks your manifest before doing anything.

What is a Composer package, briefly

If you've used PHP for any length of time you've probably run composer require for something. A module is exactly that kind of package — a directory with a composer.json describing its name, its dependencies, and (critically for autoloading — see below) which folder its PHP classes live in. Nothing about a Rhapsody module is a special new packaging format; it's a normal Composer package with one extra convention (a module.json manifest) and one required field ("type": "rhapsody-module") that tells Rhapsody's module system to look for it.

What is PSR-4 autoloading, briefly

PSR-4 is the standard PHP uses to map a class's namespace onto a file path, so that PHP can find and load a class file automatically the first time that class is used — you never write a manual require or include for your own classes. In a module's composer.json, this line:

"autoload": {
    "psr-4": { "Acme\\WelcomeBonus\\": "src/" }
}

means "any class under the Acme\WelcomeBonus namespace lives in the src/ folder, in a matching path." So Acme\WelcomeBonus\ModuleProvider must live at src/ModuleProvider.php, and Acme\WelcomeBonus\Support\Formatter (if you had one) would live at src/Support/Formatter.php. Get this mapping wrong — wrong folder, wrong namespace, typo in either — and your provider class in module.json simply won't be found. This is, in practice, one of the most common first-time mistakes; see Troubleshooting.


2. Module Structure

A module is a standard Composer package with one special requirement: its type must be rhapsody-module. That's the field Rhapsody's ModuleRegistry uses to find your package among everything else installed in vendor/ — it asks Composer, "give me every installed package whose type is exactly rhapsody-module," and goes from there.

my-modules/welcome-bonus/
├── composer.json      # Standard Composer file
├── module.json        # Your module's manifest (most important file!)
└── src/
    └── ModuleProvider.php  # Your module's entry point

Here's a minimal composer.json:

{
    "name": "acme/welcome-bonus",
    "type": "rhapsody-module",
    "require": {
        "php": "^8.3",
        "arout/rhapsody-core": "^2.0"
    },
    "autoload": {
        "psr-4": { "Acme\\WelcomeBonus\\": "src/" }
    }
}

Walking through every line, for anyone new to composer.json:

  • name — the package name Composer (and Rhapsody) identify this module by. Always vendor-slug/module-slug, lowercase.
  • type — must be exactly rhapsody-module. This is not a Rhapsody-specific Composer feature — type is a standard Composer field any package can set to any string; Rhapsody's ModuleRegistry just happens to look specifically for this value.
  • require — your module's own dependencies. You'll basically always require arout/rhapsody-core with a version constraint, since your code depends on classes like ModuleContext that live in that package.
  • autoload — the PSR-4 mapping explained above.

3. The Manifest (module.json)

This file declares everything your module is allowed to do. Think of it as a permission request form, filled out once, that the framework checks against for the entire lifetime of your module. If your provider ever tries to do something not listed here, the framework throws an exception immediately — no silent failures, no security holes, and (this matters a lot once you submit to the marketplace) no need for a human reviewer to read your whole codebase just to know what your module is capable of. They can read this one file.

Required Fields

  • name – vendor-slug/module-slug, must match your Composer package name exactly, character for character.
  • type – must be "rhapsody-module". Same field as in composer.json, repeated here so the manifest is self-describing even outside a Composer context.
  • version – semantic version, e.g. 1.0.0. (If "semantic version" is new to you: it's the MAJOR.MINOR.PATCH convention where you bump MAJOR for breaking changes, MINOR for new backward-compatible features, and PATCH for bug fixes.)
  • rhapsody_core – a version constraint against the installed core, e.g. "^2.0" means "any 2.x version, but not 3.0 or later." Modules whose constraint doesn't match the installed core version are skipped gracefully at boot — not a fatal error, just quietly not loaded, with a line in the error log explaining why.
  • title – human-readable name displayed in the marketplace and in module:list output. This is what a person sees, as opposed to name, which is what the machine sees.
  • provider – the fully-qualified class name of your entry-point class (see Writing the Provider), e.g. "Acme\\WelcomeBonus\\ModuleProvider". Note the doubled backslashes — that's how a single literal backslash is written inside a JSON string.

Optional Fields

  • description – shown on your marketplace listing. Keep it to a sentence or two.
  • category – must be one of: seo, commerce, content, analytics, integration, security, media, utility, other. Defaults to other if you leave it out.
  • license – an SPDX identifier (like MIT) or proprietary for a commercial license. Defaults to proprietary.
  • author – an object with optional name, email, url sub-fields.
  • settings_schema – defines the fields for an admin-editable settings form (validated today; the actual form-rendering UI is planned but not shipped yet — see What's Coming). Write it anyway; when the renderer ships, your module gets a working settings page for free with no code changes.

Permissions

There are exactly seven capability keys, and this is a closed list — you cannot invent an eighth, and putting anything else here fails validation. Omitting a key (or explicitly setting it to false) means your module cannot perform that action, full stop; there's no "maybe" or "ask at runtime." Each one is covered in exhaustive detail in section 5 below, but as a quick preview, they are: events.listen, routes.register, twig.extensions, twig.functions, storage.access, settings.manage, database.access.

Full Example

{
    "name": "acme/welcome-bonus",
    "type": "rhapsody-module",
    "version": "1.0.0",
    "rhapsody_core": "^2.0",
    "title": "Welcome Bonus",
    "description": "Grants new users a configurable welcome bonus.",
    "category": "commerce",
    "provider": "Acme\\WelcomeBonus\\ModuleProvider",
    "permissions": {
        "events.listen": { "listen": ["Rhapsody\\Core\\Events\\UserRegistered"] },
        "routes.register": { "prefix": "welcome-bonus" },
        "settings.manage": {},
        "storage.access": {}
    },
    "settings_schema": [
        { "key": "bonus_amount", "type": "number", "label": "Bonus amount (USD)", "default": 10 }
    ]
}

Notice this module did not declare twig.extensions, twig.functions, or a wide-open events.listen — it only asked for exactly what it uses. That's the pattern to copy: request the smallest set of permissions your module actually needs. It's not just tidiness — a smaller permission set is faster to review and more likely to sail through automated marketplace checks without a human needing to look at it at all.

Important: an invalid manifest (unknown permission key, malformed field, missing required field) throws a ManifestValidationException the moment it's parsed. During normal boot, this disables just that one module — logged, not fatal, the rest of your site keeps working. During marketplace submission, manifest validation is the very first, cheapest stage of the automated review pipeline, run before a single line of your module's actual PHP code is ever executed.

Some real examples of what that exception message actually looks like, so you can recognize it instantly if you hit it:

module.json: missing required field "provider"
module.json: "type" must be "rhapsody-module", got "plugin"
module.json: "name" must look like "vendor-slug/module-slug"
module.json: "version" must be valid semver, got "1.0"
module.json: unknown permission "payments.charge" — allowed: events.listen, routes.register, twig.extensions, twig.functions, storage.access, settings.manage, database.access
module.json: "events.listen" requires a non-empty "listen" array of event class names

Each of those corresponds to exactly one thing going wrong — a typo'd field name, a permission key that doesn't exist, forgetting the listen array. If you see one of these, the fix is almost always a one-line edit to module.json.


4. Writing the Provider

Your provider class is the entry point for your module — the one class the framework ever directly instantiates and calls into. It must implement three methods from ModuleServiceProviderInterface:

  • boot() – runs on every request, for every module that's currently installed. This is where you register listeners, routes, and Twig helpers. Keep it fast — it's on the hot path of every single page load.
  • install() – runs exactly once, the first time an operator runs php rhapsody module:install your/module. Seed default settings here, create database tables (once that capability ships), anything that should happen one time on activation.
  • uninstall() – runs exactly once, when an operator runs php rhapsody module:uninstall your/module. Clean up whatever install() created.

The request lifecycle, in order

To make the "runs on every request" vs. "runs once" distinction concrete, here's the actual sequence of events, from Composer installing your package to your code executing on a real page load:

composer require acme/welcome-bonus     # code is on disk, autoloadable — nothing runs yet
php rhapsody module:list                # ModuleRegistry finds it (type: rhapsody-module), shows "not installed"
php rhapsody module:install acme/welcome-bonus
    → ModuleRegistry validates the manifest
    → ModuleRegistry checks rhapsody_core compatibility
    → your provider's install() runs — ONCE, right now
    → the module is marked "installed"

# every request from here on, e.g. a browser hitting any page of the site:
    → bootstrap.php builds the container
    → ModuleRegistry::bootAll() runs
    → for every INSTALLED module (yours included): your provider's boot() runs
    → your event listeners / routes / twig helpers are now live for this request
    → the rest of the request is handled normally (routing, controller, response)

php rhapsody module:uninstall acme/welcome-bonus --force
    → your provider's uninstall() runs — ONCE, right now
    → the module is marked "not installed" — boot() will not run for it anymore

The practical upshot: nothing in your module does anything at all until an operator explicitly runs module:install. Being composer require'd only makes your module discoverable, not active.

Here's a complete, real provider (this is the actual acme/welcome-bonus example module shipped with the framework — you can find it at vendor/arout/rhapsody-core/examples/acme-welcome-bonus/ and read/copy it directly):

<?php
namespace Acme\WelcomeBonus;

use Rhapsody\Core\Events\UserRegistered;
use Rhapsody\Core\Modules\Contracts\ModuleServiceProviderInterface;
use Rhapsody\Core\Modules\ModuleContext;
use Rhapsody\Core\Response;

class ModuleProvider implements ModuleServiceProviderInterface
{
    public function boot(ModuleContext $context): void
    {
        // Listen for user registration events.
        $context->events()->listen(UserRegistered::class, function (UserRegistered $event) use ($context) {
            $amount = $context->settings()->get('bonus_amount', 10);
            $context->storage()->put(
                'bonus-log.txt',
                sprintf("[%s] granted $%s to user #%s\n", date('c'), $amount, $event->user->getUserId())
            );
        });

        // Register a route: mounted at /welcome-bonus/balance
        $context->routes()->get('/balance', function () use ($context) {
            $amount = $context->settings()->get('bonus_amount', 10);
            return (new Response())->setContent("Current welcome bonus: \${$amount}");
        });
    }

    public function install(ModuleContext $context): void
    {
        // Set default settings when the module is first activated.
        $context->settings()->set('bonus_amount', 10);
    }

    public function uninstall(ModuleContext $context): void
    {
        // Clean up whatever install() created.
        $context->storage()->delete('bonus-log.txt');
    }
}

Walking through boot() line by line, for anyone who wants the full mental model:

  1. $context->events()->listen(UserRegistered::class, ...) — registers a closure to run whenever a UserRegistered event is dispatched anywhere in the application. The closure itself receives the event object.
  2. Inside that closure, $context->settings()->get('bonus_amount', 10) reads a setting, falling back to 10 if it was never set.
  3. $context->storage()->put(...) writes a line to a log file that belongs exclusively to this module.
  4. $context->routes()->get('/balance', ...) registers an HTTP route. Because this module's manifest sets "prefix": "welcome-bonus", the final URL is /welcome-bonus/balance, not just /balance — more on exactly how that path gets built in 4.2.
Golden Rule: everything goes through $context — never reach for the service container, raw PDO, $_SERVER, eval(), or system functions like exec() directly. Marketplace static analysis specifically looks for exactly these patterns and will flag a submission that uses them, regardless of intent.

5. ModuleContext and the Six Facades

$context — the single argument every one of your three provider methods receives — is an instance of Rhapsody\Core\Modules\ModuleContext. It doesn't do much on its own; its entire job is to hand you one of six small, focused objects (the "facades" from section 1) when you call a method on it:

$context->events()    // → EventsFacade
$context->routes()    // → RoutesFacade
$context->twig()      // → TwigFacade
$context->storage()   // → StorageFacade
$context->settings()  // → SettingsFacade
$context->database()  // → DatabaseFacade

Every single one of these facades does the same thing before doing anything else: it checks ModulePermissions::can('the.relevant.key') against your manifest. If the answer is no, it throws ModulePermissionException immediately — your code never even reaches the real framework service underneath. This check happens on every call, not just once at boot, so there's no way to "get away with it" by calling something in an unusual order.

events.listen

First, what an "event" actually is, if that's a new concept: an event is a small object representing "something already happened" — a user registered, a payment succeeded — broadcast to anyone who cares, without the code that dispatched it needing to know who (if anyone) is listening. This is what lets your module react to things happening elsewhere in the application without the application needing to know your module exists.

As of this version of the framework, there are exactly four events dispatched by core that a module can listen for:

Event class Dispatched when Public properties
Rhapsody\Core\Events\UserRegistered A new user finishes registration $event->user — the new User entity
Rhapsody\Core\Events\PaymentSucceededEvent A payment completes successfully $event->transactionId, $event->amount, $event->metadata
Rhapsody\Core\Events\PaymentFailedEvent A payment attempt fails $event->message, $event->context
Rhapsody\Core\Events\RouteNotFound* No registered route matches the incoming request $event->uri, $event->method — plus a setResponse(Response $response) method

*RouteNotFound is not like the other three. UserRegistered and the two payment events are plain notifications — nothing your listener does can change what core does next. RouteNotFound is a stoppable event: if your listener calls $event->setResponse(...) — for example, after resolving the requested URI against your module's own table of old-slug-to-new-slug redirects — the router returns that response directly instead of showing a 404, and no other RouteNotFound listener runs. If your listener finds nothing, just return without calling setResponse() and the normal 404 proceeds untouched. See Stoppable Events for the full mechanism behind this, and Routing for how the router uses it.

Declare it in your manifest — remember, no wildcards, you list every event class you want by its full name:

"permissions": {
    "events.listen": {
        "listen": [
            "Rhapsody\\Core\\Events\\UserRegistered",
            "Rhapsody\\Core\\Events\\PaymentFailedEvent"
        ]
    }
}

Then, in your provider:

use Rhapsody\Core\Events\UserRegistered;
use Rhapsody\Core\Events\PaymentFailedEvent;

public function boot(ModuleContext $context): void
{
    $context->events()->listen(UserRegistered::class, function (UserRegistered $event) {
        // $event->user is the newly registered User entity
        error_log("Welcome, user #{$event->user->getUserId()}!");
    });

    $context->events()->listen(PaymentFailedEvent::class, function (PaymentFailedEvent $event) {
        // e.g. notify an admin, log to a monitoring service
        error_log("Payment failed: {$event->message}");
    });
}

Under the hood, what actually happens when you call listen():

  1. EventsFacade::listen() checks $this->permissions->can('events.listen'). If events.listen isn't in your manifest at all, it throws immediately: Module "your-slug" tried to listen for events without declaring "events.listen" in module.json.
  2. It then checks that the specific event class you passed is in your declared whitelist. If it isn't, it throws: Module "your-slug" tried to listen for "SomeOther\Event", which isn't in its declared events.listen.listen whitelist.
  3. Only if both checks pass does it call the real EventDispatcher::listen() underneath — the same dispatcher core itself uses.

routes.register

Lets you register HTTP routes: GET, POST, PUT, DELETE. There are two distinct ways to do this, and the difference matters.

Namespaced routes (the common case)

$context->routes()->get('/balance', function () {
    return (new \Rhapsody\Core\Response())->setContent('42');
});

This is not mounted at /balance. It's automatically mounted at /{prefix}/balance, where {prefix} is either your module's own slug (its name field with the slash turned into a dash — e.g. acme/welcome-bonus becomes acme-welcome-bonus) or, if you set one, the custom prefix from your manifest:

"routes.register": { "prefix": "welcome-bonus" }

With that manifest, $context->routes()->get('/balance', ...) ends up live at exactly /welcome-bonus/balance. This namespacing is automatic and mandatory for this method — it's what guarantees your module's routes can never collide with another module's routes, or with an app route, no matter what path string you pass in.

Root-level routes (the exception)

Sometimes a route genuinely needs to live at the true root of the site — a sitemap.xml, a robots.txt, a fixed webhook URL a third-party service expects at an exact path. For that, there's a second method, root(), which bypasses the prefix entirely — but only for paths you explicitly whitelist:

"routes.register": {
    "prefix": "seo-toolkit",
    "paths": ["/sitemap.xml", "/robots.txt"]
}
$context->routes()->root('GET', '/sitemap.xml', function () {
    // build and return the sitemap
});
$context->routes()->root('GET', '/robots.txt', function () {
    // build and return robots.txt
});

Try to call root() with a path that isn't in your paths whitelist and it throws: Module "your-slug" tried to register root-level route "/some/path", which isn't in its declared routes.register.paths whitelist. This is the same "explicit list, no wildcard" pattern as events.listen — you can have exactly the root path you need, and nothing more.

Advanced: reading the live route table

There's a third thing routes.register unlocks, gated by the same permission: read-only access to every route already registered on the site (app routes, core routes, and any module routes booted before yours), via $context->routes()->registered(). This is genuinely useful — for example, the shipped acme/seo-toolkit module builds sitemap.xml by reading the real route table instead of maintaining a hand-written URL list:

foreach ($context->routes()->registered() as $route) {
    // $route is a RegisteredRoute: ->method, ->path, ->name, ->middleware, ->controller
    if ($route->method !== 'GET') {
        continue; // nothing to crawl
    }
    if ($route->hasParams()) {
        continue; // can't enumerate a concrete URL for e.g. /docs/{slug}
    }
    // ...more filtering, then build a <url> entry from $route->path
}

Read the full thing at vendor/arout/rhapsody-core/examples/acme-seo-toolkit/src/ModuleProvider.php — it's a complete, working example of every filtering decision you'd realistically need to make (excluding non-public routes, excluding routes behind certain middleware, excluding routes owned by a specific controller class).

twig.extensions / twig.functions

Rhapsody's themes are rendered with Twig, a templating language — the .twig files in views/themes/. These two permissions let a module add its own logic that theme authors can call from inside a template.

twig.functions is the simpler of the two — a single callable exposed as a function:

$context->twig()->addFunction('formatBonus', function ($amount) {
    return '$' . number_format($amount, 2);
});

In a template, this is not called as formatBonus(10). Every function name you register is automatically prefixed mod_{your-slug}_, so it's callable as:

{{ mod_welcome_bonus_formatBonus(10) }}
{# outputs: $10.00 #}

This prefixing is enforced, not optional — it's what guarantees two different modules (or a module and core itself) can never silently overwrite each other's Twig functions just because they both picked the name format.

twig.extensions is for the more advanced case where you have a full Twig\Extension\ExtensionInterface class (multiple functions/filters/tests bundled together) rather than a single callable:

$context->twig()->addExtension(new MyModuleTwigExtension());

Declare only the one(s) you actually use — most modules only need twig.functions, if they need Twig integration at all.

storage.access

Lets you read and write files that belong exclusively to your module.

$context->storage()->put('log.txt', "Some data\n");           // write (creates or overwrites)
$content = $context->storage()->get('log.txt');                // read — returns false if missing
$exists  = $context->storage()->exists('log.txt');              // check without reading
$context->storage()->delete('log.txt');                         // remove

Every path you pass is resolved relative to storage/modules/{your-slug}/ — for the acme/welcome-bonus example above, that's literally storage/modules/acme-welcome-bonus/ on disk. You never construct that path yourself; you just pass 'log.txt' or 'cache/report.json' and the facade does the rest.

Path traversal is actively blocked, not just discouraged. If you pass something like '../../.env', trying to reach outside your module's own directory, the facade resolves the real filesystem path and checks it's still inside your sandbox before touching the disk — if it isn't, it throws ModulePermissionException with the message Storage path escapes module sandbox: ../../.env rather than silently succeeding. There is no way to read or write anything outside your own module's storage folder through this facade, full stop.

Typical uses: application logs specific to your module, generated/cached files (a rendered report, a downloaded image), simple flat-file data that doesn't need a real database table.

settings.manage

Lets your module persist its own configuration — the kind of thing an admin would eventually edit in a settings screen (see settings_schema back in section 3).

$context->settings()->set('bonus_amount', 25);              // write — requires settings.manage
$amount = $context->settings()->get('bonus_amount', 10);    // read — always allowed, even without the permission
$all    = $context->settings()->all();                       // every setting as an array

This is the one asymmetric permission on this whole page. Every other facade gates the entire capability — but for settings, reads are always allowed, with or without settings.manage declared, because a module obviously needs to read its own configuration just to function. Only writing a new value requires the permission.

How this is actually stored, in full: under the hood, each module's settings live in one plain JSON file, at storage/modules/{your-slug}/settings.json. For the welcome-bonus example, after install() has run, that file looks exactly like this on disk:

{
    "bonus_amount": 10
}

Every set() call reads that whole file, updates the one key you're setting, and writes the whole file back out — so it's safe to call set() repeatedly with different keys over time; nothing gets clobbered. There's no schema enforcement at the storage layer itself — you can store a string, a number, an array, whatever json_encode() can represent — but your settings_schema declaration is what tells the (future) admin UI what type each field should be and how to render it.

This is a JSON file rather than a database table by design, and that's not just a historical accident from before database.access existed — it keeps a module's own configuration readable and versionable as a single small file, with no schema migration of its own to manage. If your module needs genuinely relational storage instead (multiple rows, foreign keys, querying by anything other than a settings key), that's exactly what database.access below is for — the two are complementary, not one superseding the other.


database.access

Grants read access to every table in the application's database, and write access (including creating your own tables) to a table namespace reserved for your module alone — every table starting with mod_{your-slug}_. That prefix is not something you declare: it's derived automatically from your manifest's own name field, the exact same way slug() already is. There's no permissions-block field for it, on purpose — if a module could simply declare its own table prefix, it could declare "tablePrefix": "users" and write to a table it has no business touching. Deriving it from identity that's already fixed at manifest-parse time closes that off entirely.

Declare it in module.json with no extra configuration, same as storage.access:

{
    "permissions": {
        "database.access": {}
    }
}

The permission gates reads too — this is the one place that's easy to get backwards relative to settings.manage, where reads are free and only writes require the key. Here, query(), select(), find(), and findBy() all check can('database.access') exactly like the write methods do. What's asymmetric isn't whether the permission is required, but which tables a write is allowed to touch.

DatabaseFacade offers two ways in — plain SQL, or Doctrine — and the same ownership rule applies to both:

Method Scope Notes
query($sql, $params) Any table, read-only Must start with SELECT/WITH; a write-like keyword found anywhere else in the string is rejected too, as a defense against a stacked statement slipping through.
select($table, $where) Any table, read-only A structured convenience wrapper for the common case — no need to hand-write SQL for a simple lookup.
insert() / update() / delete() Your tables only Table name is a real parameter, not embedded in a SQL string — checked with a plain string comparison against your prefix, not parsed out of free-form SQL.
migrate($sql) Your tables only Accepts CREATE TABLE, ALTER TABLE, or DROP TABLE only — this is how a module gets its own real tables in the first place. Call it from install() to create, uninstall() to drop.
find() / findBy() Any entity, read-only Doctrine equivalent of query() — works against any entity the application already has mapped, e.g. Rhapsody\Core\Entities\User.
save() / remove() Your entities only Checked via Doctrine's own metadata — getClassMetadata($entity)->getTableName() — against your prefix before persist()/flush() is allowed to run.

Raw SQL example — a module tracking its own click-through counts, called from a route handler:

public function recordClick(ModuleContext $context, int $itemId): void
{
    $context->database()->insert('mod_acme_click_tracker_clicks', [
        'item_id'    => $itemId,
        'clicked_at' => date('Y-m-d H:i:s'),
    ]);
}

public function topItems(ModuleContext $context): array
{
    // Reading your own table works the same as reading any other —
    // select()/query() aren't scoped by ownership, only writes are.
    return $context->database()->select(
        'mod_acme_click_tracker_clicks',
        [],
        ['item_id', 'COUNT(*) as total']
    );
}

Doctrine example — the same module, using an entity instead of raw SQL:

// src/Entities/Click.php — table name must fall under your prefix,
// checked at save() time, not at class-definition time.
#[ORM\Entity]
#[ORM\Table(name: 'mod_acme_click_tracker_clicks')]
class Click
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Column(type: 'integer')]
    private int $itemId;

    #[ORM\Column(type: 'datetime')]
    private \DateTime $clickedAt;

    // ...constructor/getters omitted
}

public function recordClick(ModuleContext $context, int $itemId): void
{
    $context->database()->save(new Click($itemId, new \DateTime()));
}

public function lookUpBuyer(ModuleContext $context, string $userId): ?object
{
    // Reading an entity you don't own — allowed, read-only.
    return $context->database()->find(\Rhapsody\Core\Entities\User::class, $userId);
}

Notice $context->database() never hands you a raw PDO connection or the application's actual EntityManager — on purpose. Either one would come with unrestricted exec() or unrestricted persist() on any entity, which would make every other rule in this section decorative. Everything above is the entire surface area; if something isn't listed here, DatabaseFacade doesn't do it.


6. Installing and Testing Locally

Local Development Setup

While you're building a module, you don't want to publish it anywhere just to test it. Point your app's Composer at the module's local folder with a path repository:

// your-app/composer.json
"repositories": [
    { "type": "path", "url": "../my-modules/welcome-bonus" }
],
"require": {
    "acme/welcome-bonus": "*"
}

This tells Composer "treat this local folder as if it were a real registered package," so you can iterate on the module's code and just re-run Composer to pick up changes, with no publishing step.

Composer installation (getting the code) and module activation (making it run) are two separate, deliberate steps — this trips up almost everyone the first time:

# 1. Get the code
composer update acme/welcome-bonus

# 2. See everything Composer/Rhapsody can discover
php rhapsody module:list
#   acme/welcome-bonus     v1.0.0     not installed

# 3. Actually activate it (runs install(), boot() will now run every request)
php rhapsody module:install acme/welcome-bonus
#   "acme/welcome-bonus" v1.0.0 installed and activated.

php rhapsody module:list
#   acme/welcome-bonus     v1.0.0     installed

Testing each piece

  • Routes — just hit them with curl or your browser: curl http://your-app.test/welcome-bonus/balance. No special setup needed.
  • Event listeners — trigger the actual event through your application's real flow (e.g. registering a user through the real signup form), not by inserting a row directly into the database. Events only fire where application code explicitly dispatches them — a raw database insert never touches that code path, so your listener simply won't run.
  • Settings — after install() has run, check storage/modules/{your-slug}/settings.json directly. You can hand-edit it during testing to simulate an admin changing a setting, then re-test your module's behavior.
  • Storage — check files under storage/modules/{your-slug}/ directly with cat/a file browser after triggering whatever writes to them.

7. CLI Commands

Command What it does
module:list Lists every module Composer can discover (any installed package of type rhapsody-module) and whether it's currently installed.
module:install {name} Runs install() once and activates the module. Fails if the core-version constraint doesn't match, the provider class can't be found, or install() itself throws. Running it again on an already-installed module is a safe no-op — it won't re-run install().
module:uninstall {name} [--force] Runs uninstall() and deactivates the module. Prompts for confirmation first (since uninstall() may delete data) unless --force/-f is passed.

Note: module:uninstall does not remove the package from vendor/ — that's a separate, deliberate step if you also want the code itself gone:

php rhapsody module:uninstall acme/welcome-bonus --force
composer remove acme/welcome-bonus

8. A Second, Bigger Worked Example

The welcome-bonus example above touches four of the six facades but keeps each one simple. For a module that leans harder into routes.register — using both namespacing styles, root(), and registered() in one place — read through acme/seo-toolkit, also shipped with the framework at vendor/arout/rhapsody-core/examples/acme-seo-toolkit/. It generates sitemap.xml and robots.txt entirely from the live route table, configurable through settings, with zero database dependency and zero manually-maintained URL list. Its manifest:

{
    "name": "acme/seo-toolkit",
    "type": "rhapsody-module",
    "version": "1.0.0",
    "rhapsody_core": "^2.0",
    "title": "SEO Toolkit",
    "category": "seo",
    "provider": "Acme\\SeoToolkit\\ModuleProvider",
    "permissions": {
        "routes.register": {
            "paths": ["/sitemap.xml", "/robots.txt"]
        },
        "settings.manage": {}
    },
    "settings_schema": [
        { "key": "excluded_prefixes", "type": "string", "label": "Excluded path prefixes (comma-separated)", "default": "/auth,/login,/forgot-password,/reset-password,/payment" },
        { "key": "excluded_controllers", "type": "string", "label": "Excluded controller classes (comma-separated FQCNs)", "default": "Rhapsody\\Core\\Controllers\\DocsController" },
        { "key": "excluded_middleware", "type": "string", "label": "Excluded middleware keys (comma-separated)", "default": "auth" },
        { "key": "change_frequency", "type": "select", "label": "Sitemap change frequency", "default": "weekly", "options": ["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"] },
        { "key": "robots_disallow", "type": "string", "label": "Additional robots.txt Disallow paths (comma-separated)", "default": "/auth,/login" }
    ]
}

Notice everything here is root()-only — no namespaced prefix routes at all, because a sitemap only makes sense at the true site root, never under a module-specific path. Notice too that it doesn't declare storage.access or any twig.* permission, because it genuinely doesn't need them — it generates its output fresh on every request straight from the route table, rather than caching to a file. This is worth internalizing as a habit: only ask for the permissions your module actually uses.

The core of its boot() method — reading the live route table and filtering it down to what's actually safe to publish:

public function boot(ModuleContext $context): void
{
    $context->routes()->root('GET', '/sitemap.xml', fn () => $this->sitemapResponse($context));
    $context->routes()->root('GET', '/robots.txt', fn () => $this->robotsResponse($context));
}

private function sitemapResponse(ModuleContext $context): Response
{
    $excludedPrefixes    = $this->csv($context->settings()->get('excluded_prefixes', ''));
    $excludedControllers = $this->csv($context->settings()->get('excluded_controllers', ''));
    $excludedMiddleware  = $this->csv($context->settings()->get('excluded_middleware', 'auth'));

    $urls = [];
    foreach ($context->routes()->registered() as $route) {
        if ($route->method !== 'GET') continue;
        if ($route->hasParams()) continue;                                          // e.g. /docs/{slug}
        if (array_intersect($route->middleware, $excludedMiddleware)) continue;      // e.g. behind "auth"
        if ($this->startsWithAny($route->path, $excludedPrefixes)) continue;         // e.g. /payment/*
        if ($route->controller && in_array($route->controller, $excludedControllers, true)) continue;

        $urls[$route->path] = true; // dedupe
    }

    // ...build and return the XML from array_keys($urls)
}

That last filter — excluding by $route->controller — is worth calling out specifically: it's what lets this module tell apart core's own internal /docs/* reference pages (handled by Rhapsody\Core\Controllers\DocsController) from an app's own /docs pages under a completely different controller class, even though both sit at the same URL prefix. Path matching alone can't make that distinction — you need the actual handler identity, which is exactly what RegisteredRoute::$controller gives you.


9. Troubleshooting

Real errors you'll actually hit while building a module, and what they mean.

"My route always returns 404"

  • Did you run php rhapsody module:install your/module? Routes are only registered inside boot(), and boot() only runs for installed modules. Check with module:list.
  • Did you declare routes.register in your manifest at all?
  • Remember the automatic prefix — $context->routes()->get('/balance', ...) is not at /balance, it's at /{your-prefix}/balance. Double-check the URL you're actually requesting.

Exception: ModulePermissionException

This always means the same thing: your code called a facade method for a capability your manifest didn't declare — or declared, but not broadly enough (e.g. an event class not in your listen whitelist, or a root path not in your paths whitelist). The exception message names exactly which permission and which action. Fix: add or widen the relevant block in module.json.

Exception: ManifestValidationException

Your module.json itself is malformed — a typo'd field name, an unknown permission key, a missing required field, an invalid semver string. The exception message quotes the exact field and rule that failed (see the examples in section 3). Fix the JSON file directly; this has nothing to do with your PHP code.

"My provider class isn't found" / module doesn't show up in module:list

  • Almost always a PSR-4 mismatch — re-check the autoloading explanation above. The provider field in module.json must exactly match a real, autoloadable class.
  • Confirm "type": "rhapsody-module" is set in both composer.json and module.json — it's genuinely required in both places, for different reasons (Composer package discovery vs. manifest validation).
  • After any change to composer.json's autoload block, run composer dump-autoload. If your project's Composer is configured with an optimized/cached autoloader, new classes added to an existing package might not be picked up until you regenerate it.

"My event listener never fires"

  • Confirm you triggered the event through the real application flow, not a direct database write — see section 6.
  • Confirm the module is actually installed (module:list) — boot(), where listen() is called, only runs for installed modules.
  • Double-check the event class name in your manifest's listen array matches exactly, including the full namespace.

"My settings changes aren't taking effect"

  • Confirm you declared settings.manage — without it, set() throws rather than silently failing, so check your error log/output for a ModulePermissionException.
  • Check storage/modules/{your-slug}/settings.json directly to see the actual current state on disk — this sidesteps any question of whether your code is reading the value you think it's reading.

"My database write throws ModulePermissionException"

  • First check the message itself — it tells you which of two things went wrong: missing database.access entirely, or a table name that doesn't start with your prefix. They're different fixes.
  • Remember reads need the permission too. Unlike settings.manage, forgetting to declare database.access breaks query()/select()/find() just as much as it breaks a write.
  • Your table prefix is always mod_ + your manifest's name field with / and - both converted to _ — e.g. acme/click-tracker becomes mod_acme_click_tracker_. If your table doesn't start with exactly that, insert()/update()/delete()/migrate()/save()/remove() will all reject it — there's no way to configure or override the prefix.
  • For a Doctrine entity specifically, the check runs against the table name in your entity's own #[ORM\Table(name: ...)] attribute, not the entity's class name — a mismatched attribute is the usual cause here.

10. Glossary

Module
A Composer package of type rhapsody-module that adds functionality to a Rhapsody site through the permission-based API described on this page.
Manifest
The module.json file — declares what a module is and what it's allowed to do. See section 3.
Provider
The class named in a manifest's provider field — a module's entry point, implementing boot(), install(), and uninstall().
Slug
A module's name field with the slash replaced by a dash — acme/welcome-bonus becomes acme-welcome-bonus. Used to namespace routes, scoped storage, and settings so modules can never collide with each other.
Capability / Permission
One of the seven fixed keys in a manifest's permissions block, each unlocking one facade method (or part of one).
Facade (as used on this page)
A small object (EventsFacade, RoutesFacade, etc.) that stands between your module's code and a real framework service, checking your manifest's permissions before doing anything. Not the same concept as a "Laravel Facade," if you've encountered that term elsewhere — no static magic methods here, just plain objects.
Service Container ("the container")
The object, built once per request, that constructs and hands out every major framework service (Router, EventDispatcher, the Twig Environment, etc.). Modules never access it directly — see section 1.
Composer package
A directory with a composer.json, installable via composer require. Every Rhapsody module is one, with the extra "type": "rhapsody-module" field.
PSR-4 autoloading
The standard that maps a PHP namespace onto a folder structure, so class files load automatically without manual require statements. Configured per-package in composer.json's autoload.psr-4 block.
Semver (semantic versioning)
The MAJOR.MINOR.PATCH version numbering convention (e.g. 1.4.2), and the constraint syntax built on it (e.g. ^2.0 means "2.x, not 3.0+") used in both version and rhapsody_core.
Twig
The templating language Rhapsody themes are written in (the .twig files under views/themes/). twig.extensions/twig.functions let a module add logic theme authors can call from a template.

11. What's Coming

Future capabilities on the roadmap:

  • Admin UI renderer – automatically generate a working settings form from settings_schema, no extra code required.
  • Global middleware – for modules that need to intercept every request (e.g. a maintenance-mode module), deliberately not available yet since it's the single most powerful capability a module could have.

The manifest schema already reserves space for all of these, so adopting them later won't break any existing module.json file you write today.


Quick Reference

  • Modules are Composer packages with type: "rhapsody-module", discovered via Composer\InstalledVersions.
  • The manifest (module.json) declares permissions – only what's listed is allowed; seven fixed capability keys, no exceptions.
  • The provider is the entry point – implements boot() (every request), install() (once, on activation), uninstall() (once, on deactivation).
  • Everything goes through $context – never the raw container, never a raw PDO connection or EntityManager, never $_SERVER/exec() directly; use $context->database() for scoped database access.
  • Composer install ≠ module activation – module:install is what actually turns a module on.
  • Namespaced routes are auto-prefixed with your slug; use root() plus an explicit paths whitelist for true root-level URLs.
  • routes()->registered() gives read-only access to the live route table, gated by the same routes.register permission.
  • Storage is sandboxed to storage/modules/{slug}/, with path traversal actively blocked.
  • Settings are JSON-file-backed at storage/modules/{slug}/settings.json — reads always allowed, writes require settings.manage.
  • Database access is table-namespace-scoped — read any table, but write only to your own mod_{slug}_* tables (row data via insert()/update()/delete(), schema via migrate()); unlike settings, reads also require database.access.
  • Twig function names are auto-prefixed mod_{slug}_ to prevent collisions.
  • Request only the permissions you actually use — smaller manifests review faster, both by automation and by humans.