Modularity
Marko’s module system is the foundation of the framework. Every feature — routing, caching, authentication, your business logic — is a module. Understanding how the module system works — how modules are discovered, prioritized, and composed — unlocks the full power of Marko.
What Is a Module?
Section titled “What Is a Module?”The module system treats a module as any Composer package that Marko recognizes. At minimum, it needs a name, PSR-4 autoload mapping, and the extra.marko.module flag set to true:
{ "name": "app/blog", "autoload": { "psr-4": { "App\\Blog\\": "src/" } }, "extra": { "marko": { "module": true } }}The extra.marko.module flag tells Marko this package should be wired into the application. Without it, the package is treated as a plain Composer dependency — installed but not discovered by the module system.
No service provider registration, no boot methods, no kernel configuration. Marko discovers modules automatically.
Module Discovery
Section titled “Module Discovery”Marko scans three locations for modules, in priority order:
app/ → Your application (wins all conflicts)modules/ → Third-party (overrides vendor)vendor/ → Composer packages (base defaults)Discovery is automatic. Drop a module in app/ or modules/, and Marko picks it up on the next request.
No rebuild step is needed for the application to see a new module --- discovery runs live on every request. The code index used by AI tooling (the MCP or LSP server) is equally hands-free for app/ and modules/: the running server re-checks staleness on every read, so a freshly created module appears in tooling on the very next tool call with no indexer:rebuild required. The only case still needing an explicit marko indexer:rebuild is a vendor change — a new or removed package installed via composer require or composer update.
The module.php File
Section titled “The module.php File”Each module can optionally include a module.php at its root. This file declares the module’s dependency injection wiring:
<?php
declare(strict_types=1);
use App\Blog\Repository\PostRepository;use App\Blog\Repository\PostRepositoryInterface;
return [ // Bind interfaces to implementations 'bindings' => [ PostRepositoryInterface::class => PostRepository::class, ],
// Register shared instances (created once, reused) 'singletons' => [ PostRepository::class, ],];What Can module.php Declare?
Section titled “What Can module.php Declare?”| Key | Purpose |
|---|---|
bindings | Map interfaces to concrete implementations |
singletons | Classes that should only be instantiated once |
Interface/Implementation Split
Section titled “Interface/Implementation Split”Marko packages follow a deliberate pattern: interfaces and implementations are separate packages.
marko/cache → CacheInterface (the contract)marko/cache-file → FileCacheDriver (file-based)marko/cache-redis → RedisCacheDriver (Redis-based)Your application code depends on the interface package:
use Marko\Cache\Contracts\CacheInterface;
readonly class ProductService{ public function __construct( private CacheInterface $cache, ) {}}The implementation is wired in module.php. To switch from file cache to Redis, change one binding — zero application code changes.
Unsupported Features
Section titled “Unsupported Features”A driver sometimes cannot do something its interface declares (the SQLite driver has no containment operator for object needles in whereJsonContains()). Marko handles this one way:
- The method stays on the interface when most drivers can do it.
- The driver throws an exception implementing
Marko\Core\Exceptions\UnsupportedFeatureExceptionInterface. It exposesgetFeature(),getDriver(),getContext()andgetSuggestion(). Packages whose callers catch their own exception type ship a subclass (for exampleUnsupportedMigrationFeatureException), so existingcatchblocks keep working. - A driver may return a no-op only when the method’s promise still holds, and documents it. The sync queue is the one named carve-out: it runs delayed jobs immediately because it exists to execute inline in development.
- A separate capability check, like
ConnectionInterface::supportsReturning(), exists only when callers must choose a path before calling.
Catch the interface to handle any driver’s gap:
use Marko\Core\Exceptions\UnsupportedFeatureExceptionInterface;
try { $queue->later(60, $job);} catch (UnsupportedFeatureExceptionInterface $exception) { $logger->warning($exception->getFeature() . ' unavailable on ' . $exception->getDriver());}Module Composition
Section titled “Module Composition”Modules can build on each other. An acme/blog module might depend on marko/routing, marko/database, and marko/view — but you never wire that manually. Composer handles the dependency graph, and Marko handles the module discovery.
{ "name": "acme/blog", "require": { "marko/core": "^1.0", "marko/routing": "^1.0", "marko/database": "^1.0" }, "extra": { "marko": { "module": true } }}Overriding Vendor Behavior
Section titled “Overriding Vendor Behavior”The priority system means your app/ modules always win. But Marko provides structured ways to customize vendor behavior:
- Preferences — Swap an interface’s implementation entirely
- Plugins — Modify a method’s input or output without replacing the class
- Events & Observers — React to things happening in the system
Next Steps
Section titled “Next Steps”- Dependency Injection — how the container resolves modules
- Preferences — swapping implementations
- Plugins — intercepting method calls
- Core Package — API reference for module discovery and the DI container
- Routing Guide — define and handle HTTP routes