Skip to content

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.

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:

composer.json
{
"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.

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.

Each module can optionally include a module.php at its root. This file declares the module’s dependency injection wiring:

module.php
<?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,
],
];
KeyPurpose
bindingsMap interfaces to concrete implementations
singletonsClasses that should only be instantiated once

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.

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 exposes getFeature(), getDriver(), getContext() and getSuggestion(). Packages whose callers catch their own exception type ship a subclass (for example UnsupportedMigrationFeatureException), so existing catch blocks 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());
}

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.

composer.json
{
"name": "acme/blog",
"require": {
"marko/core": "^1.0",
"marko/routing": "^1.0",
"marko/database": "^1.0"
},
"extra": {
"marko": {
"module": true
}
}
}

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