Modules

How an application organizes its own code

What a module is (and is not)

A module is a unit of organization of your application, not a unit of distribution.

Composer handles distribution and autoloading. Definitions handles composition. Modules structure the app. Kaly never scans installed packages for extra keys and never gives a dependency behaviour just because it was installed: a library becomes part of the app when a config.php binds it.

Layout

Every folder of modules/ containing a config.php is a module. config.php is the only required file — it may be empty — and the only file booted per module: services and routing are both declared there.

modules/
  Common/
    config.php
    src/
  Site/
    config.php
    src/
    templates/
  Admin/
    config.php
    src/
    templates/
  Api/
    config.php
    src/
Path Role
config.php local composition root, required
src/ the classes, under the module namespace
templates/ registered under the module name (see Views)
assets/ browser-ready static files of the module (see Assets)

The namespace defaults to the camelized folder name (routable-module → RoutableModule). Use $module->namespace('Vendor\Thing') to override it.

A typical layering

        Common
      /   |    \
   Site  Admin  Api

Common holds what is genuinely transversal: domain model, repository interfaces, shared services, value objects. Avoid cycles (Site -> Admin -> Api -> Site) and avoid turning Common into a giant Utils: share the domain when it is really common, let the workflows live in the module that owns them.

Common/Order/Order.php
Common/Order/OrderRepository.php
Site/Order/PublicOrderService.php
Admin/Order/OrderManagementService.php
Api/Order/OrderController.php

Nothing enforces this by default: module dependencies are a convention, not a constraint. When an application wants the boundaries checked, Building a Kaly application provides a Mago Guard profile.

config.php

config.php is the module’s local composition root: the first place to look to understand how the module is assembled. It brings together the implementations chosen by the application, their dependencies, environment values, constructor parameters and routing declarations. Each module owns its part of the object graph; there is no central configuration file that must know every component.

config.php returns a closure. It receives the module and its definitions; the file runs in an empty scope, so nothing leaks, and the definitions are locked by the framework once it returns.

<?php

use Kaly\Core\Module;
use Kaly\Di\Definitions;
use Kaly\Util\Env;

return static function (Module $module, Definitions $di): void {
    $di
        ->bind(OrderRepository::class, SqlOrderRepository::class)
        ->set(ApiClient::class, static fn() => new ApiClient(
            endpoint: Env::getString('API_ENDPOINT'),
            timeout: Env::getInt('API_TIMEOUT', 10),
        ));
};

The service names above are application examples. Env supplies deployment values; the module decides how to use them. Application services receive the constructed dependencies through their constructors rather than reading Env or looking up configuration in the container. See DI for the equivalent composition using constructor parameters.

The module side of the configuration is fluent:

Method Effect
priority(50) configuration order, see below
namespace('Vendor\Thing') root namespace of the module classes
mount('boutique') url segment of the conventional routes
mount(['fr' => 'boutique', 'en' => 'shop']) one url segment per locale (with localized() and routing(localePrefixes: true))
mount('/') own the root: answer urls carrying no prefix (a single module)
localized() participate in locale-prefix routing (requires routing(localePrefixes: true))
routes(fn(Routes $routes) => ...) local route table, see Routing
resolver(PageResolver::class, priority: 500) custom resolver (pages in a database…)
claim('/about', fn(Routes $routes) => ...) own a path outside of the module segment
withoutConventionRouting() only expose the route tables and custom resolvers
whenAllLoaded(fn(Definitions $all) => ...) second pass, see below

The former format, where config.php used an implicit $this, is refused with an explicit message.

A module config should be fast, declarative and side effect free: no I/O, no database connection, no request dependent work, no expensive instantiation. Prefer a factory over an eager instance, so that the cost stays dormant:

// avoid: built during boot, on every request of every route
$di->set(HugeClient::class, new HugeClient(...));

// prefer: built on first get()
$di->set(HugeClient::class, fn() => new HugeClient(...));

Values and configuration objects

The object graph is the application’s configuration. Keep a value in config.php when its only purpose is to construct an object. A FooConfig DTO, builder or loader is not required for every component, and Kaly provides no generic configuration layer between module declarations and DI.

