Combining permission checks for granular form field accessfor Drupal 8 , 9 , 10 , and 11

Last updated :  

Route-level access answers "can this visitor reach the form at all?" — but sometimes different visitors who all pass that first check should still see different things once they're in. A logged-in editor and a site administrator might both be allowed onto the same form, yet only the administrator should see a particular sensitive field. This lesson covers that finer-grained layer: per-element access checks written as a small custom helper method on your own form class.

What you'll learn in this lesson

  • Why per-element access is a pattern you write yourself, not a Drupal API method you override
  • How to combine #access with permission checks on individual form elements
  • How this layers on top of, rather than replaces, route-level access from the previous lesson

A custom per-element access helper

namespace Drupal\my_module\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;

class SecureAccessForm extends FormBase {

  public function getFormId() {
    return 'secure_access_form';
  }

  public function buildForm(array $form, FormStateInterface $form_state) {
    $form['restricted_data'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Restricted Data'),
      '#access' => $this->elementAccess('restricted_data'),
    ];

    $form['admin_setting'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Admin Setting'),
      '#access' => $this->elementAccess('admin_setting'),
    ];

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => $this->t('Save Changes'),
    ];

    return $form;
  }

  public function submitForm(array &$form, FormStateInterface $form_state) {
    $this->messenger()->addMessage($this->t('Form has been submitted!'));
  }

  /**
   * Checks whether the current user may see a given form element.
   *
   * This is an ordinary method we wrote ourselves — not a Drupal Form API
   * hook or interface method. You could name it anything.
   */
  protected function elementAccess(string $element_key): bool {
    $current_user = \Drupal::currentUser();

    return match ($element_key) {
      'restricted_data' => $current_user->hasPermission('access restricted data'),
      'admin_setting' => $current_user->hasPermission('administer site configuration'),
      default => TRUE,
    };
  }

}
Corrected from the original: the method is renamed from getFormAccess() to elementAccess(), and it's explicitly called out as a plain custom method, not something you're "implementing" against a Drupal interface — no such getFormAccess() API exists in core. The underlying #access pattern itself is genuinely idiomatic and correct; only the framing of it as a built-in method was wrong. Also swapped in $this->messenger()->addMessage() for the dead drupal_set_message() call, and modernized the switch to a PHP match expression.

This layers on top of route-level access, it doesn't replace it

Nothing here checks whether the visitor can reach /secure-access-form at all — that's still the route's requirements key from the previous lesson. What #access controls is whether one specific element renders once the visitor is already on the page. Setting #access => FALSE doesn't just visually hide a field either: Drupal strips it from processing entirely, so even a manually crafted POST request that includes a value for that field gets ignored on submission. That's a meaningfully stronger guarantee than hiding a field with CSS.

Defining the permissions this depends on

access restricted data:
  title: 'Access Restricted Data'
  description: 'Allow users to access restricted data fields.'
  restrict access: TRUE

administer site configuration:
  title: 'Administer Site Configuration'
  description: 'Allows users to have administrative access to site configuration.'
  restrict access: TRUE

Quick check: if a visitor without the access restricted data permission somehow submits a raw POST request that includes a value for restricted_data, does it reach your submitForm()? (Answer: no — because the element's #access evaluated to FALSE during buildForm(), Drupal excludes it from the processed form values entirely, regardless of what the raw request contained.)

Key takeaways

  • Route-level access (_permission, _custom_access) controls whether a visitor reaches a form at all; per-element #access controls what they see once they're on it — use both together, not one instead of the other.
  • There's no built-in Drupal method you "implement" for per-element access — write a plain method on your form class and call it from buildForm().
  • #access => FALSE strips an element from processing entirely, not just from the rendered HTML — a stronger guarantee than CSS-based hiding.
  • drupal_set_message() is dead Drupal 7 code — use the Messenger service instead.

Coming up next

You've now covered the full security surface of Drupal forms — CSRF tokens, input sanitization, route-level permissions, file upload validation, and per-element access. Next, we shift focus from security to speed: caching form render arrays, so a form's expensive parts don't get rebuilt from scratch on every single request.