Modules

A module groups one application's services, controllers, routes, migrations and extensions behind a manifest. Modules can declare dependencies and be enabled or disabled without removing their source files.

The module classes live in FloCMS\Core\Modules. The skeleton does not configure or boot them automatically. This guide adds a news module and the setup it needs.

Creating a module

php flo make:module news

This creates modules/news/module.php and modules/news/src/NewsServiceProvider.php, using the namespace App\Modules\News. It does not synchronize a registry, run migrations or enable the module.

Add the files used in this guide:

File Purpose
modules/news/module.php Manifest
modules/news/src/NewsServiceProvider.php Register and boot services
modules/news/src/NewsService.php Example application service
modules/news/routes/api.php API route registration
modules/news/database/001_create_news_posts.php Module migration
modules/news/Integrations/page-blocks.php Optional extension, covered in module extensions

Create the directories before synchronizing. Every declared route, extension and migration file must exist.

The manifest

Replace the generated manifest with:

<?php
declare(strict_types=1);

return [
    'id' => 'news',
    'name' => 'News',
    'description' => 'News posts and their public API.',
    'version' => '1.0.0',
    'schema_version' => 1,
    'kind' => 'optional',
    'default_enabled' => false,
    'priority' => 1000,
    'dependencies' => [],
    'autoload' => ['App\\Modules\\News\\' => 'src'],
    'providers' => [App\Modules\News\NewsServiceProvider::class],
    'routes' => ['api' => 'routes/api.php'],
    'migrations' => ['database/001_create_news_posts.php'],
];
Field Meaning
id Required; matches the directory name; lowercase letters, digits and dashes, starting with a letter
name Required; display name, 1–150 bytes
version Required; semantic version, such as 1.0.0
schema_version Required; non-negative integer identifying the required database schema
kind Required; core or optional
default_enabled Initial state; defaults to false; must be true for a core module
description Optional text
priority Ordering among modules, lower first; defaults to 1000; dependencies still come first
dependencies Module IDs or maps with id and minimum_version
autoload PSR-4 namespace prefixes mapped to module-relative directories
providers Service provider class names
routes Channel names mapped to route files; the API loader uses api by default
extensions Extension names mapped to files
migrations Ordered list of migration files, not directories
controllers, models Class ownership declarations, including compatibility short names
metadata Optional application-defined array

Paths are relative to the module and cannot be absolute or contain ... IDs, namespace prefixes and declared class ownership must not conflict with another module. See module lifecycle for dependencies and validation.

Registering a service

Create modules/news/src/NewsService.php:

<?php
declare(strict_types=1);

namespace App\Modules\News;

final class NewsService
{
    public function title(): string
    {
        return 'News';
    }
}

Replace modules/news/src/NewsServiceProvider.php with:

<?php
declare(strict_types=1);

namespace App\Modules\News;

use FloCMS\Core\Modules\Contracts\ModuleProviderInterface;
use FloCMS\Core\Support\Container;

final class NewsServiceProvider implements ModuleProviderInterface
{
    public function register(Container $container): void
    {
        $container->singleton(NewsService::class, NewsService::class);
    }

    public function boot(Container $container): void
    {
        // Services from all enabled providers have been registered here.
    }
}

The bootstrapper registers the module autoloader, then calls register() on enabled providers in dependency order. Only after all registrations does it call their boot() methods. Repeated boot() calls on the same bootstrapper do nothing. See service container.

Configuring the runtime

Create config/modules.php:

<?php
declare(strict_types=1);

use FloCMS\Core\App;
use FloCMS\Core\DatabaseConnectionException;
use FloCMS\Core\Modules\ModulePaths;
use FloCMS\Core\Modules\ModuleSystem;
use FloCMS\Core\Support\Container;

// A database outage must not take every page down with it
try {
    $database = App::db();
} catch (DatabaseConnectionException) {
    $database = null;
}

ModuleSystem::configure(
    paths: ModulePaths::fromRoot(ROOT),
    database: $database,
    container: new Container(),
    allowRuntimeDiscovery: false,
    tablePrefix: 'app_'
);

Load this file at the end of config/config.php, after database settings:

require_once ROOT . '/config/modules.php';

This configures paths and services without booting providers. Keep configuration separate from booting so a deployment command can synchronize a missing registry first. The example uses your configured MySQL/MariaDB database; complete database setup first.

