Create a Marko module
A Marko module is a Composer package that the framework auto-discovers via the extra.marko.module flag. Modules can live anywhere — app/{name}/ for application code, modules/{vendor}/{name}/ for a distributable package dropped into a host project, packages/{name}/ in the framework monorepo, or vendor/{vendor}/{name}/ once installed from Packagist. The layout is identical in every case. Directory and Composer-name segments are always lowercase; only the PHP namespace is StudlyCase.
This skill is the canonical specification for a Marko module. Do not inspect existing modules in this project to infer layout — siblings may have drifted from spec. Copy the templates from assets/ verbatim, substitute placeholders, and stop.
Step 1 — Confirm the name, then pick a location
If the user did not give the module a name, ask for one before scaffolding anything. Do not invent a name, reuse a vendor as the name, or proceed with a placeholder.
Casing rule — read this before creating any directory
A module's identity has two forms that must stay in sync:
- Directory and Composer name → always lowercase. They are identical segment-for-segment, so a module can move between tiers (e.g.
modules/→vendor/) or be symlinked with zero path rewrites. - PHP namespace → always StudlyCase.
blog→Blog,my-payment→MyPayment.
So the name blog yields directory blog/, Composer name …/blog, and namespace …\Blog. Lowercase module directories are correct — do not capitalize them. The casing only flips to StudlyCase in the PHP namespace and the psr-4 autoload key. (If you have seen "lowercase module names" before and assumed it was a bug, it was not: the directory is supposed to be lowercase; only the namespace is StudlyCase.)
Pick the tier by where the module lives
| Tier | Directory | Composer name | Namespace | When |
|---|---|---|---|---|
| App-local | app/{name}/ |
app/{name} |
App\{Name} |
Application code, not distributed. The vendor segment is always the literal app — there is no project-derived vendor. |
| Distributable | modules/{vendor}/{name}/ |
{vendor}/{name} |
{Vendor}\{Name} |
A package you intend to publish or share, dropped into a host project. Two-segment, vendor-scoped. |
| Framework monorepo | packages/{name}/ |
marko/{name} |
Marko\{Name} |
Only when working inside the Marko monorepo itself (root contains packages/core/). |
Never author into vendor/ — it is ephemeral and populated by Composer. The modules/ tier is the manual-install mirror of vendor/: same two-segment {vendor}/{name} shape, so a module can later move to vendor/ (or be symlinked) without changes.
Default tier: if the user does not specify, assume an app-local module (app/{name}/, namespace App\{Name}). Only use modules/{vendor}/{name}/ when the user signals the module is meant to be distributed/shared.
Choosing {vendor} (distributable modules only — app-local modules have no vendor): derive it from the host project's root directory name (a project in ~/Sites/acme → vendor acme). Do not read it from the project's composer.json name: a project scaffolded from marko/skeleton still carries marko/skeleton there, so the directory name is the reliable signal. Never use marko as the vendor for an application or third-party module — that vendor is reserved for packages inside the framework monorepo.
Step 2 — Write composer.json
Copy the appropriate template to <module-root>/composer.json and substitute all placeholders.
Required keys: name, type: marko-module, require.marko/core, psr-4 autoload, and extra.marko.module: true to flag it for the code indexer. Never set a version field — let Composer infer it from the branch.
Deterministic constraint selection rule
Resolve {{marko_constraint}} and choose the template using this ordered decision tree:
Inside the Marko monorepo (root directory contains
packages/core/) → useassets/composer.json.monorepo.tmpl. Set{{marko_constraint}}toself.version. This ensures all packages in the monorepo resolve against each other at the same version without manual pinning.Otherwise (host project / standalone module) → use
assets/composer.json.tmpl. Determine{{marko_constraint}}by reading the host project's rootcomposer.json:- Find the first
marko/*constraint that is already declared (e.g."marko/framework": "*"→ constraint is*;"marko/framework": "dev-develop"→ constraint isdev-develop;"marko/framework": "^1.2"→ constraint is^1.2). - If no
marko/*constraint is present in the hostcomposer.json, default to*.
Using
*(or matching the host's existing constraint) ensures the scaffolded module resolves against whatever version of Marko the host project is consuming — whether that is a tagged release, adev-developbranch alias, or a Composer path symlink. Never default to^1.0: that constraint is incompatible with any host consumingdev-developor*, which is the most common case during active development (e.g.marko/skeletonrequiresmarko/framework: *).- Find the first
Step 3 — Create the directory layout
{module-root}/
composer.json
src/ # PSR-4 source
tests/
Pest.php # Pest bootstrap
Unit/
Feature/
README.md # Slim pointer per docs/DOCS-STANDARDS.md
Copy assets/Pest.php.tmpl to tests/Pest.php. No placeholder substitution needed.
Step 4 — Decide whether you need module.php
module.php is optional. Only create it if the module needs explicit DI bindings (interface → concrete class wiring), singleton declarations, or boot callbacks for lifecycle hooks.
If the module is just classes that auto-resolve, omit module.php entirely. Do not create an empty manifest.
When you do need it, copy assets/module.php.tmpl to <module-root>/module.php and substitute {{Vendor}} and {{Name}} placeholders.
Step 5 — Add a slim README
Copy assets/README.md.tmpl to <module-root>/README.md and substitute {{vendor}} and {{name}} placeholders.
Per docs/DOCS-STANDARDS.md, package READMEs are slim pointers — title, install command, one quick example, and a link to the full docs page. Substantive documentation belongs in docs/src/content/docs/packages/{name}.md, not the README.
Step 6 — Verify the module is discovered
The module exists because you wrote it. The runtime discovers and autoloads it live on the next request — no composer dump-autoload, no index rebuild, no extra registration step. The MCP/LSP index self-refreshes (scoped to app/ and modules/), so list_modules and validate_module will reflect freshly created code automatically.
If you want to double-check structure, confirm:
composer.jsonhasextra.marko.module: true- The module's psr-4 namespace resolves correctly
You may call list_modules or validate_module as an optional cross-check, but these are not gates — the module is live as soon as the files are in place.
Verification
After writing files, expect LSP diagnostics from marko-lsp to surface in the same turn. Resolve all diagnostics before declaring the module complete — diagnostics are the verification gate, not optional warnings. Calling list_modules is an optional cross-check to confirm the MCP index reflects the new module; it is not required for the module to be active.
Conventions to enforce
- Every PHP file:
declare(strict_types=1); - Constructor property promotion always
- Type declarations on every parameter, return, and property
- No
finalclasses (blocks Preferences extensibility) - No magic methods — be explicit
- Use
readonlywhere immutability is appropriate, not as a blanket rule
What this skill does not cover
- Authoring plugins for the new module — see the
marko-create-pluginskill - Adding routes, observers, commands — see the relevant Marko docs pages
- Database migrations — see the
marko/databasepackage docs
See also
Source: marko-php/marko — distributed by TomeVault.