# Drupal 11.3+ / 12 Best Practices


> **Target:** Drupal 11.3+ and Drupal 12 compatibility

---

## 1. Module Structure

### 1.1 Required Files

```
ffmpeg_media/
├── ffmpeg_media.info.yml      # REQUIRED: Module definition
├── ffmpeg_media.module        # MINIMAL: Only for theme hooks
├── ffmpeg_media.services.yml  # REQUIRED: Service definitions
├── ffmpeg_media.install       # REQUIRED: Schema, updates
├── composer.json              # REQUIRED: Dependencies
└── README.md                  # REQUIRED: Documentation
```

### 1.2 info.yml Template

```yaml
name: 'FFmpeg Media'
type: module
description: 'Professional video processing suite with FFmpeg 8 support.'
package: Media
core_version_requirement: ^11.3 || ^12
php: 8.5
lifecycle: experimental

dependencies:
  - drupal:media
  - drupal:file

configure: ffmpeg_media.settings
```

---

## 2. OOP Hooks with #[Hook] Attribute

### 2.1 Hook Class Pattern

```php
<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\media\MediaInterface;

/**
 * Entity hook implementations.
 */
final class EntityHooks {

  use StringTranslationTrait;

  public function __construct(
    protected readonly QueueManagerInterface $queueManager,
    protected readonly FormatterSettingsResolverInterface $formatterSettingsResolver,
    protected readonly SourceFileResolverInterface $sourceFileResolver,
    protected readonly MessengerInterface $messenger,
    protected readonly AccountProxyInterface $currentUser,
    protected readonly ModuleHandlerInterface $moduleHandler,
  ) {}

  /**
   * Implements hook_media_insert().
   */
  #[Hook('media_insert')]
  public function mediaInsert(MediaInterface $media): void {
    $this->queueIfConfigured($media);
  }

  /**
   * Implements hook_media_update().
   */
  #[Hook('media_update')]
  public function mediaUpdate(MediaInterface $media): void {
    // Check if source file changed.
    $context = $this->formatterSettingsResolver->resolveForEntity($media);
    if ($context === NULL) {
      return;
    }

    $originalSourceId = NULL;
    if (isset($media->original) && $media->original instanceof MediaInterface) {
      $originalFile = $this->sourceFileResolver->getSourceFile($media->original, $context);
      $originalSourceId = $originalFile?->id();
    }

    $currentFile = $this->sourceFileResolver->getSourceFile($media, $context);
    $currentSourceId = $currentFile?->id();

    if ($currentSourceId !== NULL && $currentSourceId !== $originalSourceId) {
      $this->queueIfConfigured($media);
    }
  }

}
```

### 2.2 Register Hook Class

```yaml
# ffmpeg_media.services.yml
services:
  Drupal\ffmpeg_media\Hook\EntityHooks:
    arguments:
      $queueManager: '@Drupal\ffmpeg_media\Service\QueueManagerInterface'
      $formatterSettingsResolver: '@Drupal\ffmpeg_media\Service\FormatterSettingsResolverInterface'
      $sourceFileResolver: '@Drupal\ffmpeg_media\Service\SourceFileResolverInterface'
      $messenger: '@messenger'
      $currentUser: '@current_user'
      $moduleHandler: '@module_handler'
    public: true
```

### 2.3 Hook Ordering

```php
use Drupal\Core\Hook\OrderAfter;
use Drupal\Core\Hook\OrderBefore;

#[Hook('media_presave', order: new OrderAfter(['media_library']))]
public function mediaPresaveAfterLibrary(MediaInterface $media): void {}

#[Hook('media_presave', order: new OrderBefore(['content_moderation']))]
public function mediaPresaveBeforeModeration(MediaInterface $media): void {}
```

---

## 3. Services with Autowiring

### 3.1 Service Definition

```yaml
# ffmpeg_media.services.yml
services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: false

  # Interface-based registration
  Drupal\ffmpeg_media\Service\VideoConverterInterface:
    class: Drupal\ffmpeg_media\Service\VideoConverter

  # Alias for backwards compatibility
  ffmpeg_media.video_converter:
    alias: Drupal\ffmpeg_media\Service\VideoConverterInterface
    public: true

  # Plugin managers
  Drupal\ffmpeg_media\FFmpegCodecPluginManager:
    parent: default_plugin_manager
```

### 3.2 Service Class

```php
<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

/**
 * Main video converter service.
 */
final class VideoConverter implements VideoConverterInterface {

  public function __construct(
    private readonly FFmpegExecutorInterface $ffmpegExecutor,
    private readonly FormatterSettingsResolverInterface $formatterSettingsResolver,
    private readonly SourceFileResolverInterface $sourceFileResolver,
    private readonly OutputStorageServiceInterface $outputStorageService,
    private readonly ProgressTrackerInterface $progressTracker,
    private readonly ProfileManagerInterface $profileManager,
    private readonly QueueManagerInterface $queueManager,
    private readonly FFmpegCodecPluginManager $codecManager,
    private readonly FFmpegOutputPluginManager $outputManager,
    private readonly ConfigFactoryInterface $configFactory,
    private readonly FileSystemInterface $fileSystem,
    private readonly LoggerChannelInterface $logger,
    private readonly FFprobeCacheServiceInterface $ffprobeCache,
    private readonly ConversionFileManagerInterface $conversionFileManager,
    private readonly ProcessorPipelineRunnerInterface $processorPipelineRunner,
    private readonly ConversionConfigResolverInterface $configResolver,
    private readonly ConversionLifecycleHandlerInterface $lifecycleHandler,
    private readonly ConversionExecutorInterface $conversionExecutor,
    private readonly AdaptiveStreamingConverterInterface $adaptiveConverter,
  ) {}

}
```

