Drupal 11: a reusable paragraphs package with their SDC components

Cet article est aussi disponible en français

Every Drupal site I maintain in my uzinasit ends up needing the same content blocks: rich text, text with an image, a gallery, a video, a map, a button, a quote. Until now, each site rewrote them its own way. I eventually packaged a set of paragraph config and SDC components into a module that I am rolling out to all the sites, little by little.

A package in three parts

The package is made of three pieces:

  • a Drupal recipe (recipes/uzinasit_paragraphs) carries the config: the paragraph types, their fields, their form and view displays, and the media types they need (image, remote_video);
  • a module carries the code: one bundle class per type, and the business logic of each type;
  • SDC components, in that same module, carry the rendering: neutral HTML and minimal CSS, working without any theme.

Between content and rendering, there is a single point of contact: the binding template paragraph--<bundle>.html.twig.

Paragraph (content)  →  fields  →  formatters  →  binding template      →  SDC component (rendering)
  bundle (recipe)                  data.*         paragraph--<bundle>     uzinasit_paragraphs:<name>
  + bundle class                   content.*      (module)                (replaceable by the theme)

Why a recipe

An installation profile is only used when a site is created. A recipe, on the other hand, can also be applied to an existing site:

drush @mysite recipe ../recipes/uzinasit_paragraphs_page

There are two of them. The first one creates the paragraphs without assuming anything about the site. The second one, uzinasit_paragraphs_page, applies the first one and then adds the field_paragraphs field to the "page" content type, creating it if it does not exist. Once the recipe is applied, the site's config (drush cex) becomes the source of truth again: a recipe cannot be "uninstalled", and you do not re-apply it on top to evolve a type.

All names are prefixed with uzp_ (bundles) and field_uzp_ (fields): a site that already has its own rich_text or field_text does not collide with the package.

Ten paragraph types

  • rich text, rich text with an image (on the left or on the right)
  • image gallery, with a lightbox, a caption per image and a grid or strip layout
  • video (YouTube)
  • OpenStreetMap map
  • CTA (call to action)
  • quote
  • Block
  • ...

Each one can carry a shared editorial title (text + level h2 to h4), which is itself an SDC component. An empty title renders nothing, not even a wrapper.

The template

Raw values arrive in data thanks to raw_fields_formatters (formatters that expose a field's value instead of rendering it), and are passed to the component as props. Fields that are already rendered (an oEmbed video, for instance) are passed as slots. Here is the quote as an example:

{% set title %}
  {%- if data.title -%}
    {{ include('uzinasit_paragraphs:heading', {
      text: data.title,
      level: data.title_level|default(2),
    }, with_context: false) }}
  {%- endif -%}
{% endset %}
{% if data.quote %}
  {{ include('uzinasit_paragraphs:quote', {
    attributes: attributes.addClass(classes),
    quote: data.quote,
    author: data.author|default(''),
    title: title|trim ? title : '',
  }, with_context: false) }}
{% endif %}

The component itself knows nothing about Drupal: it never reads an entity.

<figure{{ attributes.addClass('uzp-quote') }}>
  {{ title }}
  <blockquote class="uzp-quote__text">
    <p>{{ quote|nl2br }}</p>
  </blockquote>
  {% if author %}
    <figcaption class="uzp-quote__author">{{ author }}</figcaption>
  {% endif %}
</figure>

Each site's theme styles these components, or replaces one entirely thanks to the SDC replaces key, without touching either the module or the content.

Bundle classes: the business logic

Cleaner than using a paragraph preprocess hook, they hold whatever is specific to each paragraph type.

#[Bundle(entityType: 'paragraph', bundle: 'uzp_cta')]
final class CtaParagraph extends UzpParagraphBase {

  public const FIELD_LINK = 'field_uzp_link';
  public const FIELD_STYLE = 'field_uzp_style';

  /**
   * The allowed styles, those of the component's enum.
   */
  public const STYLES = ['primary', 'secondary'];

  public function getStyle(): string {
    $style = $this->hasField(self::FIELD_STYLE)
      ? (string) ($this->get(self::FIELD_STYLE)->value ?? '')
      : '';
    return in_array($style, self::STYLES, TRUE) ? $style : self::STYLES[0];
  }

  public function getComponentData(): array {
    return ['style' => $this->getStyle()] + parent::getComponentData();
  }

}

Each type has its class, always, even without any business logic: at the very least it holds the constants of its fields, and it is the class that decides whether the shared editorial title applies (only bundles whose class extends UzpParagraphBase get it).

What's next

I am starting to think about packaging this, so that it can be used on other sites. I want to polish it all first: the paint is still wet.

Written by Kevin Gautreau — freelance PHP/Drupal developer & trainer in Auvergne, France. get in touch

Related content

article · 29 May 2026 Uzinasit: lessons learned from my Drupal 11 site factory I took advantage of having a bit of free time during summer 2025 to start rationalizing all my websites, to make them easier to manage Why a site factory?… article · 13 Oct 2026 Drupal: raw_fields_formatters, raw data for SDC components and decoupled front ends I've been working on decoupled Drupal projects for a few years now (Drupal as the back end for content editing, with an Angular front end or a mobile app, for…

Comments (0)