Skip to content

Module Structure

Ray Fung edited this page Feb 26, 2026 · 5 revisions

Module File Structure

Understanding how Razy modules are organized on disk.

Overview

Every Razy module follows a strict directory convention. The framework uses this structure to auto-discover controllers, views, API handlers, web assets, and plugins. Modules live directly under the distributor's own folder (e.g., sites/{dist}/) or the global shared/module/ folder.

New to Razy? See the Hello World demo module ??a 4-file minimal module with only one route and plain text output. Every file is commented explaining why each line exists.

Canonical Layout

{vendor}/my-module/
?œâ??€ module.php                 # Module metadata (required)
?œâ??€ default/                   # Default version folder (required)
??  ?œâ??€ package.php            # Package configuration (required)
??  ?œâ??€ controller/            # Controller closures
??  ??  ?œâ??€ my_module.php      # Main controller (class name match)
??  ??  ?œâ??€ my_module.index.php   # Route handler "index"
??  ??  ?œâ??€ my_module.detail.php  # Route handler "detail"
??  ??  ?”â??€ api/               # API command handlers
??  ??      ?œâ??€ getData.php    # API command "getData"
??  ??      ?”â??€ submit.php     # API command "submit"
??  ?œâ??€ view/                  # Template files
??  ??  ?œâ??€ main.tpl           # Main template
??  ??  ?œâ??€ list.tpl           # List template
??  ??  ?”â??€ include/           # Included partials
??  ??      ?”â??€ alert.tpl
??  ?œâ??€ webassets/             # Web-accessible static files
??  ??  ?œâ??€ css/
??  ??  ?œâ??€ js/
??  ??  ?”â??€ img/
??  ?”â??€ plugins/               # Plugin closures
??      ?”â??€ my_plugin.php
?”â??€ 1.0.0/                     # Tagged version (optional)
    ?œâ??€ package.php
    ?œâ??€ controller/
    ?”â??€ view/

module.php

The root-level module.php defines module identity. It sits outside any version folder and provides metadata used by the distributor.

return [
    'module_code' => 'vendor/my-module',
    'name'        => 'My Module',
    'author'      => 'Author Name',
    'description' => 'Module description',
    'version'     => '1.0.0',
];

The module_code must be in vendor/package format, matching the directory path.

package.php

Each version folder contains a package.php that defines what gets loaded at runtime:

return [
    'module_code'  => 'vendor/my-module',
    'author'       => 'Author Name',
    'description'  => 'Package description',
    'version'      => '1.0.0',
    'api_name'     => 'mymod',       // Cross-module API identifier
    'alias'        => 'my_module',   // URL alias (defaults to class name)
    'shadow_asset' => false,         // Symlink webassets instead of copying
    'require'      => [
        'vendor/other' => '>=1.0.0',
    ],
];
Key Type Description
module_code string Module identifier in vendor/package format
author string Author name
description string Package description
version string Semantic version
api_name string Cross-module API identifier for $this->api('name')
alias string URL alias used in webassets and route paths. Defaults to the controller class name if not set.
shadow_asset bool When true, webassets are symlinked instead of copied during deployment. Automatically disabled for PHAR-packaged modules. Default: false.
require array Module dependencies with semver constraints

Controller Directory

The controller/ directory contains PHP closure files that the framework auto-loads. Files are named following a strict convention:

File Pattern Purpose Example
{class}.php Main controller ??extends Controller, defines lifecycle hooks my_module.php
{class}.{name}.php Route handler closure ??registered via addRoute() / addLazyRoute() my_module.index.php
api/{command}.php API command handler ??registered via addAPICommand() api/getData.php
demo/{name}.php Custom sub-folders for logical grouping demo/overview.php

Main Controller File

The main controller file returns an anonymous class extending Controller:

return new class extends Controller {
    protected function __onInit(Agent $agent): bool
    {
        $agent->addRoute('/', 'index');
        $agent->addAPICommand('getData', 'api/getData');
        return true;
    }
};

Route Handler Closure

Route handler files return a Closure that receives captured URL parameters:

// controller/my_module.index.php
return function () {
    $template = $this->loadTemplate('main');
    echo $template->output();
};

View Directory

The view/ folder holds .tpl template files used by the Razy Template Engine. Templates are loaded via $this->loadTemplate('name') in the controller, which resolves to view/name.tpl.

You can organize templates into subdirectories. The include/ subfolder is a convention for partial templates referenced by the INCLUDE block type.

Web Assets

The webassets/ directory contains publicly accessible static files (CSS, JS, images). These are served directly by the web server via Apache rewrite rules generated by php Razy.phar rewrite.

The URL structure is: {siteURL}/webassets/{moduleAlias}/{version}/{file}

Use $this->getAssetPath() in your controller to get the base URL, then append the file path:

// In a route handler closure
$cssUrl = $this->getAssetPath() . 'css/style.css';
// ??https://example.com/webassets/my_module/default/css/style.css

Plugin Directory

The plugins/ directory contains plugin closure files that extend the Template Engine or Collection system. Plugins are loaded via $this->registerPluginLoader() in the controller. See the Plugin System page for details.

Version Folders

Modules support multiple versions via named version folders:

{vendor}/my-module/
?œâ??€ module.php
?œâ??€ default/       ??default version
??  ?œâ??€ package.php
??  ?”â??€ ...
?œâ??€ 1.0.0/         ??tagged version
??  ?œâ??€ package.php
??  ?”â??€ ...
?”â??€ 2.0.0/         ??another tagged version
    ?œâ??€ package.php
    ?”â??€ ...

The dist.php configuration selects which version to load:

'modules' => [
    '*' => [
        'vendor/my-module' => '*',       // Latest version
        'vendor/my-module' => 'default', // Default folder
        'vendor/my-module' => '1.0.0',   // Specific tag
    ],
],

See Packaging & Distribution for full version management details.

Shared vs. Distributor Modules

Location Type Description
shared/module/ Shared Available to all distributors. Enabled via global_module or autoload_shared in dist.php
sites/{dist}/ Distributor-specific Only available to that distributor. Modules go directly under the site folder as {vendor}/{module}/
Custom module_path Override Set module_path in dist.php to use an alternative module source directory

Real Example: Route Demo

demo_modules/core/route_demo/
?œâ??€ module.php
?”â??€ default/
    ?œâ??€ package.php
    ?œâ??€ controller/
    ??  ?œâ??€ route_demo.php              # Main controller
    ??  ?œâ??€ route_demo.main.php         # /route handler
    ??  ?œâ??€ route_demo.article.php      # /article/:id handler
    ??  ?œâ??€ route_demo.product.php      # /product/:slug handler
    ??  ?œâ??€ route_demo.search.php       # /search handler
    ??  ?œâ??€ route_demo.user.php         # /user/:id handler
    ??  ?œâ??€ route_demo.tag.php          # /tag/:name handler
    ??  ?”â??€ route_demo.code.php         # /code/:lang handler
    ?”â??€ view/
        ?”â??€ main.tpl

Each .{name}.php file corresponds to a route action registered in the main controller's __onInit via $agent->addRoute().

Complete Module Skeleton

Here's a full, working module you can copy as a starting point:

// myteam/blog/module.php
return [
    'module_code' => 'myteam/blog',
    'name'        => 'Blog',
    'author'      => 'My Team',
    'description' => 'A blog module with listing and detail views',
    'version'     => '1.0.0',
];
// myteam/blog/default/package.php
return [
    'module_code' => 'myteam/blog',
    'author'      => 'My Team',
    'description' => 'Blog module ??default version',
    'version'     => '1.0.0',
    'api_name'    => 'blog',
    'alias'       => 'blog',
    'require'     => [],
];
// myteam/blog/default/controller/blog.php ??Main controller
use Razy\Controller;
use Razy\Agent;

return new class extends Controller {
    protected function __onInit(Agent $agent): bool
    {
        // Routes
        $agent->addRoute('/', 'index');
        $agent->addRoute('/post/(:d)', 'post');    // :d = digits
        $agent->addRoute('/search/(:a)', 'search'); // :a = any

        // API commands (callable from other modules)
        $agent->addAPICommand('getPost', 'api/getPost');

        // Listen for events
        $agent->listen('onReady', 'events/ready');

        return true;
    }

    protected function __onReady(): void
    {
        // All modules loaded ??perform late initialization
    }
};
// myteam/blog/default/controller/blog.index.php ??List route
return function () {
    $template = $this->loadTemplate('list');
    $template->assign('title', 'Blog Posts');
    echo $template->output();
};
// myteam/blog/default/controller/blog.post.php ??Detail route
return function (string $postId) {
    $db = $this->resolve(\Razy\Database::class);
    $post = $db->prepare()
        ->select('*')
        ->from('posts')
        ->where('id=:id')
        ->lazy(['id' => $postId]);

    if (!$post) {
        throw new \Razy\Exception\NotFoundException('Post not found');
    }

    $template = $this->loadTemplate('detail');
    $template->assign('post', $post);
    echo $template->output();
};
// myteam/blog/default/controller/api/getPost.php ??API handler
return function () {
    $params = func_get_args();
    $id = $params[0]['id'] ?? null;

    if (!$id) {
        return ['error' => 'Missing post ID'];
    }

    $db = $this->resolve(\Razy\Database::class);
    $post = $db->prepare()
        ->select('id, title, content')
        ->from('posts')
        ->where('id=:id')
        ->lazy(['id' => $id]);

    return ['post' => $post];
};

Clone this wiki locally