---

## 4. Plugin System

### 4.1 Plugin Attribute (Drupal 11+)

```php
<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Attribute;

use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * FFmpeg codec plugin attribute.
 */
#[\Attribute(\Attribute::TARGET_CLASS)]
final class FFmpegCodec extends Plugin {

  public function __construct(
    public readonly string $id,
    public readonly TranslatableMarkup $label,
    public readonly ?TranslatableMarkup $description = NULL,
    public readonly string $encoder = '',
    public readonly array $supportedContainers = [],
    public readonly bool $hardwareAccelerated = FALSE,
    public readonly int $weight = 0,
    public readonly ?string $deriver = NULL,
  ) {}

}
```

### 4.2 Plugin Implementation

```php
<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Plugin\FFmpegCodec;

use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\ffmpeg_media\Attribute\FFmpegCodec;

/**
 * VP9 codec plugin.
 */
#[FFmpegCodec(
  id: 'vp9',
  label: new TranslatableMarkup('VP9'),
  description: new TranslatableMarkup('High-quality open codec for WebM containers.'),
  encoder: 'libvpx-vp9',
  supportedContainers: ['webm', 'mkv'],
  hardwareAccelerated: FALSE,
  weight: 10,
)]
final class VP9Codec extends FFmpegCodecBase {

  #[\Override]
  #[\NoDiscard]
  public function buildArguments(ConversionOptions $options): array {
    $args = [
      '-c:v', 'libvpx-vp9',
      '-cpu-used', (string) $this->getCpuUsed($options->preset),
      '-row-mt', '1',
    ];

    // Rate control and common arguments omitted for brevity.
    return $args;
  }

}
```

### 4.3 Plugin Manager

```php
<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\ffmpeg_media\Attribute\FFmpegCodec;

/**
 * Plugin manager for FFmpeg codecs.
 */
final class FFmpegCodecPluginManager extends DefaultPluginManager {

  public function __construct(
    \Traversable $namespaces,
    CacheBackendInterface $cache_backend,
    ModuleHandlerInterface $module_handler,
  ) {
    parent::__construct(
      'Plugin/FFmpegCodec',
      $namespaces,
      $module_handler,
      FFmpegCodecInterface::class,
      FFmpegCodec::class,
    );

    $this->alterInfo('ffmpeg_codec_info');
    $this->setCacheBackend($cache_backend, 'ffmpeg_codec_plugins');
  }

}
```

---

## 5. Configuration

### 5.1 Config Schema

```yaml
# config/schema/ffmpeg_media.schema.yml
ffmpeg_media.settings:
  type: config_object
  label: 'FFmpeg Media settings'
  mapping:
    ffmpeg_path:
      type: string
      label: 'FFmpeg path'
    ffprobe_path:
      type: string
      label: 'FFprobe path'
    output_directory:
      type: string
      label: 'Output directory'
    ffmpeg_timeout:
      type: integer
      label: 'FFmpeg conversion timeout (seconds)'
    queue_claim_timeout:
      type: integer
      label: 'Queue claim timeout (seconds)'
    max_concurrent:
      type: integer
      label: 'Maximum concurrent conversions'
    retry:
      type: mapping
      mapping:
        enabled:
          type: boolean
        max_retries:
          type: integer
        base_delay:
          type: integer
        max_delay:
          type: integer
    enabled_processors:
      type: sequence
      sequence:
        type: string
```

### 5.2 Config Form

```php
<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Form;

use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Module settings form.
 */
final class SettingsForm extends ConfigFormBase {

  /**
   * {@inheritdoc}
   */
  protected function getEditableConfigNames(): array {
    return ['ffmpeg_media.settings'];
  }

  /**
   * {@inheritdoc}
   */
  public function getFormId(): string {
    return 'ffmpeg_media_settings';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state): array {
    $config = $this->config('ffmpeg_media.settings');

    $form['ffmpeg_path'] = [
      '#type' => 'textfield',
      '#title' => $this->t('FFmpeg path'),
      '#default_value' => $config->get('ffmpeg_path'),
      '#description' => $this->t('Leave empty for auto-detection.'),
    ];

    return parent::buildForm($form, $form_state);
  }

}
```

---

## 6. Queue Workers

### 6.1 Queue Worker Attribute

