Skip to content

Service Container

Dingfelder\AdForm\Core\Container ist ein bewusst kleiner, PSR-11-inspirierter Container. Er kennt kein Auto-Wiring und keine Reflection: Jede Abhängigkeit wird in einer Factory-Closure explizit aufgelöst. Dadurch bleibt das Plugin ohne Composer-Runtime-Pakete installierbar.

Interner Aufbau

Der Container hält vier Maps:

PropertyInhalt
$bindingsid → Factory-Closure
$instancesid → bereits aufgelöstes Objekt
$sharedid → ob das Ergebnis gecacht wird
$resolvingid → aktuell in Auflösung (Zirkel-Erkennung)

Als id wird durchgängig ein Klassen- oder Interface-Name über ::class verwendet. Freie Strings sind möglich, aber im Kern nicht gebräuchlich.

API

singleton()

php
public function singleton( string $id, callable $factory ): void

Registriert eine geteilte Factory. Sie läuft beim ersten get(), danach wird immer dieselbe Instanz zurückgegeben.

php
$container->singleton(
	MergeTagEngine::class,
	static fn (): MergeTagEngine => new MergeTagEngine()
);

Die Factory bekommt den Container als einzigen Parameter übergeben und kann darüber weitere Services auflösen:

php
$container->singleton(
	ActionRunner::class,
	static fn ( Container $container ): ActionRunner => new ActionRunner(
		$container->get( ActionRegistry::class ),
		$container->get( LoggerInterface::class ),
		$container->get( ConditionEngineInterface::class )
	)
);

bind()

php
public function bind( string $id, callable $factory ): void

Registriert eine transiente Factory: Jeder get()-Aufruf erzeugt ein neues Objekt. Ein zuvor gecachtes Ergebnis für dieselbe id wird dabei verworfen.

php
$container->bind(
	ReportBuilder::class,
	static fn (): ReportBuilder => new ReportBuilder()
);

$a = $container->get( ReportBuilder::class );
$b = $container->get( ReportBuilder::class );
// $a !== $b

Für Services ist singleton() der Normalfall. bind() eignet sich für zustandsbehaftete Wegwerf-Objekte.

instance()

php
public function instance( string $id, mixed $instance ): void

Hinterlegt ein bereits existierendes Objekt. Es wird sofort als geteilt markiert, es läuft keine Factory. Genau so registriert sich das Plugin selbst:

php
private function __construct() {
	$this->container = new Container();
	$this->container->instance( self::class, $this );
	$this->container->instance( Container::class, $this->container );
}

Praktisch auch, um im Test eine Fake-Implementierung einzusetzen:

php
$container->instance( MailerInterface::class, new CapturingMailer() );

has()

php
public function has( string $id ): bool

true, wenn für die id eine Factory oder eine Instanz hinterlegt ist. has() löst nichts auf und hat keine Nebenwirkungen. Der richtige Weg, um optional vorhandene Services zu benutzen:

php
if ( $container->has( MyAddon\LicenseGuard::class ) ) {
	$container->get( MyAddon\LicenseGuard::class )->check();
}

get()

php
public function get( string $id ): mixed

Auflösungsreihenfolge:

  1. Existiert ein Eintrag in $instances, wird er direkt zurückgegeben.
  2. Fehlt eine Factory, wirft get() eine ContainerException::not_found().
  3. Ist die id bereits in Auflösung, wirft get() eine ContainerException::circular().
  4. Die Factory läuft; das resolving-Flag wird in einem finally-Block wieder entfernt, auch wenn die Factory eine Exception wirft.
  5. War das Binding geteilt, wird das Ergebnis in $instances gecacht.
php
try {
	$service = $container->get( FormService::class );
} catch ( ContainerException $exception ) {
	// Service ist nicht gebunden oder es liegt ein Zirkel vor.
}

Fehlerbehandlung

Dingfelder\AdForm\Core\ContainerException erweitert RuntimeException und implementiert das Marker-Interface Dingfelder\AdForm\Exception\PluginException. Damit lassen sich alle Plugin-Fehler mit einem catch abfangen:

