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