Skip to content

Eigene Submit-Actions ​

Submit-Actions laufen, nachdem eine Übermittlung erfolgreich gespeichert wurde. Der Kern liefert genau einen Typ mit: email. Eigene Typen – Webhook, CRM-Sync, PDF-Erzeugung – implementieren ActionInterface und melden sich über einen Filter an.

Wann Actions laufen ​

ActionsServiceProvider::boot() hängt den ActionRunner an ad_form_after_submission:

php
add_action(
	Hooks::AFTER_SUBMISSION,
	static function ( mixed $submission, mixed $form ) use ( $runner ): void {
		if ( ! $submission instanceof Submission || ! $form instanceof Form ) {
			return;
		}

		$runner->run( $form, $submission );
	},
	10,
	2
);

Daraus folgen drei Eigenschaften, auf die man sich verlassen kann:

  • Die Übermittlung ist bereits persistiert. $submission->id() und $submission->uuid() sind gesetzt.
  • Honeypot-Treffer erreichen den Hook nie – Spam löst also keine Actions aus.
  • Fehler blockieren nichts. Der Runner fängt jedes Throwable, protokolliert es und macht mit der nächsten Action weiter. Der Nutzer sieht immer seine Bestätigung.

Ablauf im ActionRunner ​

Für jeden Eintrag aus definition.actions gilt:

  1. Kein Array oder enabled explizit falsy → überspringen.
  2. Bedingungsgruppe auswerten. Passt sie nicht, wird die Action übersprungen und im Kanal actions als Debug-Meldung protokolliert – kein Fehler.
  3. type per sanitize_key() normalisieren; leerer Typ → überspringen.
  4. Handler aus der ActionRegistry holen. Unbekannter Typ → Warnung im Kanal actions, überspringen. Die Definition selbst bleibt erhalten, damit Add-on-Einstellungen nicht verloren gehen, wenn das Add-on kurzzeitig deaktiviert ist.
  5. Entitlement-Prüfung: Sind nicht alle von required_entitlements() genannten Feature-Schlüssel freigeschaltet, wird die Action mit einer Warnung übersprungen. Der Eintrag selbst ist zu diesem Zeitpunkt bereits gespeichert – die Daten des Besuchers bleiben erhalten, nur die Ausführung unterbleibt. Ohne injizierte RuntimePolicy greift die Prüfung nicht.
  6. ad_form_before_action feuern.
  7. run( $settings, $context ) aufrufen, umschlossen von try/catch. Eine Exception wird zu ActionResult::fail( 'exception', … ) und landet als Fehler im Log.
  8. ad_form_after_action feuern.
  9. Bei Misserfolg (außer exception, das ist bereits protokolliert) eine Warnung schreiben – Kanal mail beim Typ email, sonst actions.

Das Interface ​

Dingfelder\AdForm\Actions\ActionInterface verlangt acht Methoden:

php
public function type(): string;
public function label(): string;
public function description(): string;

/** @return array<string, mixed> */
public function default_settings(): array;

/** @return list<array<string, mixed>> */
public function inspector_fields(): array;

/**
 * @param array<string, mixed> $settings Raw settings.
 * @return array<string, mixed>
 */
public function sanitize_settings( array $settings ): array;

/** @return array<string, mixed> */
public function to_rest_array(): array;

/**
 * @param array<string, mixed> $settings Sanitized action settings.
 */
public function run( array $settings, ActionContext $context ): ActionResultInterface;
MethodeBedeutung
type()Stabiler Typ-Schlüssel und zugleich Registry-Key. Muss sanitize_key()-sicher sein.
label()Anzeigename im Builder.
description()Kurzbeschreibung im Builder und im REST-Katalog.
default_settings()Wird beim Hinzufügen der Action in die Definition kopiert.
inspector_fields()Steuerelemente im Builder; path beginnt bei Action-Settings mit settings..
sanitize_settings()Serverseitiges Sanitizing beim Speichern des Formulars.
to_rest_array()Katalog-Nutzlast für GET /wp-json/ad-form/v1/actions.
run()Ausführung. Muss ein ActionResultInterface zurückgeben.

AbstractAction ​

Dingfelder\AdForm\Actions\AbstractAction nimmt drei Methoden ab: description() liefert einen leeren String, required_entitlements() eine leere Liste, und to_rest_array() baut daraus die Katalog-Nutzlast:

php
abstract class AbstractAction implements ActionInterface {

	public function description(): string {
		return '';
	}

	/**
	 * @return list<string>
	 */
	public function required_entitlements(): array {
		return array();
	}

	/**
	 * @return array<string, mixed>
	 */
	public function to_rest_array(): array {
		return array(
			'type'                  => $this->type(),
			'label'                 => $this->label(),
			'description'           => $this->description(),
			'default_settings'      => $this->default_settings(),
			'inspector'             => $this->inspector_fields(),
			'required_entitlements' => $this->required_entitlements(),
		);
	}
}

