Skip to content

AI blog · DotApp PHP Framework 2.0

How to create a module in DotApp PHP Framework

A DotApp PHP Framework application is a set of modules. Each module owns its routes, controllers, views, assets, and config fallbacks. You scaffold with DotApper, register defaults in initialize(), and put production overrides in app/config.php — never only inside the module folder. This article is a complete Shop module from zero: CLI, folder map, module.init.php, a controller, a view, and portable config.

Common mistakes

Wrong Right
Hand-write the module class, controller, or middleware files. Run php dotapper.php --create-module=Shop and the matching --create-controller / --create-middleware commands.
Hard-code /shop and secrets only in the module folder. Set fallbacks in initialize(), then override in app/config.php.
Skip Config::module('Shop', 'prefix') ?? Config::module('Shop', 'prefix', '/shop'). Missing fallbacks are a programmer error: the module is not portable.
Register application routes in index.php. Register routes in the module’s initialize($dotApp).
Create tables named items or dotapp_items. Module tables are {lowercase_modulename}_* — here shop_items.
Type-hint injected services on a method reached with Shop:Home@index!. Trailing ! skips DI. Create Renderer::new() inside the method.

When to create a module

Create a module when you add a product surface: a shop, a docs site, a users area, a public API. Do not add “just one route” to another team’s module, and do not edit app/parts/. One feature set = one module name with a clear prefix.

Modules keep teams from stepping on each other

Modularity is not only a folder convention. It is how several people ship one DotApp PHP Framework app without sharing a single routes file. Developer A owns app/modules/Shop/: Shop URLs, Shop views, shop_* tables, and Shop fallbacks in initialize(). Developer B owns app/modules/Users/ the same way. Their git diffs stay in different trees. A merge does not rewrite the other person’s routes or module settings.

What they do share is app/config.php: databases, drivers, secrets, and Config::module('Shop', …) / Config::module('Users', …) overrides for this environment. That file is the contract with the application owner — not a dump of both teams’ PHP. Do not edit another module’s folder unless that team asked you to.

Owned by the Shop team Owned by the Users team Shared (app owner)
Router::get under the Shop prefix Routes under the Users prefix Host, HTTPS, database DSN
Config::module('Shop', …) fallbacks Config::module('Users', …) fallbacks Production overrides in app/config.php
Tables shop_* Tables users_* (plus auth tables the Users module installs) Connection name main, not table names
views/, assets/ inside Shop The same, inside Users Never app/parts/
Kill switch pattern

Optional public surfaces use a module flag with a false fallback, then an override in app/config.php. This site’s blog uses Config::module('Docs', 'showblog') ?? Config::module('Docs', 'showblog', false);. When the flag is false, do not register those routes.

What you may edit