php
use Dingfelder\AdForm\Exception\PluginException;

try {
	$container->get( SomeService::class );
} catch ( PluginException $exception ) {
	error_log( $exception->getMessage() );
}

Es gibt zwei Named Constructors:

AufrufNachricht
ContainerException::not_found( $id )Service "…" is not bound in the AD Form container.
ContainerException::circular( $id )Circular service dependency detected for "…".

Zirkuläre Abhängigkeiten vermeiden

Ein Zirkel entsteht, wenn zwei Factories sich gegenseitig auflösen:

php
// Führt bei get( A::class ) zu ContainerException::circular().
$container->singleton( A::class, fn ( Container $c ) => new A( $c->get( B::class ) ) );
$container->singleton( B::class, fn ( Container $c ) => new B( $c->get( A::class ) ) );

Lösung: den Container (oder eine Closure) statt der Instanz injizieren und erst zur Laufzeit auflösen – oder besser, die gemeinsame Verantwortung in einen dritten Service ziehen.

Zugriff auf den Container

Der Container ist über die Plugin-Instanz erreichbar:

php
use Dingfelder\AdForm\Plugin;

$container = Plugin::instance()->container();

Innerhalb von Hooks kommt er meist direkt als Parameter an – bei ad_form_register_services und ad_form_rest_routes.

Eigene Services binden

ad_form_register_services feuert, nachdem alle Kern-Provider ihre Bindings gesetzt haben und bevor irgendein Provider bootet. Das ist der richtige Zeitpunkt für Add-on-Bindings.

php
<?php
declare(strict_types=1);

namespace Acme\AdFormCrm;

use Dingfelder\AdForm\Core\Container;
use Dingfelder\AdForm\Logging\LoggerInterface;

add_action(
	'ad_form_register_services',
	static function ( Container $container ): void {
		$container->singleton(
			CrmClient::class,
			static fn (): CrmClient => new CrmClient(
				(string) get_option( 'acme_crm_api_key', '' )
			)
		);

		$container->singleton(
			CrmSync::class,
			static fn ( Container $container ): CrmSync => new CrmSync(
				$container->get( CrmClient::class ),
				$container->get( LoggerInterface::class )
			)
		);
	}
);

Später zugreifen:

php
use Dingfelder\AdForm\Forms\Form;
use Dingfelder\AdForm\Plugin;
use Dingfelder\AdForm\Submissions\Submission;

add_action(
	'ad_form_after_submission',
	static function ( Submission $submission, Form $form ): void {
		Plugin::instance()->container()->get( CrmSync::class )->push( $submission, $form );
	},
	10,
	2
);

Kern-Bindings ersetzen

Weil singleton() ein bestehendes Binding überschreibt, lässt sich jede Interface-Bindung austauschen – solange das noch vor dem ersten get() passiert. ad_form_register_services ist genau dieses Zeitfenster:

php
use Dingfelder\AdForm\Core\Container;
use Dingfelder\AdForm\Notifications\MailerInterface;

add_action(
	'ad_form_register_services',
	static function ( Container $container ): void {
		$container->singleton(
			MailerInterface::class,
			static fn (): MailerInterface => new Acme\PostmarkMailer()
		);
	},
	20
);

Nur Interfaces ersetzen

Sinnvoll austauschbar sind die Interface-Bindings: OptionsInterface, LoggerInterface, DatabaseInterface, SchemaInstallerInterface, MailerInterface, FormRepositoryInterface, ConditionEngineInterface, CalculationEngineInterface, FormRendererInterface. Konkrete Klassen zu ersetzen bricht die Typ-Hints der Kern-Services.

Was der Container nicht kann

  • Kein Auto-Wiring. get( Foo::class ) ohne vorheriges Binding wirft, auch wenn die Klasse existiert.
  • Keine kontextabhängigen Bindings, keine Tags, keine Scopes über den Request hinaus.
  • Kein Container-Reset. Ein aufgelöstes Singleton bleibt für den Request bestehen; nur bind() verwirft eine gecachte Instanz.

Digitale Lösungen. Persönlich. Zukunftssicher.