parisek / drupal-kit
Shared Drupal infrastructure for sites built on the PORTA component pattern: services, base classes, Twig extensions, image resizer.
Requires
- php: >=8.3
- drupal/components: ^3.2
- drupal/config_pages: ^2.19
- drupal/core: ^11.4
- drupal/extra_field: ^3.0
- drupal/twig_real_content: ^1.0
- drupal/twig_tweak: ^3.4
- parisek/twig-typography: ^1.3
Requires (Dev)
- composer/installers: ^2.0
- drupal/address: ^2.0
- drupal/coder: ^8.3
- drupal/commerce: ^3.0
- drupal/core-dev: ^11.4
- drupal/file_mdm: ^3.0
- drupal/focal_point: ^2.0
- drupal/image_effects: ^4.0
- drupal/inline_entity_form: ^3.0@RC
- drupal/scheduler: ^2.3
- drupal/webform: ^6.3
- drush/drush: ^13.0
- ergebnis/composer-normalize: ^2.47
- mglaman/phpstan-drupal: ^2.0
- phpstan/phpstan: ^2.0
- phpstan/phpstan-deprecation-rules: ^2.0
- phpunit/phpunit: ^10 || ^11
Suggests
- drupal/address: Enables address field rendering in EntityHelper::getAddressField.
- drupal/commerce: Enables commerce_price field rendering (via the commerce_price submodule) and commerce_product entity references in EntityHelper.
- drupal/office_hours: Enables office_hours field rendering in EntityHelper.
- drupal/scheduler: Announces a scheduled publish or unpublish date on the entity page.
- drupal/webform: Lets the datalayer feature push a generate_lead after a webform submission.
- drush/drush: Provides the kit:config-apply command.
Provides
None
Conflicts
None
Replaces
None
README
parisek/drupal-kit — base library module for Drupal sites built on the PORTA component pattern. Provides shared infrastructure (services, base classes, Twig extensions, image resizer) reused across projects. The Drupal counterpart of parisek/timber-kit (WordPress).
Requires PHP 8.3+ and Drupal 10 or 11.
Installation
Published on Packagist:
composer require parisek/drupal-kit drush en drupal_kit
What this module provides
Services
drupal_kit.entity_helper— high-level entity loading and rendering helpers consumed by display plugins and Twig templates. Facade over the three builders below.drupal_kit.media_array_builder— builds the documented array shapes for Media and File entities (image, SVG, video, remote video, document, Lottie).drupal_kit.menu_tree_builder— renders a menu into the documented item shape (active trail,field_*enrichment, subtree scoping viaparams['root']).drupal_kit.taxonomy_tree_builder— builds nested taxonomy term trees.Drupal\drupal_kit\Services\FeatureFlags(drupal_kit.feature_flags) — reads the opt-in flags in thedrupal_kit.feature_flagsconfig object, the shape core uses forsystem.feature_flags.enabled('<flag>')is FALSE unless the module declares the flag and the project set it, which is how hook-level behaviour ships opt-in.Drupal\drupal_kit\Services\Resizer(drupal_kit.resizer) — image style + focal point + responsive variant generator. Take the service, or call the staticResizer::resizer($images, $variants)facade, which delegates to it and is kept for backwards compatibility.$variantsis a list of positional tuples, or an orientation map (landscape/portrait/square) that picks its tuples from the image's own aspect ratio — see Orientation-aware variants.drupal_kit.menu_active_trail_resolver— resolves the active menu trail accounting for entity references and aliases.drupal_kit.twig_extension— registers Twig functions used by component templates, including the typography-aware translation helpers_xt/__t/_nt/_nxt(translate, then pipe through|typography).drupal_kit.typography_twig_extension— provides the|typographyTwig filter; delegates toparisek/twig-typographyand resolves typography config from{active_theme}/static/typography.yml.drupal_kit.route_subscriber— alters routes for entity access edge cases.drupal_kit.config_applier(Drupal\drupal_kit\Services\ConfigApplier) — creates (and, opt-in, updates) an explicit list of config objects through the entity/config API, in dependency order. See Applying config on deploy.drupal_kit.menu_locations(Drupal\drupal_kit\Services\MenuLocations) — resolves a theme-declared menu "slot" (menu_locations:in<theme>.info.yml) to the menu assigned to it, and returns its items inEntityHelper::getMenu()'s shape. One menu per slot in the default language, assigned on the admin form at/admin/structure/menu/locations; a genuinely different menu per language is optional, through Drupal's nativeconfig_translationmodule. See Menu locations.
Base classes
Drupal\drupal_kit\ComponentBase— base for component block plugins.Drupal\drupal_kit\DisplayBase— base forextra_fielddisplay plugins that render components.
Filters
FilterImage, FilterLinks, FilterTable, FilterTypography, FilterYoutube — text format filters that normalize editor output into PORTA's component shape.
Orientation-aware variants
|resizer takes either positional tuples or one orientation map. The map is
recognised by its keys, so a template that never writes one never changes:
{# tuples — the historical shape #} {{ image|resizer(['960', '720', '1280', 'crop'], ['480', '360', '', 'crop']) }} {# orientation map — the image's own aspect ratio picks the bucket #} {{ image|resizer({ landscape: [['960', '720', '1280', 'crop'], ['480', '360', '', 'crop']], portrait: [['720', '960', '1280', 'crop'], ['360', '480', '', 'crop']], square: [['800', '800', '1280', 'crop'], ['400', '400', '', 'crop']], }) }}
An image counts as square while its sides differ by no more than 10 %,
inclusive at both edges; otherwise the longer side decides. A bucket that is
absent or empty falls through to landscape, so a map carries only the
orientations that actually differ.
An image with no width or height is classified as landscape. That is the
ordinary case rather than an edge case: MediaArrayBuilder fills the
dimensions only when the file exists and is a valid image, so a remote file, a
missing file or a broken one arrives with neither.
The same call shape works in parisek/timber-kit
and in the styleguide preview, so one template renders on every stack.
Applying config on deploy
Sites on this stack never run drush config:import on deploy — a full import
diffs the whole active config store against the repository and would
overwrite production UI edits (menu links, block placement, view filters —
anything an editor changed after launch). ConfigApplier and
drush kit:config-apply are the supported alternative: they create (and,
opt-in, update) an explicit, named list of config objects through the
same entity API core's own config sync uses — ConfigEntityStorage::createFromStorageRecord()
/ updateFromStorageRecord() — never a raw \Drupal::configFactory()->save().
That matters for config like field.storage.*: going through the entity API
is what creates the database column and refreshes the entity field map, not
just the config object.
Guarantees:
- Never "everything". You always name the config objects, directly or
via a list file (
--names-file, one name per line,#comments allowed). - Never deletes. A config present in the active store but absent from your list is left untouched, full stop — it is never even considered.
- Create-only by default. An existing config is reported
SKIP-EXISTSunless its name is also in--update. - Update guard. Pass
--expect-hash name=<sha256>(fromConfigApplier::activeHash()) to refuse an update whose active value has drifted from the hash you captured — protects a production UI edit you didn't know about. - Dependency order. Names are topologically sorted from each config's own
dependencies.configlist (field.storage.*beforefield.field.*, a bundle before its fields, …) — you don't have to list them in order. A dependency that is neither in your list nor already active is reportedERRORand nothing is applied. - Idempotent. Safe to run on every deploy; the second run reports
SKIP-EXISTSfor everything it already created.
CLI
# Preview only — prints CREATE / SKIP-EXISTS / UPDATE / REFUSE / ERROR, changes nothing. drush kit:config-apply --names=paragraphs.paragraphs_type.hero,field.storage.paragraph.field_hero_image,field.field.paragraph.hero.field_hero_image --dry-run # Apply for real. drush kit:config-apply --names-file=../config-deploy/hero.txt # Allow one already-existing config to change, guarded by a hash captured earlier. drush kit:config-apply --names=language.content_settings.paragraph.hero \ --update=language.content_settings.paragraph.hero \ --expect-hash=language.content_settings.paragraph.hero=3f9c…
--config-dir defaults to config/sync.
hook_post_update_NAME() pattern
The command above is a manual front end for the same PHP API — call it from
a hook_post_update_NAME() in a project module so a new paragraph type (or
any other config) ships through drush updb on deploy, the same way schema
changes do:
/** * Creates the "hero" paragraph type and its fields. */ function my_project_post_update_hero_paragraph_type(): void { /** @var \Drupal\drupal_kit\Services\ConfigApplier $applier */ $applier = \Drupal::service('drupal_kit.config_applier'); $names = [ 'paragraphs.paragraphs_type.hero', 'field.storage.paragraph.field_hero_image', 'field.field.paragraph.hero.field_hero_image', 'core.entity_view_display.paragraph.hero.default', 'core.entity_form_display.paragraph.hero.default', ]; $plan = $applier->apply( \Drupal::service('extension.list.module')->getPath('my_project') . '/config/deploy', $names, ); foreach ($plan as $entry) { if ($entry['action'] === 'ERROR') { throw new \RuntimeException("kit:config-apply: {$entry['name']}: {$entry['reason']}"); } } }
Re-running drush updb on a site that already has the paragraph type is a
no-op — every name comes back SKIP-EXISTS. Ship the config as a
config/deploy-style directory shipped with the module (not config/sync,
which is the site's own export), so the fixture travels with the code that
needs it.
Menu locations
A theme declares named "slots" for the menus it renders — header, hamburger,
footer columns. A site builder assigns ONE real menu to each slot on one
admin form. The theme reads the slot's items as data. This is the pattern
WordPress calls register_nav_menus(); MenuLocations is the Drupal
equivalent.
It replaces a pattern several projects grew independently: a "menu_block"
block plugin placed in a theme region, configured with a per-language menu
select, used only to hand that menu's items to a component template. The
block placement carried no visual meaning — the region was never printed —
so the block was a menu picker wearing a block. MenuLocations is that
picker, without the block.
One menu per slot in the default language. A menu link
(menu_link_content) is itself content-translatable, and
EntityHelper::getMenu() already returns the current content language's
translation — that is how every other menu-driven part of this module has
always worked, and it is enough for most sites: one menu, its links
translated. Point the slot at that one menu; translate its links with
content translation, the same as any other translatable content, and leave
the per-language assignment below alone entirely.
A site that genuinely needs a DIFFERENT menu per language — not just
different translated links in the same menu — gets that through Drupal's
own, optional config_translation module (see
Per-language menus below), never through a
second key bolted onto this config. A config-level "menu per language"
layer sitting next to content translation is a second translation
mechanism for the same fact, and the two drift: a real example from a
downstream theme carried main-en with a footer address translated as
"ARKERO, DE", while main's own German translation read "ARKERO GmbH,
DE" — two independent copies of the same fact, disagreeing.
Declaring slots
Add a menu_locations key to the theme's <theme>.info.yml, machine name to
label, the same shape as regions::
menu_locations: header_menu: 'Header' hamburger_menu: 'Header (mobile)' footer_menu1: 'Footer (primary)' footer_menu2: 'Footer (secondary)' footer_menu3: 'Footer (tertiary)'
MenuLocations::slots() reads this from the currently active theme —
theme negotiation's answer for the request, read through theme.manager,
not system.theme:default. That matters on a site running more than one
front-end theme (domain-negotiated multi-brand, for instance): each active
theme reads its own declared slots and its own assignment, not always the
site's configured default. An unset key, or a theme with no such key,
returns an empty array — a theme with no menu_locations behaves exactly
as one without any menu slots.
Assigning menus
/admin/structure/menu/locations — a local task next to core's own Menus
page — lists ONE <select> per slot, offering every menu on the site plus
"- None -". No per-language fieldsets: a slot takes one menu, full stop.
Requires the administer menu permission, the same one menu_ui itself
requires: assigning a menu to a slot needs no more authority than placing a
menu block does.
Unlike the runtime lookup above, the form always edits the site's default
(frontend) theme (system.theme:default), never the theme rendering the
admin route it lives on — an admin theme's own menu_locations (if it
declared any) has no form of its own. This is deliberate: an editor opening
this page expects to configure the theme visitors normally see, not
whichever theme happens to be active on the admin page they are looking at.
A project running more than one front-end theme calls MenuLocations
with an explicit $theme argument from its own code for the themes this
form doesn't cover.
Saved into config object drupal_kit.menu_locations, keyed by theme first —
two themes on one site (a default theme and an admin theme, or a multi-brand
site) can declare the same slot name for unrelated content:
locations: arkero: header_menu: main hamburger_menu: main footer_menu1: footer-company
An unassigned slot resolves to NULL and items() returns an empty array.
Per-language menus (optional)
Install config_translation
(a core module, drush en config_translation) and a Translate local
task appears next to the menu locations form's own tabs. It lists every
enabled language; opening one shows a <select> per slot, pre-filled with
the default-language assignment, offering every menu on the site plus
"- None -" — the exact same choice the default-language form makes, on a
per-language override.
This is Drupal's own, native mechanism for translating configuration
(config_translation's ConfigTranslationFormBase), not a bespoke
per-language field of this module's own — config/schema/drupal_kit.schema.yml
marks the slot value translatable: true with a form_element_class
(Drupal\drupal_kit\FormElement\MenuSelect) that renders as the same kind
of <select> the default-language form uses, instead of
config_translation's own default (a plain textfield, wrong for a menu
machine name a translator should pick from a list). Saved as a language
config override — language.<langcode>/drupal_kit.menu_locations in config
sync terms — never as a second key in drupal_kit.menu_locations itself.
config_translation stays entirely optional. Nothing in MenuLocations
checks whether it is installed or behaves differently based on it —
menuName() and items() read the assignment through the injected config
factory exactly like any other config value a project might translate, and
\Drupal\Core\Config\ConfigFactory::get() transparently returns the
current language's override when one exists (via language module's own
LanguageConfigFactoryOverride) and the plain default-language value when
none does. A site with config_translation never installed sees exactly
that second case, always — no code path here treats its absence as
anything other than "no override exists".
One subtlety worth knowing: that override follows the interface
language (languages:language_interface), not the content language this
module's menu items vary by — core's own LanguageConfigFactoryOverride
declares that context, and it is set from the interface language on every
request. On a site like arkero, where content and interface language are
both negotiated from the same URL path prefix, the two are always equal in
practice and this distinction is invisible. A site where they can diverge
(a multilingual admin UI over otherwise-monolingual content, for instance)
would see the slot assignment follow the interface language while the
resolved menu's items still follow the content language — two
different translation mechanisms, each answering its own question.
Reading a slot from a preprocess function
MenuLocations::items() returns a slot's items in EntityHelper::getMenu()'s
shape — the array a component template already expects:
function mytheme_preprocess_page(array &$variables): void { /** @var \Drupal\drupal_kit\Services\MenuLocations $menu_locations */ $menu_locations = \Drupal::service('drupal_kit.menu_locations'); $collected = new CacheableMetadata(); $header_items = $menu_locations->items('header_menu', $collected); $hamburger_items = $menu_locations->items('hamburger_menu', $collected); $header = [ '#theme' => 'custom_component', '#template' => 'header', '#content' => [ 'menu' => $header_items, 'hamburger_menu' => $hamburger_items, ], '#cache' => ['keys' => ['mytheme_layout', 'header']], ]; $collected->applyTo($header); $variables['header_output'] = $header; }
Pass the same CacheableMetadata instance across several items() calls
that feed one render element — the metadata accumulates once and applies
once, rather than needing a separate merge() per call.
items() render-caches its own build in the render cache bin, keyed by
theme, slot AND the resolved menu name (so a reassignment is reflected
immediately rather than serving the old menu from a stale entry). The
languages:language_content cache context on the entry — carried by the
menu's own cache metadata, not by the assignment — is what makes the SAME
key correctly serve a different translation per language: the entry varies
by content language because EntityHelper::getMenu()'s own output does, not
because of anything MenuLocations adds. A cache hit carries the same tags,
contexts and max-age the original build had, so a cached slot is
indistinguishable from a fresh one to the caller.
Change log (optional)
A wrong menu in a header or footer changes every page of the site. With
the menu_locations_log flag in drupal_kit.feature_flags on, the
module writes one notice entry to the drupal_kit log channel for each
real change to an assignment:
header (en) [arkero]: main-en -> main-de, by editor (uid 5)
The entry names the slot, the language, the theme, the old and the new menu,
and the user. One listener covers the form, the Translate tab and a direct
config write such as drush config:set. A save that changes no assignment
writes nothing. A change without a logged-in user (drush, an update hook)
reads by anonymous (cli). Removing a language override reads
main-de -> (inherited). A slot with no menu reads (none).
The flag is on by default, on a fresh install and, through an update
function, on a site that already had the module enabled. A site that does not
want the entries sets menu_locations_log: false in drupal_kit.feature_flags.
The log itself adds the IP address, the time and the request, so the module
does not add them again.
dblog keeps 100000 rows by default and then drops the oldest. A site that
needs a permanent record sends the drupal_kit channel to syslog or
another log service. That is site configuration, not module code.
Migrating from a menu-block-in-a-region theme
A theme that placed a menu_block-family plugin in a region only to pick a
menu moves the assignment into config with a post_update hook, then
deletes the blocks:
/** * Move menu-picker block config into drupal_kit.menu_locations. */ function mytheme_post_update_menu_locations(): void { $region_to_slot = [ 'header_menu' => 'header_menu', 'hamburger_menu' => 'hamburger_menu', 'footer_menu1' => 'footer_menu1', 'footer_menu2' => 'footer_menu2', 'footer_menu3' => 'footer_menu3', ]; $theme = \Drupal::configFactory()->get('system.theme')->get('default'); $config = \Drupal::configFactory()->getEditable('drupal_kit.menu_locations'); $locations = $config->get('locations') ?? []; $default_langcode = \Drupal::languageManager()->getDefaultLanguage()->getId(); $blocks = \Drupal::entityTypeManager()->getStorage('block')->loadByProperties([ 'theme' => $theme, ]); foreach ($blocks as $block) { $region = $block->getRegion(); if (!isset($region_to_slot[$region])) { continue; } // The old block config carried one menu PER LANGUAGE. Content // translation replaces that: point the slot at the site's default // language's menu, and translate that menu's LINKS from there on — // never assign the other languages' separate menu copies. If those // copies diverged from the default (see the drift example above), that // divergence is now a content-translation task, not a config choice. $per_language_menu = $block->get('settings')['menu'] ?? []; $locations[$theme][$region_to_slot[$region]] = $per_language_menu[$default_langcode] ?? reset($per_language_menu) ?: NULL; $block->delete(); } $config->set('locations', $locations)->save(); }
Add the theme's menu_locations: key to its .info.yml in the same
release, and remove the region-only-for-menus preprocess helper (arkero's
arkero_region_items() and its per-region loop) once every slot reads
through MenuLocations instead. Translate the KEPT menu's links for every
other language the site needs, through the normal content translation UI —
/admin/structure/menu/manage/<menu_name> → Translate on each link — and
delete the other languages' now-redundant menu copies once their content is
confirmed to be folded into the kept menu's translations.
A site that already had drupal_kit enabled
config/install/drupal_kit.menu_locations.yml only runs on a fresh
drush en drupal_kit — a site that had the module enabled before this
feature shipped never gets the config object through that path, and
drush updb alone reports nothing pending for it (there is no schema
change). drupal_kit_post_update_menu_locations() is the fix: it runs on
the next drush updb on any site, and creates drupal_kit.menu_locations
(as an empty locations: {}) if, and only if, it does not already exist —
a fresh install, or a site that already ran this update, gets
SKIP-EXISTS and nothing changes. It uses ConfigApplier against the
module's own config/install directory, the same hook_post_update_NAME()
pattern documented above.
Both the service and the form already tolerate the config object being
entirely absent (not just empty) without this update — every read goes
through ?? [] / ?? NULL, and Drupal's config system itself returns an
empty Config object for a name nothing has saved yet, never NULL — so a
site that has not run drush updb yet sees exactly the same behavior as
one that has: every slot resolves to "nothing assigned".
Pulled automatically by Composer when you install:
drupal/components— Twig component discovery (@component/namespace).drupal/config_pages— single-instance config entities for site-wide content.drupal/extra_field— extra field display plugins on entities.drupal/twig_real_content— Twig filter to extract plain text from render arrays.drupal/twig_tweak— collection of helpful Twig extensions.parisek/twig-typography— upstream typography filter (powers|typography).
dataLayer
One thin layer that replaces the custom_datalayer module each site used to carry. It pushes the objects a tag manager reads onto window.dataLayer. It ships off: nothing is sent until a site turns the flag on.
# config/sync/drupal_kit.feature_flags.yml datalayer: true
With the flag on and every setting at its default, the layer sends one thing: generate_lead after a webform submission. A site adds the rest one switch at a time in drupal_kit.datalayer:
| Setting | Default | What it adds |
|---|---|---|
status_codes |
false |
{statusCode: 403} or {statusCode: 404} on the error pages |
page_context |
false |
drupalSettings.drupal_kit.datalayer.page: type, name, id of the current node |
click_events |
false |
A click on an element with a gtm-event-* class pushes an event |
lead_form_data |
all |
Which submitted values the lead carries: all, none, or keys |
lead_form_keys |
[] |
The submission keys sent when lead_form_data is keys |
The lead
A completed webform submission becomes:
{event: 'generate_lead', form_type: '<webform id>', form_data: {…}}
It reaches the page two ways. A webform that redirects carries ?token= to its confirmation page, and the layer builds the push there. A webform that confirms inline gets the push in its AJAX response, and js/datalayer.js applies it. Both are built by one call, so the event name, the form type and the values are decided in one place.
form_data carries the values the visitor typed: names, e-mail addresses, phone numbers, messages. They go to the tag manager and on to every tag in the container. all keeps a container that reads them working. Set none, or keys with a list, to send only what the tags use.
Click events
With click_events on, a click inside an element with a gtm-event-foo-bar class pushes {event: 'foo_bar'}. Each data-gtm-* attribute becomes a key (data-gtm-item-id="7" becomes item_id: "7") and overrides the page context.
<a href="/pdf" class="gtm-event-download" data-gtm-file-name="brochure">Download</a>
Adding your own pushes
Do not edit the layer. Subscribe to one of two events.
DataLayerEvents::COLLECT runs on every page build. Add a push, add page context, and say what the push depends on:
public function onCollect(DataLayerCollectEvent $event): void { $node = $event->routeMatch()->getParameter('node'); if ($node instanceof NodeInterface && $node->bundle() === 'product') { $event->add(['event' => 'view_item', 'item_id' => $node->id()]); $event->cacheability()->addCacheableDependency($node); } }
The page is cached. A push that reads the session, a cookie or a query argument has to declare it through $event->cacheability(), or a cached page shows it to the wrong visitor or not at all. This is why the layer has no "push on the next page" helper: a flag in the session cannot be delivered correctly through a cached page, and each site that needs one has to decide what to do about that.
DataLayerEvents::LEAD runs once for each completed submission. Rename the event for a container that expects an old name, change the form type or the data, or suppress the push:
public function onLead(DataLayerLeadEvent $event): void { if ($event->webformId() === 'newsletter') { $event->setEvent('newsletterSubmitted'); } }
The values a LEAD subscriber sees are already filtered by lead_form_data. The event also carries the submission, for a subscriber that needs more than the values the site chose to send. A site that sends no values can add back one field:
public function onLead(DataLayerLeadEvent $event): void { $event->setFormData(['topic' => $event->submission()?->getElementData('topic')]); }
The submission holds every value, so a subscriber that reads from it decides for itself what leaves the site.
On the redirect path the page that carries the lead is cached. The layer already makes it depend on the submission, its webform and its source entity. A subscriber that reads anything else adds a tag through $event->cacheability(), as a COLLECT subscriber does.
Migrating from a site-local custom_datalayer
- Turn the flag on and set the switches so the output matches what the site sends today. A site that sends only the lead changes nothing but the flag.
- Move the site's own events into a subscriber.
- Uninstall
custom_datalayer, then delete its code. While that module is installed, this layer sends nothing and the status report shows a warning, so the two never send every lead twice.
Drupal cannot uninstall a module whose code is gone. Uninstall it in a deploy that still has the code, or in the same deploy before the code is removed.
Optional integrations
The following modules are optional. When present, EntityHelper automatically exposes additional fields and renderers; when absent, those code paths gracefully no-op.
Contrib (install via Composer):
drupal/commerce—commerce_productentity support.drupal/office_hours—office_hoursfield rendering.
Drupal core (enable via drush en …):
comment— comment entity support (comment_bodyfield on Comment entities).
Drupal core patches (apply via cweagans/composer-patches):
- drupal.org#2466553 — adds
menu.language_tree_manipulatorto Drupal core. When applied,EntityHelper::getMenu()filters menu links by the current content language. When absent, the filter step is silently skipped and menu items for all languages appear — on multilingual sites the status report (/admin/reports/status) shows a warning so the gap is visible.
Local development
Local environment is DDEV — pinned to PHP 8.3 in .ddev/config.yaml so it matches the production deploy target and CI. The database container is omitted; kernel tests use sqlite in-memory.
ddev start ddev composer install ddev exec scripts/dev-link-module.sh # symlink module into web/modules/contrib + bridge web/autoload.php ddev exec vendor/bin/phpunit
scripts/dev-link-module.sh resolves paths relative to where it runs, so it must be invoked inside the container — otherwise the symlinks point to host paths the container can't see.
Tests
Tests are self-contained — Composer scaffolds Drupal via installer-paths; PHPUnit bootstraps from web/core/tests/bootstrap.php.
ddev exec vendor/bin/phpunit --testsuite unit ddev exec vendor/bin/phpunit --testsuite kernel
Coverage
ddev coverage is a custom command (defined in .ddev/commands/web/coverage) that runs PHPUnit with xdebug.mode=coverage and emits both clover XML and a textual summary:
ddev coverage
ddev coverage --filter ResizerTest # any phpunit args pass through
CI uses the same flags so local + CI numbers stay aligned.
Step-debugging
ddev xdebug on # loads xdebug in debug mode; listen on host port 9003 ddev xdebug off # debug mode is a heavy perf hit; keep it off by default
See CONTRIBUTING.md for how to add tests and the unit-vs-kernel decision tree.
Without DDEV
DDEV is the canonical local environment, but the repo doesn't hard-depend on it — CI runs vanilla composer install + vendor/bin/phpunit against PHP 8.3 from the shivammathur/setup-php GitHub Action. If you prefer host-PHP, ensure you're on PHP 8.3 (matching CI / production) to avoid composer.lock drift.
Releasing
Tag-driven; published on Packagist, which syncs tags automatically via the GitHub webhook. Version bumps follow Conventional Commits, the public-API surface and deprecation lifecycle are defined in RELEASING.md — read it before tagging.
Distribution scope: composer require ships only the module files, src/, templates/, composer.json, LICENSE and README.md — everything development-only is export-ignored in .gitattributes.
Related projects
Part of the PORTA ecosystem:
parisek/timber-kit— the WordPress counterpart: shared Timber/ACF infrastructure for PORTA themes.parisek/twig-typography— framework-agnostic typography Twig extension that powers our|typographyfilter.
License
GPL-2.0-or-later. See LICENSE.