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-Referenzad_form_actions, ad_form_before_action, ad_form_after_action, ad_form_merge_tags

Digitale Lösungen. Persönlich. Zukunftssicher.