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/ |
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.