dotkernel-module-structure
Use when creating, moving, renaming or locating any file in a Dotkernel project — deciding whether code belongs in an application module or the shared Core layer, adding a Handler, Service, Collection
SKILL.md.claude/skills/dotkernel-module-structure/SKILL.md
GitHubModule and file structure
Applies to
Projects: - Dotkernel API
- Dotkernel Admin
Packages: - none
Identify the variant first (dotkernel-project-variants). The two-layer split below is how the
stock API and Admin are built; which artefacts a module contains differs between them. Frontend
keeps entities inside its own modules, Light has no persistence, and Queue is a single flat
Queue\ namespace — none of them has a Core layer.
The two layers
| Layer | Namespace | Path | Contains |
|---|---|---|---|
| Application module | <App>\<Module>\ |
src/<Module>/src/ |
HTTP / CLI facing code |
| Shared core module | Core\<Module>\ |
src/Core/src/<Module>/src/ |
persistence + shared domain |
<App> is the project's root namespace — Api or Admin.
Core is designed to be a git submodule shared between the projects on one database
(see dotkernel-core-submodule). Nothing in Core may depend on a project namespace.
Golden rule: Core is the only place that manages database entities.
Which layer?
| Artefact | Layer |
|---|---|
| Handler, RoutesDelegator, Collection, Form, InputFilter, Input, Service, ServiceInterface, OpenAPI | application module |
| Entity, Repository, Enum, DBAL type, EventListener, Fixture, Migration, shared Message catalogue | Core |
| Middleware, Attribute, Exception, Guard, Template helper | the project's App module (cross-cutting) |
| CLI Command | application module if feature-specific, Core\App\Command if infrastructural |
| ConfigProvider | both — one per module, per layer |
A service imports Core\<Module>\Entity\X. Never the reverse.
Canonical module layout
Shared skeleton:
src/<Module>/src/
├── Handler/<Resource>/… one folder per resource, nested for sub-resources
├── InputFilter/
│ ├── Input/…Input.php
│ └── {Create,Update}<X>InputFilter.php
├── Service/
│ ├── <X>Service.php
│ └── <X>ServiceInterface.php
├── ConfigProvider.php
└── RoutesDelegator.php
src/Core/src/<Module>/src/
├── Entity/<X>.php
├── Repository/<X>Repository.php
├── Enum/<X>StatusEnum.php
├── DBAL/Types/<X>StatusEnumType.php
└── ConfigProvider.php
Variant-specific additions to the application module:
- API:
Collection/<X>Collection.php,OpenAPI.php - Admin:
Form/<X>Form.php, and templates undersrc/<Module>/templates/<module>/
Handler folders mirror the URL: a route at /user/{id}/avatar lives in
Handler/User/Avatar/.
Wiring a new module — all four steps, or it silently does nothing
composer.json→autoload.psr-4:"<App>\\Book\\": "src/Book/src/"and"Core\\Book\\": "src/Core/src/Book/src/". This edits composer.json — ask the user first, then they runcomposer dump-autoload.config/config.php→ add bothConfigProvider::classentries to the Dotkernel modules block, alphabetically.- The module
ConfigProvider→dependencies(factories / delegators / aliases), plus whatever else the variant needs (HALMetadataMapfor API,templatesfor templated projects,doctrine.typesfor enum columns). - The authorization config → an entry for every new route name.
API:
config/autoload/authorization.global.php— a missing entry denies the route. Admin:config/autoload/authorization-guards.global.php— a missing entry leaves the route open to everyone, because the guards usePOLICY_ALLOW.
Config is cached in production; run the repo's cache-clear script (commonly
composer clear-config-cache) after config changes.
ConfigProvider contract
public function __invoke(): array
{
return [
'dependencies' => $this->getDependencies(),
// API: MetadataMap::class => $this->getHalConfig(),
// Admin: 'templates' => $this->getTemplates(),
];
}
delegators→ API: each handler maps to the repo's handler delegator factory. Admin handlers have no delegator entry; theredelegatorsholds onlyApplication::class => [RoutesDelegator::class]and form delegators.factories→ each handler and concrete service maps toAttributedServiceFactory::class.aliases→BookServiceInterface::class => BookService::class.
Keep these arrays alphabetically sorted with aligned => — phpcs enforces the alignment.
Other paths
| Path | Purpose |
|---|---|
config/autoload/*.global.php |
committed defaults |
config/autoload/*.local.php |
git-ignored secrets; only .dist templates are committed |
config/pipeline.php |
PSR-15 middleware order |
config/routes.php |
thin — real routes live in each module's RoutesDelegator |
bin/ |
CLI entry points |
test/Unit/, test/Functional/ |
namespaces per autoload-dev |
documentation/ |
project docs (CLI command docs, a generated openapi.json, Postman collections) where present — the stock API ships one, derived projects vary, so check |
| any Doctrine migrations directory | generated — never hand-edit |
Checklist for a new artefact
- Namespace matches the directory exactly (PSR-4, case-sensitive).
- Entity/Repository in
Core(API and Admin; Frontend: inside the owning module), everything request-facing in the application module. - Registered in the module
ConfigProvider(API handlers: factory and delegator; Admin handlers: factory). - Route declared in
RoutesDelegator; authorization entry added. - Presentation registered: HAL metadata (API) or template path (templated project).
-
OpenAPI.phpoperation added, if this project publishes OpenAPI. - Test added.