---
name: drupal-webform
description: 'Build and extend Drupal Webform. Use when: creating or configuring webforms, writing custom Webform handlers (PHP), creating custom Webform elements/components, processing submissions programmatically, adding validation to webform elements, theming webform templates, working with webform confirmation pages, or managing webform submission data.'
argument-hint: 'Webform machine name (or "new") and what needs to be built or customised (handler, element, template, validation, etc.)'
---

# Drupal Webform — Square360 Conventions

> **Also load:** [drupal-11-coding-standards](../drupal-11-coding-standards/SKILL.md) for PHP patterns and PHPCS rules.
> **Also load:** [drupal-module-scaffold](../drupal-module-scaffold/SKILL.md) when creating a new module for custom handlers or elements.

## When to Use
- Configuring webform fields, conditions, and confirmation pages via the UI (document intent in config)
- Writing a custom **WebformHandler** to process submissions (send to CRM, trigger an action, etc.)
- Writing a custom **WebformElement** plugin for a non-standard input type
- Altering webform behaviour in a `.module` file via hooks
- Theming webform output with Twig templates
- Accessing or deleting stored submissions programmatically

---

## Webform Handler (Custom Submission Processing)

Handlers are `@WebformHandler` plugins. They respond to submission events: `presave`, `postsave`, `predelete`, etc.

**File:** `web/modules/custom/mymodule/src/Plugin/WebformHandler/MyCrmHandler.php`

```php
<?php

declare(strict_types=1);

namespace Drupal\mymodule\Plugin\WebformHandler;

use Drupal\Core\Form\FormStateInterface;
use Drupal\webform\Plugin\WebformHandlerBase;
use Drupal\webform\WebformSubmissionInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Sends form submission data to the CRM.
 *
 * @WebformHandler(
 *   id = "my_crm_handler",
 *   label = @Translation("My CRM Handler"),
 *   category = @Translation("Square360"),
 *   description = @Translation("Posts submission data to the CRM API."),
 *   cardinality = \Drupal\webform\Plugin\WebformHandlerInterface::CARDINALITY_SINGLE,
 *   results = \Drupal\webform\Plugin\WebformHandlerInterface::RESULTS_PROCESSED,
 *   submission = \Drupal\webform\Plugin\WebformHandlerInterface::SUBMISSION_REQUIRED,
 * )
 */
final class MyCrmHandler extends WebformHandlerBase {

  /**
   * Constructs a MyCrmHandler object.
   */
  public function __construct(
    array $configuration,
    string $plugin_id,
    mixed $plugin_definition,
    private readonly \GuzzleHttp\ClientInterface $httpClient,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('http_client'),
    );
  }

  /**
   * {@inheritdoc}
   */
  public function postSave(WebformSubmissionInterface $webform_submission, bool $update = TRUE): void {
    if ($update) {
      return; // Only act on initial save.
    }

    $data = $webform_submission->getData();

    // Post to CRM.
    try {
      $this->httpClient->post('https://crm.example.com/api/leads', [
        'json' => [
          'first_name' => $data['first_name'] ?? '',
          'email'      => $data['email'] ?? '',
        ],
      ]);
    }
    catch (\Exception $e) {
      $this->getLogger()->error('CRM submission failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
    $form = parent::buildConfigurationForm($form, $form_state);

    $form['endpoint_url'] = [
      '#type'          => 'url',
      '#title'         => $this->t('Endpoint URL'),
      '#default_value' => $this->configuration['endpoint_url'] ?? '',
      '#required'      => TRUE,
    ];

    return $form;
  }

}
```

---

## Handler Event Methods

Override only the events you need:

| Method | When it fires |
|--------|--------------|
| `preSave()` | Before the submission record is saved |
| `postSave()` | After the submission record is saved |
| `preDelete()` | Before a submission is deleted |
| `postDelete()` | After a submission is deleted |
| `confirmForm()` | After the confirmation page is shown |
| `overrideSettings()` | Modify webform settings per-request |

---

## Accessing Submission Data

```php
// Get all submitted values as an array.
$data = $webform_submission->getData();
$email = $data['email'] ?? '';

// Get a single element value.
$email = $webform_submission->getElementData('email');

// Get the webform this submission belongs to.
$webform = $webform_submission->getWebform();
$webform_id = $webform->id();
```

---

## Hook-Based Webform Alterations

In `mymodule.module`:

```php
/**
 * Implements hook_webform_submission_presave().
 */
function mymodule_webform_submission_presave(WebformSubmissionInterface $webform_submission): void {
  // Only act on a specific webform.
  if ($webform_submission->getWebform()->id() !== 'contact_form') {
    return;
  }

  // Normalise phone number before saving.
  $phone = $webform_submission->getElementData('phone');
  if ($phone) {
    $normalised = preg_replace('/[^0-9+]/', '', $phone);
    $webform_submission->setElementData('phone', $normalised);
  }
}

/**
 * Implements hook_webform_element_alter().
 */
function mymodule_webform_element_alter(array &$element, FormStateInterface $form_state, array $context): void {
  // Make a specific field required conditionally.
  if (isset($element['#webform_key']) && $element['#webform_key'] === 'company') {
    $element['#required'] = TRUE;
  }
}
```

---

## Theming Webforms

### Template suggestions
```
webform--{webform-id}.html.twig
webform.html.twig
```

**Location:** `web/themes/custom/{theme}/templates/webform/`

### Element-level templates
```
webform-element--{element-type}--{webform-id}--{element-key}.html.twig
webform-element--{element-type}.html.twig
```

### Confirmation page
```
webform-confirmation--{webform-id}.html.twig
webform-confirmation.html.twig
```

---

## Querying Submissions Programmatically

```php
/** @var \Drupal\webform\WebformSubmissionStorageInterface $storage */
$storage = \Drupal::entityTypeManager()->getStorage('webform_submission');

// Load by webform ID.
$submissions = $storage->loadByProperties([
  'webform_id' => 'contact_form',
]);

// Query with conditions.
$query = $storage->getQuery()
  ->condition('webform_id', 'contact_form')
  ->condition('created', strtotime('-7 days'), '>')
  ->accessCheck(TRUE);
$ids = $query->execute();
$submissions = $storage->loadMultiple($ids);
```

> **Inject `entity_type.manager`** in classes — do not use `\Drupal::entityTypeManager()` directly.

---

## Square360 Conventions

> **TODO: Team member to complete this section.**

Document here:
- Which webforms exist on this project and their machine names
- Whether submissions are stored in the database or emailed only (or both)
- Any shared handlers used across webforms (CRM, mailing list, notification, etc.)
- Whether the Webform Results module is in use
- Any global webform theme overrides applied
