Skip to content

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/.

Terminal window
composer require marko/core

Note: Most applications install this via a metapackage or implementation package.

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.

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();
}
}

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));

Create a directory in app/ with a composer.json:

app/
mymodule/
composer.json # Required: name, autoload
module.php # Optional: enabled, bindings
src/
MyService.php

Modules are discovered automatically. Use module.php for bindings:

module.php
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:

module.php
return [
'bindings' => [
PaymentInterface::class => StripePayment::class,
],
'boot' => function (ErrorHandlerInterface $handler): void {
$handler->register();
},
];

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:

module.php
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:

config/payments.php
return [
'gateway_url' => $_ENV['PAYMENT_GATEWAY_URL'] ?? 'https://sandbox.stripe.com',
'dry_run' => (bool) ($_ENV['PAYMENT_DRY_RUN'] ?? true),
];

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.

Terminal window
marko discovery:cache

Scans all modules, writes the compiled cache, and reports per-section counts:

Discovery cache compiled successfully.
Cache path: /var/www/html/storage/cache/discovery.php
preferences: 4
plugins: 12
observers: 7
commands: 9
Terminal window
marko discovery:clear

Deletes the compiled cache file. Idempotent --- safe to run when no cache exists.

At boot, Application::initialize() reads three environment variables directly (not via marko/config, so the gate works before any config package is loaded):

VariableDefaultDescription
APP_ENVproductionApplication environment. Set to development to disable the cache.
DISCOVERY_CACHE_ENABLEDtrueSet to 0, false, no, off, or empty to disable.
DISCOVERY_CACHE_PATHstorage/cache/discovery.phpPath 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:

  1. DISCOVERY_CACHE_ENABLED is truthy
  2. APP_ENV is not development
  3. 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.

When marko/config is installed, the same keys are available as a config file:

config/discovery.php
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.

There is no file-modification-time invalidation. Whenever code changes (new modules, updated attributes), regenerate the cache as part of your deploy:

Terminal window
composer install --no-dev --optimize-autoloader
marko discovery:cache

Serving stale discovery results in missing preferences, plugins, observers, or commands until the cache is recompiled.

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',
);
#[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 command
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;
}
interface EventDispatcherInterface
{
public function dispatch(Event $event): void;
}
class MarkoException extends Exception
{
public function __construct(
string $message,
string $context = '',
string $suggestion = '',
);
public function getContext(): string;
public function getSuggestion(): string;
}
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.

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:

MethodWhen 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.