Introduce a separate object when the values have meaning together and that object is itself a useful dependency: a retry policy with validated limits, a cookie policy, or a money rounding policy. Kaly’s CookiePolicy is one such example. These objects are ordinary services in the same graph; they do not need a separate registry.

The rule is: inject dependencies as objects; supply configuration values where those objects are constructed; introduce a configuration object when the group of values has semantics of its own.

Loading order

Modules are discovered by sorted folder name, so the order is deterministic across filesystems, and their config.php closures run in that discovery order. Each one gets a priority of 100, 200, 300… in that order unless it sets its own with $module->priority(50). Definitions are then merged from the lowest priority to the highest. Merging is additive: two modules owning the same service id is a conflict that fails at boot — a later module cannot silently override an earlier binding. To replace a service intentionally, use rebind() in whenAllLoaded() (second pass below) or in an App::configure() hook, which runs last, once the modules are merged and the framework defaults registered — so rebind() works there for a module service and for a framework default alike.

Priority controls merging and the second pass; it does not reorder the initial execution of config.php. Service-id conflicts always fail, regardless of priority. Constructor parameters and named callbacks customize services rather than owning them: for the same parameter or callback key, the later merged value wins. Keep those customizations in the owning module where possible.

A second pass runs after every module has been merged, for features that depend on what the other modules declared:

$module->whenAllLoaded(function (Definitions $all): void {
    if ($all->has(SearchInterface::class)) {
        $all->bind(IndexerInterface::class, SearchIndexer::class);
    }
});

Autoloading

Declare your modules in composer.json — this is the recommended setup:

{
  "autoload": {
    "psr-4": {
      "Common\\": "modules/Common/src/",
      "Admin\\": "modules/Admin/src/"
    }
  }
}

If a module src/ directory is not covered by a psr-4 entry, Kaly registers a small fallback autoloader for that module only (Namespace\Some\Class → modules/Name/src/Some/Class.php). It is a convenience, not a discovery mechanism.

Routing a module

Every url belongs to exactly one module, which resolves it alone. Every module is mounted under its decamelized folder name, with no configuration: modules/Admin answers on /admin/..., modules/routable-module on /routable-module/.... The module mounted on / is the default one and answers without prefix, whatever its PHP namespace:

return static function (Module $module): void {
    $module->mount('/');   // a single module may own the root
};
return static function (Module $module): void {
    $module
        ->mount('back-office')              // /back-office/... instead of /admin/...
        ->routes(function (Routes $routes): void {
            $routes->get('/orders/{id}', [OrderController::class, 'show'])->name('order');
        });
};

A url segment is mounted once: two modules mounting the same segment fail at boot. See Routing for resolvers, claims, locales and url generation.

Registration is eager, resolution is lazy

Every config.php runs during boot(), so every module is always registered. But the container is lazy: a service is only built on the first get(). Nothing decides which modules are “active” — the dependency graph does it naturally.

boot
 |- Common/config.php  --+
 |- Site/config.php      |  declarations only
 |- Admin/config.php     |
 +- Api/config.php     --+

GET /api/orders
        |
    route Api
        |
  Api\OrderController
        |- OrderRepository
        +- ApiSerializer      <- the only services actually built

Admin and Site services: never requested

Route aware behaviour

Behaviour that belongs to a module rather than to a service goes in the routed middleware band, where the route — and therefore the module — is already known:

$app->middleware()->routed(
    AdminAuthorization::class,
    when: static fn(HttpContext $ctx): bool => $ctx->route()->module === 'Admin',
);

The condition is evaluated before the middleware is resolved, so a skipped middleware is never even built. Use this for admin audit and session policy, API CORS and authentication, site locale and CSP…

What applies to the response — regardless of the module or the origin of the response — goes in the outgoing band instead:

$app->middleware()->outgoing(
    ApiSerializer::class,
    when: static fn(ResponseInterface $response): bool => $response->getHeaderLine('Content-Type') === 'application/json',
);

Do not try to mutate the container per module ($ctx->activateModule('Api')). The container is application scoped while the context is request scoped: in a worker, requests hit Api, then Admin, then Site on the same container, and mutating the composition per route would break singletons already resolved.

Modules are always registered, but their runtime dependencies are demand driven. Route specific behaviour belongs after routing, not in module registration.


Kaly PHP framework — MIT

This site uses Just the Docs, a documentation theme for Jekyll.