Important

Keep the try/catch. This file runs on every request, and App::db() throws when the database can't be reached. Without the catch, a database outage turns every page into an error, the static ones and /api/v1/health included.

With the catch, the module system runs without its database while the server is down. Module state then comes from the state cache, storage/cache/modules-state.json, which is written whenever state is read from the database and refreshed by module migrations and state changes. Module migrations and setEnabled() still need the database.

If that file is missing during an outage, modules fall back to their manifests, so only core and default_enabled modules count as enabled, and that fallback is written to the cache file. Once the database is back, delete storage/cache/modules-state.json or run your preparation command to reload the real state.

App::db() opens the connection here, on every request, also for pages that never query the database. That's the price of storing module state in the database.

ModuleSystem::configure() resets the previous module runtime. Call it once per application bootstrap, before resolving module services.

Paths and custom repositories

ModulePaths::fromRoot(ROOT) selects modules/ as the discovery root and storage/cache/ for modules.php and modules-state.json. Pass its $modules and $cache arguments to change those relative directories.

For several module roots or separate cache paths:

$paths = ModulePaths::custom(
    root: ROOT,
    moduleDirectories: ['modules', 'packages/site-modules'],
    registryCache: 'storage/cache/modules.php',
    stateCache: 'storage/cache/modules-state.json'
);

The facade also accepts repository: implementing ModuleStateRepositoryInterface. Without one, a configured database supplies DatabaseModuleStateRepository. Other database drivers need their own state repository and migration runner; the bundled implementations use MySQL/MariaDB.

Preparing and enabling the module

Add the module migration and the route file below before synchronizing. Then follow the preparation command to synchronize and migrate, and changing module state to enable news.

Booting page requests

In public/index.php, after the application bootstrap and before App::run(), add:

\FloCMS\Core\Modules\ModuleSystem::bootstrapper()->boot();

Module autoloading alone does not create conventional page URLs. The page router still resolves FloCMS\Controllers\NameController. To keep an existing page controller inside a module, declare a short name in controllers, put its class in Http/NameController.php, and keep the FloCMS\Controllers namespace. Models have the equivalent models declaration and Models/NameModel.php layout.

New service and API classes should use their own PSR-4 namespace. Declaring a namespaced controller does not change how the conventional page router selects classes.

The page runtime does not automatically check module state. A module-owned page action can turn an unavailable module into a 404:

if (!\FloCMS\Core\Modules\ModuleSystem::manager()->isEnabled('news')) {
    throw new \FloCMS\Core\HttpException(404);
}

assertEnabled() and assertEnabledForController() instead throw ModuleUnavailableException; applications decide how to handle it.

Loading module API routes

Create modules/news/routes/api.php:

<?php
declare(strict_types=1);

use App\Modules\News\NewsService;
use FloCMS\Api\Router;

return static function (Router $router): void {
    $router->get('/v1/news', static fn (NewsService $news): array => ['title' => $news->title()])
        ->name('news.index');
};

In public/api.php, after bootstrap, use the module container instead of creating another container, and boot providers before loading routes:

use FloCMS\Core\Modules\ModuleSystem;

$container = ModuleSystem::container();
ModuleSystem::bootstrapper()->boot();

Inside the existing /api router group, after loading the application's route file, add:

(new \FloCMS\Api\RouteLoader($router, $container))->loadModules(
    ModuleSystem::registry(),
    ModuleSystem::manager()
);

Add modules: ModuleSystem::manager() to the existing kernel constructor, retaining its debug and logger arguments:

$kernel = new \FloCMS\Api\Kernel(
    router: $router,
    container: $container,
    modules: ModuleSystem::manager(),
    debug: $debug,
    logger: $logger
);

The enabled news module now responds at /api/v1/news. The loader reads the api channel, tags each route with its module ID and skips disabled modules by default. The kernel's module manager also checks tagged routes at dispatch and returns 404 when unavailable. Keep the existing middleware and authentication aliases.

Route files may return a registration callable taking the router and optionally the container, or a list of definitions with method/methods, path, handler, name, module and middleware. Channel selection is loadModules($registry, $manager, $channel = 'api', $enabledOnly = true); it does not create a page-channel dispatcher.

Esc