Skip to content

Development Standards and Anti-Patterns

Core Standards

1. Always Use Dependency Injection

WHY: Breaks testability, violates SOLID principles, creates hidden dependencies.

// WRONG
public function buildForm(array $form, FormStateInterface $form_state) {
  $config = \Drupal::config('mymodule.settings');
  $entity_manager = \Drupal::entityTypeManager();
}

// CORRECT
protected $config;
protected $entityTypeManager;

public function __construct(ConfigFactoryInterface $config_factory, EntityTypeManagerInterface $entity_type_manager) {
  $this->config = $config_factory->get('mymodule.settings');
  $this->entityTypeManager = $entity_type_manager;
}

public static function create(ContainerInterface $container) {
  return new static(
    $container->get('config.factory'),
    $container->get('entity_type.manager')
  );
}

What breaks: Unit tests fail, service overrides ignored, hard to track dependencies.

2. Use #config_target for Config Forms

WHY: Reduces code, automatic validation, works outside forms.

// OLD WAY
public function buildForm(array $form, FormStateInterface $form_state) {
  $config = $this->config('mymodule.settings');
  $form['api_key']['#default_value'] = $config->get('api_key');
}
public function submitForm(array &$form, FormStateInterface $form_state) {
  $this->config('mymodule.settings')
    ->set('api_key', $form_state->getValue('api_key'))
    ->save();
}

// NEW WAY (Drupal 10.2+)
public function buildForm(array $form, FormStateInterface $form_state) {
  $form['api_key'] = [
    '#type' => 'textfield',
    '#config_target' => 'mymodule.settings:api_key', // Auto sync
  ];
  // submitForm() not needed
}

What breaks: Manual save bugs, forgot to save, validation bypassed.

3. Use Specific Form Alter Hooks

WHY: Performance - hook_form_alter() runs on EVERY form.

// WRONG
function mymodule_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  if ($form_id == 'user_login_form') { /* ... */ }
}

// CORRECT
function mymodule_form_user_login_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  // Runs ONLY for login form
}

What breaks: Every form on site takes performance hit.

4. Use Route Names for Redirects

WHY: Routes can change, language prefix breaks paths.

// WRONG
$form_state->setRedirectUrl(Url::fromUri('internal:/admin/content'));

// CORRECT
$form_state->setRedirect('system.admin_content');

What breaks: 404 errors when path changes, multilingual sites broken.

5. FormState Storage for Multi-Step

WHY: Forms are stateless - new instance every request.

// WRONG
protected $step = 1; // Lost after page reload

// CORRECT
public function buildForm(array $form, FormStateInterface $form_state) {
  $form_state->setCached(TRUE); // REQUIRED
  $step = $form_state->get('step') ?? 1;
}

What breaks: Multi-step forms reset to step 1, user data lost.

Critical Anti-Patterns

Anti-Pattern 1: Business Logic in buildForm()

WHY: buildForm() runs on EVERY display including AJAX, validation errors, multi-step navigation.

// WRONG
public function buildForm(array $form, FormStateInterface $form_state) {
  $data = $this->externalApi->fetchData(); // Runs on every AJAX callback!
}

// CORRECT
public function submitForm(array &$form, FormStateInterface $form_state) {
  $data = $this->externalApi->fetchData(); // Runs once on submit
}

What breaks: Massive performance hit, timeout on slow APIs.

Anti-Pattern 2: Using $_POST/$_GET

WHY: Bypasses Form API security (CSRF check skipped), no sanitization.

// WRONG
$email = $_POST['email']; // NO VALIDATION, NO CSRF CHECK

// CORRECT
$email = $form_state->getValue('email'); // Validated, sanitized, CSRF checked

What breaks: CSRF attacks work, XSS possible, SQL injection if used in queries.

Anti-Pattern 3: Storing Objects in FormState

WHY: Serialization fails, memory bloat in cache_form table.

// WRONG
$form_state->set('entity', $node); // Cache errors

// CORRECT
$form_state->set('entity_id', $node->id());
// Then load when needed:
$node = $this->entityTypeManager->getStorage('node')->load($nid);

What breaks: "Cannot serialize" errors, forms can't cache.

Anti-Pattern 4: Not Limiting File Extensions

WHY: PHP files uploaded = remote code execution.

// WRONG
$form['file'] = [
  '#type' => 'managed_file',
  '#upload_location' => 'public://uploads/',
  // No validators = SECURITY HOLE
];

// CORRECT
$form['file'] = [
  '#type' => 'managed_file',
  '#upload_location' => 'private://uploads/',
  '#upload_validators' => [
    'file_validate_extensions' => ['pdf doc docx'],
    'file_validate_size' => [2 * 1024 * 1024], // 2MB
  ],
];

What breaks: Attackers upload .php files, execute code, take over server.

Anti-Pattern 5: Hardcoding Entity IDs

WHY: IDs change between environments.

// WRONG
$node = Node::load(42); // Doesn't exist on other environments

// CORRECT
$nodes = $this->entityTypeManager
  ->getStorage('node')
  ->loadByProperties(['title' => 'About Us', 'type' => 'page']);

What breaks: Code works in dev, fails in production.

Anti-Pattern 6: No Cache Contexts on Conditional Alters

WHY: Cache serves same form to all users even when should differ.

// WRONG
function mymodule_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  if (\Drupal::currentUser()->hasPermission('administer site')) {
    $form['admin_field'] = [...]; // Cached for anonymous!
  }
}

// CORRECT
function mymodule_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  if (\Drupal::currentUser()->hasPermission('administer site')) {
    $form['admin_field'] = [...];
  }
  $form['#cache']['contexts'][] = 'user.permissions';
}

What breaks: Permission checks bypassed by cache, security leak.

Development Checklist

  • [ ] All services via dependency injection (no \Drupal::)
  • [ ] ConfigFormBase uses #config_target (Drupal 10.2+)
  • [ ] Specific form alter hooks (not generic)
  • [ ] Errors with setErrorByName() not setError()
  • [ ] All output via render arrays (no raw HTML)
  • [ ] Redirects use route names (not hardcoded paths)
  • [ ] Multi-step uses FormState storage + setCached(TRUE)
  • [ ] All user-facing strings use $this->t()
  • [ ] No business logic in buildForm()
  • [ ] Never use $_POST/$_GET
  • [ ] File uploads have extension validators
  • [ ] No hardcoded entity/config IDs
  • [ ] Cache contexts when output varies by user/permission
  • [ ] Forms are testable (DI enables mocking)

See Also