```php
<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Plugin\QueueWorker;

use Drupal\Core\Queue\Attribute\QueueWorker;
use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * Video conversion queue worker.
 */
#[QueueWorker(
  id: 'ffmpeg_media_conversion',
  title: new TranslatableMarkup('Video Conversion'),
  cron: ['time' => 600],
)]
final class VideoConversionQueueWorker extends QueueWorkerBase implements ContainerFactoryPluginInterface {

  public function processItem($data): void {
    // Process conversion
  }

}
```

---

## 7. Events

### 7.1 Event Classes

```php
<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Event;

use Drupal\Component\EventDispatcher\Event;

/**
 * Event dispatched when conversion completes.
 */
final class ConversionCompletedEvent extends Event {

  public function __construct(
    public readonly MediaInterface $media,
    public readonly ConversionResult $result,
  ) {}

}
```

### 7.2 Dispatching Events

```php
$this->eventDispatcher->dispatch(
  new ConversionCompletedEvent($media, $result),
  FFmpegMediaEvents::CONVERSION_COMPLETED
);
```

### 7.3 Event Subscriber

```php
<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Event subscriber for conversion events.
 */
final class ConversionEventSubscriber implements EventSubscriberInterface {

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents(): array {
    return [
      FFmpegMediaEvents::CONVERSION_COMPLETED => 'onConversionCompleted',
      FFmpegMediaEvents::CONVERSION_FAILED => 'onConversionFailed',
    ];
  }

  public function onConversionCompleted(ConversionCompletedEvent $event): void {
    // Handle completed conversion
  }

}
```

---

## 8. Permissions

```yaml
# ffmpeg_media.permissions.yml
administer ffmpeg media:
  title: 'Administer FFmpeg Media'
  description: 'Configure module settings and manage all conversions.'
  restrict access: true

convert media:
  title: 'Convert media files'
  description: 'Queue media files for video conversion.'

reconvert media:
  title: 'Reconvert media files'
  description: 'Re-process already converted media files.'

view conversion progress:
  title: 'View conversion progress'
  description: 'View the progress of ongoing video conversions.'

view ffmpeg media metrics:
  title: 'View FFmpeg Media metrics'
  description: 'Access Prometheus metrics endpoint for monitoring.'
```

---

## 9. Routing

```yaml
# ffmpeg_media.routing.yml
ffmpeg_media.settings:
  path: '/admin/config/media/ffmpeg-media'
  defaults:
    _form: '\Drupal\ffmpeg_media\Form\SettingsForm'
    _title: 'FFmpeg Media Settings'
  requirements:
    _permission: 'administer ffmpeg media'

ffmpeg_media.progress:
  path: '/api/ffmpeg-media/progress/{media}'
  defaults:
    _controller: '\Drupal\ffmpeg_media\Controller\ProgressController::getProgress'
  requirements:
    _permission: 'view conversion progress'
    _entity_access: 'media.view'
    media: \d+
  options:
    parameters:
      media:
        type: entity:media
    no_cache: true
```

---

## 10. Caching

### 10.1 Cache Tags

```php
$build = [
  '#theme' => 'ffmpeg_media_status',
  '#media' => $media,
  '#cache' => [
    'tags' => [
      'media:' . $media->id(),
      'ffmpeg_media:status',
    ],
    'contexts' => ['user.permissions'],
    'max-age' => 60,
  ],
];
```

### 10.2 Cache Invalidation

```php
// Invalidate specific tags
Cache::invalidateTags(['ffmpeg_media:status']);

// On entity save, tags are invalidated automatically
```

---

## 11. Translations

### 11.1 All Strings Translatable

```php
// Use $this->t() in classes that use StringTranslationTrait
$message = $this->t('Conversion completed for @title', ['@title' => $media->label()]);

// Use TranslatableMarkup in attributes
#[QueueWorker(
  id: 'ffmpeg_media_conversion',
  title: new TranslatableMarkup('Video Conversion'),
)]
```

### 11.2 No Hardcoded Strings in Output

```php
// GOOD
$this->messenger()->addStatus($this->t('Video queued for conversion.'));

// BAD
$this->messenger()->addStatus('Video queued for conversion.');
```

---

## 12. Operational Hardening (2026)

### 12.1 Health Probes in Production

- Keep probe routes orchestration-friendly, but enforce runtime auth.
- Use token-based auth with:
  - `ffmpeg_media.health_probe_token` in State API
  - `health_probe_token_required`
- Enforce fail-closed in production via `APP_ENV=prod|production` and
  environment secret injection (`FFMPEG_HEALTH_PROBE_TOKEN`).
- Expose State-backed fallback probe settings in the admin UI so non-env
  deployments can rotate tokens without writing secrets into config exports.

### 12.2 Upstream Tooling Workarounds

- If static analysis tooling has known upstream regressions, use
  temporary Composer patches with explicit tracking:
  - patch file in `patches/`
  - documented removal condition when upstream stable is available

### 12.3 Reproducible Performance Checks

- Keep a lightweight scripted benchmark in repository (for example:
  `scripts/load-benchmark.mjs`).

---

*Best practices for Drupal 11.3+/12 module development*