required_entitlements() nennt die Feature-Schlüssel, die freigeschaltet sein müssen, bevor die Action gespeichert oder ausgeführt werden darf. Sie steht bewusst auf der abstrakten Klasse und nicht auf ActionInterface, damit bestehende Interface-Implementierungen unverändert weiterlaufen. Der Standardwert – eine leere Liste – bedeutet: keine Einschränkung.

ActionContext ​

Der Kontext ist ein unveränderliches Wertobjekt mit drei Zugriffen:

php
final class ActionContext {

	public function __construct(
		private readonly Form $form,
		private readonly Submission $submission
	) {}

	public function form(): Form;
	public function submission(): Submission;

	/** @return array<string, mixed> */
	public function values(): array;   // Kurzform für $submission->payload()
}

Nützliche Zugriffe innerhalb einer Action:

php
$context->form()->id();              // int
$context->form()->title();           // string
$context->form()->slug();            // string
$context->form()->definition();      // array<string, mixed>

$context->submission()->id();         // int
$context->submission()->uuid();       // string
$context->submission()->created_at();  // string, UTC
$context->submission()->source();      // 'shortcode', 'gutenberg', 'elementor', …

$context->values()['email'] ?? '';    // Feldwerte, indiziert nach Field-Key

ActionResult ​

Dingfelder\AdForm\Actions\ActionResult implementiert ActionResultInterface und wird über zwei statische Named Constructors erzeugt:

php
ActionResult::ok( string $message = '' ): self;
ActionResult::fail( string $code, string $message ): self;

Der Code wird bei fail() durch sanitize_key() normalisiert. Auslesen lässt sich das Ergebnis über success(): bool, code(): string und message(): string.

Konventionen für den Code

Der Code ist ein maschinenlesbarer Grund, die Message ein Text fürs Log. EmailAction benutzt zum Beispiel no_recipients und mail_failed. Der Code exception ist reserviert: Er wird vom Runner selbst vergeben, wenn run() eine Exception wirft.

Fehlschläge sind kein Abbruch. Der Runner protokolliert und läuft weiter – eine Action darf also scheitern, ohne dass die Übermittlung oder eine andere Action darunter leidet.

Registrierung ​

Actions werden ausschließlich über den Filter ad_form_actions registriert. Er läuft in ActionRegistry::boot() und bekommt die Map type => ActionInterface plus die Registry:

php
use Dingfelder\AdForm\Actions\ActionRegistry;

add_filter(
	'ad_form_actions',
	static function ( array $actions, ActionRegistry $registry ): array {
		$actions[] = new Acme\AdFormWebhook\WebhookAction();

		return $actions;
	},
	10,
	2
);

Die Registry indiziert die zurückgegebene Liste anschließend selbst nach type(); ein Anhängen ohne Schlüssel ist deshalb ausreichend. Alles, was kein ActionInterface ist, wird verworfen.

Wenn die Action Container-Services braucht, wird sie dort aufgelöst:

php
use Dingfelder\AdForm\Logging\LoggerInterface;
use Dingfelder\AdForm\Plugin;

add_filter(
	'ad_form_actions',
	static function ( array $actions ): array {
		$actions[] = new Acme\AdFormWebhook\WebhookAction(
			Plugin::instance()->container()->get( LoggerInterface::class )
		);

		return $actions;
	}
);

Vollständiges Beispiel: Webhook-Action ​

php
<?php
/**
 * Webhook submit action for AD Form.
 *
 * @package Acme\AdFormWebhook
 */

declare(strict_types=1);

namespace Acme\AdFormWebhook;

use Dingfelder\AdForm\Actions\AbstractAction;
use Dingfelder\AdForm\Actions\ActionContext;
use Dingfelder\AdForm\Actions\ActionResult;
use Dingfelder\AdForm\Actions\ActionResultInterface;
use Dingfelder\AdForm\Security\Sanitizer;

final class WebhookAction extends AbstractAction {

	public const TYPE = 'webhook';

	public function type(): string {
		return self::TYPE;
	}

	public function label(): string {
		return __( 'Webhook', 'acme-ad-form-webhook' );
	}

	public function description(): string {
		return __( 'POST the submitted values as JSON to an external endpoint.', 'acme-ad-form-webhook' );
	}

	/**
	 * @return array<string, mixed>
	 */
	public function default_settings(): array {
		return array(
			'url'     => '',
			'secret'  => '',
			'timeout' => 5,
		);
	}

	/**
	 * @return list<array<string, mixed>>
	 */
	public function inspector_fields(): array {
		return array(
			array(
				'name'  => 'url',
				'path'  => 'settings.url',
				'type'  => 'text',
				'label' => __( 'Endpoint URL', 'acme-ad-form-webhook' ),
				'help'  => __( 'HTTPS only. The payload is sent as JSON.', 'acme-ad-form-webhook' ),
			),
			array(
				'name'  => 'secret',
				'path'  => 'settings.secret',
				'type'  => 'text',
				'label' => __( 'Shared secret', 'acme-ad-form-webhook' ),
				'help'  => __( 'Sent as X-Acme-Signature (HMAC-SHA256 over the request body).', 'acme-ad-form-webhook' ),
			),
			array(
				'name'  => 'timeout',
				'path'  => 'settings.timeout',
				'type'  => 'text',
				'label' => __( 'Timeout in seconds', 'acme-ad-form-webhook' ),
			),
		);
	}

