Skip to content

Best Practices

Performance

Single-table advantage:

  • Custom Field stores all sub-fields in one table row -- single query to load all data
  • Paragraphs creates N entities -- N+1 query problem (1 parent + N children)
  • For 10 sub-fields: Custom Field = 1 JOIN, Paragraphs = 10+ JOINs

Optimization patterns:

// GOOD: Load entity once, access multiple sub-fields
$node = \Drupal::entityTypeManager()->getStorage('node')->load($nid);
$street = $node->field_address->street;
$city = $node->field_address->city;
$state = $node->field_address->state;

// BAD: Loading entity repeatedly
$street = Node::load($nid)->field_address->street;
$city = Node::load($nid)->field_address->city; // Redundant load

Views performance:

  • All columns in same table -- no relationship needed
  • Filter/sort on custom field columns = single table query
  • vs Paragraphs: require relationships, multiple JOIN tables

Field count limits:

  • MySQL row size limit: ~8KB per row
  • Practical limit: ~50-100 sub-fields depending on types
  • For 100+ fields: consider custom field type plugin instead

Caching:

  • Field values cached with entity
  • No special caching needed
  • Clear entity cache when updating programmatically

Security

XSS prevention:

// GOOD: Use render arrays with automatic escaping
return [
  '#markup' => $this->t('Value: @value', ['@value' => $value]),
];

// BAD: Raw concatenation
return '<div>' . $value . '</div>'; // XSS vulnerability

Entity reference access checks:

// GOOD: Check access before rendering
$entity = $storage->load($entity_id);
if ($entity && $entity->access('view')) {
  return $entity->toLink()->toRenderable();
}

// BAD: Assume access
return $storage->load($entity_id)->toLink(); // May expose restricted content

File upload security:

  • Always validate file extensions in widget settings
  • Use private:// scheme for sensitive files
  • Configure upload_location to segregate files
  • Set max_filesize to prevent DoS

Link security:

  • Use noopener noreferrer for target="_blank"
  • Validate external URLs with allowed protocols
  • Built-in constraints: LinkExternalProtocolsConstraint, LinkAccessConstraint

SQL injection prevention:

  • Custom Field uses Entity API -- parameterized queries automatic
  • For custom queries, use query builders with placeholders
  • Never concatenate user input into SQL

Development Standards

Dependency injection (not static calls):

// GOOD: Inject services in custom plugins
class MyCustomType extends CustomFieldTypeBase {
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    protected EntityTypeManagerInterface $entityTypeManager,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('entity_type.manager'),
    );
  }
}

// BAD: Static service calls
$entity = \Drupal::entityTypeManager()->getStorage('node')->load($nid);

Proper API usage:

// GOOD: Use field methods
$values = [];
foreach ($node->field_custom as $delta => $item) {
  $values[] = $item->street;
}

// BAD: Direct property access
$values = $node->field_custom->getValue(); // Bypasses field logic

Empty checks:

// GOOD: Use field isEmpty() method
if (!$node->field_custom->isEmpty()) {
  $value = $node->field_custom->street;
}

// BAD: Checking individual properties
if ($node->field_custom->street) { // May fail on 0, '0', FALSE

Multi-value iteration:

// GOOD: Iterate properly
foreach ($node->field_custom as $delta => $item) {
  if (!$item->isEmpty()) {
    $output[] = $item->street;
  }
}

// BAD: Assume single value
$street = $node->field_custom->street; // Only gets delta 0

Update hooks for schema changes:

function my_module_update_9001() {
  /** @var \Drupal\custom_field\CustomFieldUpdateManager $updateManager */
  $updateManager = \Drupal::service('custom_field.update_manager');

  $field_storage = FieldStorageConfig::loadByName('node', 'field_address');
  $columns = $field_storage->getSetting('columns');

  // Add new column
  $columns['country'] = [
    'name' => 'country',
    'type' => 'string',
    'length' => 2,
  ];

  $field_storage->setSetting('columns', $columns);
  $updateManager->updateFieldSchema($field_storage);

  return t('Added country column to field_address');
}

Anti-Patterns

  • Modify field schema directly in database -- always use CustomFieldUpdateManager
  • Store sensitive data (passwords, API keys) in custom fields -- use State API or Key module
  • Create entity references to deleted entities -- validate entity exists before saving
  • Use @extend in SCSS for custom field styling -- creates selector explosion
  • Assume field exists -- check field definition exists before accessing
  • Hard-code field names -- use constants or config for field name references

Common Mistakes

  • Loading entities in loops -- Load once, access multiple times; or use loadMultiple()
  • Not sanitizing output in custom formatters -- Use render arrays with #markup and placeholders, not raw concatenation
  • Exposing entity references without access checks -- Always check $entity->access('view')
  • Public files for private data -- Use private:// scheme and configure file access controls
  • Using static calls in OOP code -- Inject dependencies via DI; improves testability
  • Modifying fields without update hooks -- Schema changes must go through CustomFieldUpdateManager

See Also

  • OWASP: https://owasp.org/www-project-top-ten/
  • Drupal security: https://www.drupal.org/security/secure-coding-practices
  • Drupal coding standards: https://www.drupal.org/docs/develop/standards