marko/core
The foundation of Marko — provides dependency injection, modules, plugins, events, and preferences so you can extend any class without modifying its source. Core gives you the extensibility primitives: replace any class with #[Preference], modify any method with #[Before]/#[After] plugins, react to events with #[Observer]. Everything is a module, and modules are discovered automatically from vendor/, modules/, and app/.
Installation
Section titled “Installation”composer require marko/coreNote: Most applications install this via a metapackage or implementation package.
Replacing Classes with Preferences
Section titled “Replacing Classes with Preferences”Override any class globally without touching its source:
use Marko\Core\Attributes\Preference;
#[Preference(replaces: OriginalService::class)]class MyService extends OriginalService{ public function doSomething(): string { // Your implementation return 'custom behavior'; }}Anywhere OriginalService is injected, MyService is provided instead.
Modifying Methods with Plugins
Section titled “Modifying Methods with Plugins”Intercept method calls without replacing the whole class:
use Marko\Core\Attributes\Plugin;use Marko\Core\Attributes\Before;use Marko\Core\Attributes\After;
#[Plugin(target: PaymentService::class)]class PaymentValidationPlugin{ #[Before] public function charge( float $amount, ): null|array { // Modify input — return an array to replace the arguments return [$amount * 1.1]; // Add 10% fee }}
#[Plugin(target: PaymentService::class)]class PaymentAuditPlugin{ #[After] public function charge( Receipt $result, ): Receipt { // Modify output return $result->withTax(); }}Reacting to Events
Section titled “Reacting to Events”Decouple “something happened” from “react to it”:
use Marko\Core\Attributes\Observer;use Marko\Core\Event\Event;
#[Observer(event: UserCreatedEvent::class)]class SendWelcomeEmail{ public function handle( UserCreatedEvent $event, ): void { $user = $event->user; // Send email... }}Dispatch events from anywhere:
$this->eventDispatcher->dispatch(new UserCreatedEvent(user: $user));Creating Modules
Section titled “Creating Modules”Create a directory in app/ with a composer.json:
app/ mymodule/ composer.json # Required: name, autoload module.php # Optional: enabled, bindings src/ MyService.phpModules are discovered automatically. Use module.php for bindings:
return [ 'enabled' => true, 'bindings' => [ PaymentInterface::class => StripePayment::class, ],];The boot callback runs after all module bindings are registered. Parameters are auto-injected from the container — type-hint any registered dependency:
return [ 'bindings' => [ PaymentInterface::class => StripePayment::class, ], 'boot' => function (ErrorHandlerInterface $handler): void { $handler->register(); },];Environment-Specific Bindings
Section titled “Environment-Specific Bindings”When different environments need different implementations (e.g., a mock service in development vs the real one in production), use the boot callback to conditionally override bindings:
return [ 'bindings' => [ // Default binding — used in all environments PaymentGatewayInterface::class => StripePaymentGateway::class, ], 'boot' => function (Container $container): void { if (($_ENV['APP_ENV'] ?? 'production') === 'development') { $container->bind( PaymentGatewayInterface::class, MockPaymentGateway::class, ); } },];Since boot callbacks run after all static bindings are registered, $container->bind() in a boot callback overrides the static binding from the same module. The override is explicit and visible in the module’s own module.php.
Use boot callbacks when you need a completely different implementation class per environment (mock vs real).
Use config instead when the difference is just values (API URLs, credentials, feature flags). Keep the same class everywhere and let config drive the behavior:
return [ 'gateway_url' => $_ENV['PAYMENT_GATEWAY_URL'] ?? 'https://sandbox.stripe.com', 'dry_run' => (bool) ($_ENV['PAYMENT_DRY_RUN'] ?? true),];Discovery Cache
Section titled “Discovery Cache”On every boot, Marko scans all module PHP files to discover #[Preference], #[Plugin], #[Observer], and #[Command] attributes. In production this scan can be eliminated by compiling its results into a single PHP file --- the discovery cache.
Compiling the cache
Section titled “Compiling the cache”marko discovery:cacheScans all modules, writes the compiled cache, and reports per-section counts:
Discovery cache compiled successfully.Cache path: /var/www/html/storage/cache/discovery.phppreferences: 4plugins: 12observers: 7commands: 9Clearing the cache
Section titled “Clearing the cache”marko discovery:clearDeletes the compiled cache file. Idempotent --- safe to run when no cache exists.
How boot uses the cache
Section titled “How boot uses the cache”At boot, Application::initialize() reads three environment variables directly (not via marko/config, so the gate works before any config package is loaded):
| Variable | Default | Description |
|---|---|---|
APP_ENV | production | Application environment. Set to development to disable the cache. |
DISCOVERY_CACHE_ENABLED | true | Set to 0, false, no, off, or empty to disable. |
DISCOVERY_CACHE_PATH | storage/cache/discovery.php | Path to the cache file. Relative paths resolve from the project root; absolute paths are used as-is. |
The cache is used when all three conditions are true:
DISCOVERY_CACHE_ENABLEDis truthyAPP_ENVis notdevelopment- The cache file exists at
DISCOVERY_CACHE_PATH
If the cache file is missing, boot falls back to a normal full rescan --- no error.
If the cache file is corrupt, malformed, or version-mismatched, boot throws DiscoveryCacheException immediately. There is no silent fallback. Run marko discovery:clear then marko discovery:cache to rebuild.
In development environment the cache is always bypassed, so adding or editing a #[Plugin], #[Observer], #[Preference], or #[Command] takes effect on the next request without any manual step.
Configuration via marko/config
Section titled “Configuration via marko/config”When marko/config is installed, the same keys are available as a config file:
return [ 'enabled' => true, // mirrors DISCOVERY_CACHE_ENABLED 'environment' => 'production', // mirrors APP_ENV 'cache_path' => 'storage/cache/discovery.php', // mirrors DISCOVERY_CACHE_PATH];The core-owned config/discovery.php is shipped with marko/core and populates these values from $_ENV automatically. The boot gate reads DiscoveryEnvironment directly and does not depend on marko/config.
Deploy requirement
Section titled “Deploy requirement”There is no file-modification-time invalidation. Whenever code changes (new modules, updated attributes), regenerate the cache as part of your deploy:
composer install --no-dev --optimize-autoloadermarko discovery:cacheServing stale discovery results in missing preferences, plugins, observers, or commands until the cache is recompiled.
Throwing Rich Exceptions
Section titled “Throwing Rich Exceptions”Include context and fix suggestions:
use Marko\Core\Exceptions\MarkoException;
throw new MarkoException( message: 'Payment failed', context: 'Processing order #12345', suggestion: 'Check that the API key is configured in .env',);API Reference
Section titled “API Reference”Attributes
Section titled “Attributes”#[Preference(replaces: ClassName::class)] // Replace a class globally#[Plugin(target: ClassName::class)] // Mark class as plugin#[Before] // Run before target method#[After] // Run after target method#[Observer(event: EventClass::class)] // React to events (synchronous)#[Observer(event: EventClass::class, async: true)] // Push to queue and handle in background#[Command(name: 'cmd:name', description: '')] // Register CLI commandContainer
Section titled “Container”interface ContainerInterface extends PsrContainerInterface{ public function get(string $id): mixed; public function has(string $id): bool; public function singleton(string $id): void; public function instance(string $id, object $instance): void; public function call(Closure $callable): mixed;}Events
Section titled “Events”interface EventDispatcherInterface{ public function dispatch(Event $event): void;}MarkoException
Section titled “MarkoException”class MarkoException extends Exception{ public function __construct( string $message, string $context = '', string $suggestion = '', );
public function getContext(): string; public function getSuggestion(): string;}CircularDependencyException
Section titled “CircularDependencyException”use Marko\Core\Exceptions\CircularDependencyException;Thrown by the container when a mutual constructor dependency cycle is detected (e.g. class A requires class B which requires class A). Rather than exhausting the call stack, the container detects the cycle and throws immediately with a human-readable chain (A -> B -> A) and a suggestion to remove the circular reference.
Implements Psr\Container\ContainerExceptionInterface.
DiscoveryCacheException
Section titled “DiscoveryCacheException”use Marko\Core\Exceptions\DiscoveryCacheException;Thrown by Application::initialize() when the discovery cache file is corrupt, structurally invalid, or carries a version number that does not match the running core version. There is no silent fallback --- a bad cache is a loud error.
Named constructors:
| Method | When thrown |
|---|---|
DiscoveryCacheException::unreadable($path) | Cache file exists but cannot be read |
DiscoveryCacheException::malformed($path, $reason) | Cache file structure is invalid or missing required keys |
DiscoveryCacheException::versionMismatch($path, $found, $expected) | Cache was compiled by a different core version |
DiscoveryCacheException::notWritable($path) | Cache directory or file is not writable (thrown by discovery:cache) |
Fix in all cases: marko discovery:clear && marko discovery:cache.