	/**
	 * @param array<string, mixed> $settings Raw settings.
	 * @return array<string, mixed>
	 */
	public function sanitize_settings( array $settings ): array {
		return array(
			'url'     => Sanitizer::url( $settings['url'] ?? '' ),
			'secret'  => Sanitizer::text( $settings['secret'] ?? '' ),
			'timeout' => max( 1, min( 30, Sanitizer::int( $settings['timeout'] ?? 5 ) ) ),
		);
	}

	/**
	 * @param array<string, mixed> $settings Sanitized action settings.
	 */
	public function run( array $settings, ActionContext $context ): ActionResultInterface {
		$settings = $this->sanitize_settings( $settings );
		$url      = (string) $settings['url'];

		if ( '' === $url || ! str_starts_with( $url, 'https://' ) ) {
			return ActionResult::fail(
				'invalid_url',
				__( 'Webhook skipped: no valid HTTPS endpoint configured.', 'acme-ad-form-webhook' )
			);
		}

		$body = wp_json_encode(
			array(
				'form_id'    => $context->form()->id(),
				'form_title' => $context->form()->title(),
				'entry_id'   => $context->submission()->id(),
				'entry_uuid' => $context->submission()->uuid(),
				'created_at' => $context->submission()->created_at(),
				'values'     => $context->values(),
			)
		);

		if ( ! is_string( $body ) ) {
			return ActionResult::fail(
				'encode_failed',
				__( 'Webhook skipped: the payload could not be encoded.', 'acme-ad-form-webhook' )
			);
		}

		$headers = array( 'Content-Type' => 'application/json; charset=utf-8' );
		$secret  = (string) $settings['secret'];

		if ( '' !== $secret ) {
			$headers['X-Acme-Signature'] = hash_hmac( 'sha256', $body, $secret );
		}

		$response = wp_remote_post(
			$url,
			array(
				'headers'  => $headers,
				'body'     => $body,
				'timeout'  => (int) $settings['timeout'],
				'blocking' => true,
			)
		);

		if ( is_wp_error( $response ) ) {
			return ActionResult::fail( 'request_failed', $response->get_error_message() );
		}

		$code = wp_remote_retrieve_response_code( $response );

		if ( $code < 200 || $code >= 300 ) {
			return ActionResult::fail(
				'http_' . $code,
				sprintf(
					/* translators: %d: HTTP status code */
					__( 'Webhook endpoint answered with HTTP %d.', 'acme-ad-form-webhook' ),
					$code
				)
			);
		}

		return ActionResult::ok( __( 'Webhook delivered.', 'acme-ad-form-webhook' ) );
	}
}

Registrierung:

php
<?php
declare(strict_types=1);

add_filter(
	'ad_form_actions',
	static function ( array $actions ): array {
		$actions[] = new Acme\AdFormWebhook\WebhookAction();

		return $actions;
	}
);

Merge-Tags in eigenen Actions ​

Wer Textbausteine mit Formularwerten befüllen möchte, nutzt den MergeTagEngine aus dem Container statt eigener Platzhalterlogik:

php
use Dingfelder\AdForm\Actions\MergeTagEngine;

$text = $this->merge_tags->expand( $template, $context, MergeTagEngine::MODE_PLAIN );
$html = $this->merge_tags->expand( $template, $context, MergeTagEngine::MODE_HTML );

Verfügbare Tags liefert MergeTagEngine::catalogue(): {field:key}, {form:title}, {form:id}, {form:slug}, {entry:id}, {entry:uuid}, {entry:created_at}, {admin:email}, {user:email}, {user:display_name} und {all_fields}. Eigene Tags kommen über den Filter ad_form_merge_tags dazu:

php
use Dingfelder\AdForm\Actions\ActionContext;

add_filter(
	'ad_form_merge_tags',
	static function ( array $tags, ActionContext $context, string $mode ): array {
		$tags['{site:name}'] = get_bloginfo( 'name' );

		return $tags;
	},
	10,
	3
);

Bedingte Ausführung ​

Actions unterstützen dieselbe Bedingungsgruppe wie Felder (enabled, effect, logic, rules). Der Runner wertet sie vor dem Aufruf aus; effect wird dabei ignoriert: Passt die aktivierte Gruppe nicht, wird die Action übersprungen. Das ist reine Konfiguration und muss in der Action nicht implementiert werden. Details unter Bedingte Logik.

Verwandte Referenzen ​

  • Submit-Actions – Aufbau von definition.actions und der Email-Action
  • Formular-Schema – wo Actions in der Definition gespeichert werden
  • Hook-Referenz – ad_form_actions, ad_form_before_action, ad_form_after_action, ad_form_merge_tags

Digitale Lösungen. Persönlich. Zukunftssicher.