Path Rule
app/config.php The only framework file you edit. Databases, drivers, secrets, Config::module overrides.
app/modules/Shop/** Everything inside the module you were asked to create.
app/parts/**, index.php, dotapper.php, app/DotApp.php Never edit. If you think core is wrong, stop and ask.

Scaffold with DotApper

Run DotApper from the project root (the folder that contains index.php and dotapper.php). --module= must appear before the create flag on the same command line.


Set-Location "path\to\project-root"
php .\dotapper.php --create-module=Shop
php .\dotapper.php --module=Shop --create-controller=Home
php .\dotapper.php --module=Shop --create-middleware=AuthGate
    

DotApper writes the namespace, the Module class, new Module($dotApp); at the bottom of module.init.php, and a controller that extends \Dotsystems\App\Parts\Controller. After that, you fill in fallbacks, routes, and views. Do not invent a second bootstrap file.

Folder map


app/modules/Shop/
  module.init.php              Module class, fallbacks, routes
  module.listeners.php         Optional; loaded before module.init.php
  Installation.php             Optional versioned DDL
  Controllers/Home.php
  Middleware/AuthGate.php
  views/home.view.php
  views/layouts/…
  assets/css/  assets/js/
  translations/
    
Artifact Namespace
Module / Listeners Dotsystems\App\Modules\Shop
Controllers Dotsystems\App\Modules\Shop\Controllers
Middleware Dotsystems\App\Modules\Shop\Middleware
Models Dotsystems\App\Modules\Shop\Models

Public files under app/modules/Shop/assets/ are served at /assets/modules/Shop/…. Do not copy framework JS into that folder. Pages that talk to PHP load /assets/dotapp/dotapp.js from the framework route.

Complete module.init.php

Fallbacks belong in initialize(). Read with Config::module('Shop', 'key'). If the key is missing, set the default with the three-argument call. Production values go in app/config.php.


<?php
namespace Dotsystems\App\Modules\Shop;

use Dotsystems\App\Parts\Router;
use Dotsystems\App\Parts\Config;

class Module extends \Dotsystems\App\Parts\Module
{
    public function initialize($dotApp)
    {
        Config::module('Shop', 'prefix') ?? Config::module('Shop', 'prefix', '/shop');
        Config::module('Shop', 'itemsPerPage') ?? Config::module('Shop', 'itemsPerPage', 20);
        Config::module('Shop', 'enckey') ?? Config::module('Shop', 'enckey', bin2hex(random_bytes(16)));
        Config::module('Shop', 'public') ?? Config::module('Shop', 'public', true);

        $p = Config::module('Shop', 'prefix');

        Router::get($p . '/', 'Shop:Home@index!', Router::STATIC_ROUTE);
        Router::get($p . '/item/{id:i}', 'Shop:Home@item!');
        Router::post($p . '/contact', 'Shop:Contact@save!', Router::STATIC_ROUTE);
    }

    public function initializeRoutes()
    {
        return ['/shop', '/shop/*'];
    }

    public function initializeCondition($routeMatch)
    {
        return $routeMatch;
    }
}

new Module($dotApp);
    
Method When it runs Return
initialize($dotApp) After initializeCondition allows boot. Register routes and fallbacks here. void
initializeRoutes() Used by php dotapper.php --optimize-modules for lazy loading. List of URL patterns, e.g. ['/shop', '/shop/*']. ['*'] loads on every request.
initializeCondition($routeMatch) Skip heavy init when the request is not for this module. Truthy to continue; falsy to skip initialize().

module.listeners.php, if present, is included before module.init.php. Put early global hooks there, not routes. Boot order and events: How module initialization works in DotApp PHP Framework.

Complete controller

Controllers are public static methods. There is no $this. The route Shop:Home@index! means: module Shop, class Controllers\Home, method index, no dependency injection.


<?php
namespace Dotsystems\App\Modules\Shop\Controllers;

use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\Logger;
use Dotsystems\App\Parts\Renderer;
use Dotsystems\App\Parts\Response;

class Home extends \Dotsystems\App\Parts\Controller
{
    public static function index($request)
    {
        $html = Renderer::new()
            ->module('Shop')
            ->setView('home')
            ->setViewVar('title', 'Shop')
            ->setViewVar('prefix', Config::module('Shop', 'prefix'))
            ->renderView();

        if ($html === '') {
            Logger::use()->error('Shop home view produced empty output');
            return new Response(500, 'Template error');
        }
        return $html;
    }
}
    

A missing view does not throw. The renderer logs a warning and returns "". Always check the string before you treat the page as rendered. Directives in the view use {{ var: $title }} — not {{ $title }}. Full template rules: How to render views and layouts in DotApp PHP Framework.

Complete view

File: app/modules/Shop/views/home.view.php.


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>{{ var: $title }}</title>
</head>
<body>
  <h1>{{ var: $title }}</h1>
  <p>Prefix: {{ var: $prefix }}</p>
  <script src="/assets/dotapp/dotapp.js"></script>
</body>
</html>
    

Production overrides in app/config.php

The module still ships fallbacks so it boots on a fresh clone. The application owner changes prefix, page size, and secrets in one file:


Config::module('Shop', 'prefix', '/store');
Config::module('Shop', 'itemsPerPage', 50);
Config::module('Shop', 'enckey', 'PRODUCTION_HEX_SECRET');
Config::module('Shop', 'public', true);
    

There is no global config() helper. Read with Config::module, Config::get, Config::session, and the other section helpers. How the file is structured: How app/config.php works in DotApp PHP Framework.

Route strings

String Resolves to
Shop:Home@index! Controllers\Home::index without DI
Shop:Home@index Same method with DI reflection
#Shop:AuthGate@check! Middleware\AuthGate::check
*Shop:Item@get! Models\Item::get

Grammar and DotApp::call(): Callable strings in DotApp PHP Framework. Routing verbs and STATIC_ROUTE: How routing works in DotApp PHP Framework.

Tables this module owns

Every table the Shop module creates must start with shop_. Use Installation.php for versioned DDL — DB::migrate() is not implemented. Walkthrough: How to create database migrations with Installation.php in DotApp PHP Framework.

FAQ

Why not one routes file for the whole app?

Then every team merge touches the same list of URLs and defaults. A module owns its initialize() routes and Config::module fallbacks. Two people can ship Shop and Users in parallel without overwriting each other. Production still has one app/config.php for overrides.

Must I use DotApper?

Yes for the skeleton. DotApper writes the namespaces and the new Module($dotApp) line correctly. Hand-created controllers often miss extends \Dotsystems\App\Parts\Controller or land in the wrong namespace.

What if I forget Config fallbacks?

The module then depends on whoever remembers to fill app/config.php. On a new environment the prefix is null, routes concatenate badly, and the module is not portable. That is a bug in the module, not in the framework.

Can two modules share a prefix?

Prefer one prefix per module. If you must share a path, order registrations carefully: the first matching route wins.

When do I need module.listeners.php?

When you need hooks before routes exist — for example a global Router::before. Normal GET/POST routes stay in initialize().

Where do CSS and JS go?

Module files: app/modules/Shop/assets//assets/modules/Shop/…. Framework client: always /assets/dotapp/dotapp.js on pages that post forms, call Bridge, or use $dotapp().load().

Is this the same as the Docs module on this site?

Same rules. Docs is one module with fallbacks for prefix, showblog, and friends. Your Shop module should look the same, just with a different name.

See also