How I upgraded a Drupal 8 Angular module to Drupal 10 with @angular/elements and PDB 3.x
Authored on
A few years back I built a small Drupal 8 module to render Angular components in entity view displays using the Page Decoupled Blocks (PDB) framework. It worked. It shipped on a client site. And then it sat in a sandbox on Drupal.org and collected dust — the way a lot of contrib ideas do.
Fast forward to 2026. I decided it was worth finishing properly. Angular 2 is long dead, Drupal 10 dropped a bunch of APIs I was still using, and PDB had moved to v3.x with a completely different component discovery model. The module was essentially a historical artifact at that point. So instead of patching it, I started fresh.
This is the technical write-up of what that actually involved — the architecture decisions, the live-test bugs, the schema rabbit holes, and what ended up shipping as pdb_angular_entity 1.0.0 on Drupal.org.
Why start fresh instead of patching the sandbox
The original module was called ng2_entity — named after Angular 2, which tells you how long it had been sitting. It used drupal_set_message(), drupal_get_path(), and core: 8.x — three things Drupal 10 removed entirely. The Angular side was bootstrapped via SystemJS and a scroll-loader pattern from the pdb_ng2 era, which was itself still a sandbox with no Drupal 10 path.
Patching that would have meant untangling old assumptions at every layer. Starting fresh meant I could make the right decisions upfront: drop the version-specific naming, target PDB 3.x directly, and use @angular/elements the way Angular actually intends you to use it in 2026.
The legacy sandbox (drupal.org/sandbox/keboca/2755391) stays as historical reference. The successor is pdb_angular_entity.
The core architecture decision: @angular/elements + shared runtime
The key question was how Angular components get loaded on a Drupal page. You have a few options and they all have tradeoffs:
- One giant bundle — every component in one JS file. Simple, but you pay for every component on every page whether it's rendered or not.
- Fully self-contained bundle per component — each component ships its own copy of Angular. Works fine for one component. Two components on the same page means two Angular runtimes loading. That's not a real option.
- Shared runtime + per-component entries — the Angular runtime loads once, each component ships a small entry that registers its custom element, and Drupal's library system handles deduplication automatically.
The third option is what we went with. PDB's hook_library_info_build() already merges a pdb_angular/angular presentation library into every component footer — we defined a pdb.angular.runtime library in pdb_angular_entity.libraries.yml and listed pdb_angular_entity/angular as a dependency of it. Each PDB component then lists pdb_angular_entity/pdb.angular.runtime as its dependency. Drupal deduplicates by library name, so the shared runtime loads exactly once per page regardless of how many Angular components are on it.
The Angular workspace lives in ng_component/workspace/ on Angular 21. Each component gets its own build target in angular.json, with outputHashing: none so the filenames stay predictable — Drupal can't reference main.abc123.js from a static library definition.
How entity fields get into Angular
The data handoff pattern is straightforward: PHP resolves entity field values server-side, and they land in drupalSettings.pdb.angular[instanceId].properties. Angular reads them in ngOnInit() via @Input() bindings.
The instance ID is a UUID generated per render — the custom element in the DOM gets it as its id attribute (pdb-angular-{uuid}), and the runtime shim in js/pdb-angular-runtime.js uses customElements.whenDefined() to forward the right drupalSettings slice to the right element once Angular bootstraps it.
Component declaration in the .info.yml file looks like this:
name: Article Component
type: pdb
presentation: angular
add_js:
footer:
dependencies:
- pdb_angular_entity/pdb.angular.runtime
js:
js/article-component.js:
attributes:
type: module
properties:
- heading: title
- body: body:value
- imageUrl: field_image:file:urlThe presentation: angular key is what PDB uses to distinguish Angular components from React, Vue, or anything else. The properties list maps Angular input names to Drupal field expressions — body:value uses a chained accessor to get the processed text out of the body field, and field_image:file:url chains through a file entity to its URL.
The Angular side: createApplication() and createCustomElement()
The main.ts entry point for each component does two things: creates an Angular application and registers the component as a native Custom Element:
import { createApplication } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { ArticleComponent } from './article.component';
(async () => {
if (!customElements.get('article-component')) {
const app = await createApplication();
const element = createCustomElement(ArticleComponent, { injector: app.injector });
customElements.define('article-component', element);
}
})();The double-registration guard (if (!customElements.get(...))) matters — if the runtime script loads twice for any reason, you don't want customElements.define() throwing a NotSupportedError.
The component itself is a standard Angular standalone component. It reads drupalSettings in ngOnInit() using the instance ID from its own element — and that's where the ElementRef comes in:
private elementRef = inject(ElementRef);
ngOnInit() {
const id = this.elementRef.nativeElement.id;
const config = (window as any).drupalSettings?.pdb?.angular?.[id];
this.heading = config?.properties?.heading ?? '';
this.body = config?.properties?.body ?? '';
}An earlier version used (this as any)._elementRef to reach into Angular internals. That worked until it didn't. inject(ElementRef) is the right way to do it.
The bugs that showed up on first live test
I set up the module on KEBOCA using a symlink into modules/custom/, ran npm run build:article-component, enabled it in Drupal, and hit five separate bugs before the component actually rendered. In order:
1. Missing TypedConfigManagerInterface
Drupal 10/11 changed ConfigFormBase::__construct() to require a TypedConfigManagerInterface as a second argument. My form class was constructed without it — fatal error on the settings page. Fix: add the argument, pass it to parent::__construct(), inject config.typed in create().
2. Unexpected token 'export'
Angular CLI compiles to ES module format — the output uses export statements. Drupal was loading the script as a classic script, and classic scripts don't understand export. The browser threw a syntax error and nothing loaded.
Fix: add attributes: { type: module } to the JS entry in the component's .info.yml. That's the line in the example above — easy to miss if you're not thinking about it upfront.
3. body: "[value]"
The field mapping syntax body:value is supposed to resolve to the body field's processed text via chained property access. Instead, the body was coming through as the literal string [value].
What was happening: getFieldValue() was running token resolution before chained field access. The Drupal token system got handed body:value, found no matching token, and returned the string [value] as an unresolvable token placeholder. Reordering the logic — chained access first, token resolution as fallback — fixed it. $entity->body->value now resolves correctly before the token system ever gets involved.
4. Missing config schema for third-party settings
Drupal logged a schema warning when saving the view display: core.entity_view_display.node.article.angular_component: third_party_settings.pdb_angular_entity missing schema. The fix was adding the mapping to config/schema/pdb_angular_entity.schema.yml:
core.entity_view_display.*.*.*.third_party.pdb_angular_entity:
type: mapping
label: 'PDB Angular Entity view display settings'
mapping:
component:
type: string
label: 'Component'5. ElementRef hack
Already covered above — replaced the private internals hack with proper inject(ElementRef).
After all five fixes: <article-component id="pdb-angular-{uuid}"> in the DOM, drupalSettings populated correctly, Angular 21.2.5 bootstrapped, component rendered with the article's heading and body content. Full round-trip confirmed.
Naming: two wrong names before the right one
The module went through two renames before landing on pdb_angular_entity. It started as pdb_angular — clean name, obvious PDB membership. Then I went to create the Drupal.org project and found pdb_angular was already taken by an abandoned project with no D10 path and a different maintainer. Renamed to pdb_angular_elements to reflect the @angular/elements approach. That felt too implementation-specific. Final rename to pdb_angular_entity — it describes what the module actually does: Angular components in Drupal entity view displays.
Two renames across 12 commits is not fun, but it was the right call. The machine name is permanent once the module is on Drupal.org.
Phase 4: the block plugin
The initial release covered entity view displays — you pick an Angular component from the Manage Display tab on a content type and it renders in that view mode. But PDB components should also be placeable as Drupal blocks, without needing entity context. That's what Phase 4 added.
PdbAngularBlockDeriver discovers every presentation: angular component via PDB's component discovery and derives one block plugin definition per component. PdbAngularBlock extends PDB's own PdbBlock, attaches pdb_angular_entity/pdb.angular.runtime, and writes the per-instance settings into drupalSettings the same way the entity display path does — so the Angular component doesn't need to know whether it was placed as a block or rendered in a view mode.
The schema work for the block plugin took three rounds to get right:
block.settings.pdb_angular_entity_component→block.settings.pdb_angular_entity_component:*— Drupal resolves derived block plugin IDs using the:*wildcard, not the bare base plugin ID. This is not documented anywhere obvious.pdb_configuration: type: sequence→type: ignore— PDB storespdb_configuration: nullwhen a component defines noconfiguration:key. Asequencetype can't benull.type: ignoreis null-safe and handles any future array structure.parent::build()before reading the UUID — PDB generates$this->configuration['uuid']insidePdbBlock::build()at line 62, not in the constructor. We were reading it before callingparent::build()and getting "Undefined array key uuid". Reordering fixed it.
The block plugin is on the 1.x-dev branch. The 1.0.0 stable release is entity display only.
The pdb_angular missing extension warning
PDB's hook_library_info_build() hardcodes pdb_angular/angular as a library dependency for every presentation: angular component — a remnant from when pdb_angular was the expected submodule name. Since that module doesn't exist on sites running only pdb_angular_entity, Drupal logs a missing extension warning on every page with an Angular component.
The fix is a hook_library_info_alter() in pdb_angular_entity.module that strips the orphaned dependency — but only when pdb_angular is not installed:
function pdb_angular_entity_library_info_alter(array &$libraries, string $extension): void {
if ($extension !== 'pdb') {
return;
}
if (\Drupal::moduleHandler()->moduleExists('pdb_angular')) {
return;
}
foreach ($libraries as &$library) {
if (!empty($library['dependencies'])) {
$library['dependencies'] = array_values(array_filter(
$library['dependencies'],
fn(string $dep): bool => $dep !== 'pdb_angular/angular'
));
}
}
}Sites that legitimately have pdb_angular installed are unaffected.
Getting it on Drupal.org
Creating the project page was straightforward. Getting the code there was not — port 22 to git.drupalcode.org timed out on two different networks. Drupal.org doesn't offer SSH over port 443 the way GitHub does, and altssh.drupalcode.org doesn't exist. The solution was HTTPS with a Personal Access Token scoped to write_repository, cached via osxkeychain.
One other thing worth knowing: Cursor's integrated terminal intercepts HTTPS git credentials before they reach osxkeychain. Push from Terminal.app.
The module is available at drupal.org/project/pdb_angular_entity. Install via Composer:
composer require drupal/pdb_angular_entityCoding standards: phpcs --standard=Drupal passes with 0 errors. The 1.x-dev branch has the block plugin if you want to test it early.
What's next
The module works as a standalone contrib, but the goal is to have it recognized as part of the PDB ecosystem — that conversation with the community is already open.
On the AI side, I've opened an RFC on the issue queue for a sub-module called pdb_angular_entity_ai. The idea is to use Angular's resource() API and signals-based reactivity to build AI-powered components that stream LLM responses directly into Drupal entity displays — with a PHP controller acting as a proxy to keep API keys server-side. The existing drupalSettings bridge already handles the Drupal-to-Angular data handoff; the same mechanism can carry prompt context and per-instance AI configuration to components that use resource() for the actual LLM calls.
There are open questions — whether to integrate with the Drupal AI module for provider abstraction, whether Genkit fits the contrib context, and how Angular's resource() handles SSE streaming in practice. That's what the RFC is for. If you have opinions on any of it, the issue queue is the right place.
If you're running PDB 3.x and Angular in Drupal, give it a try. And if you run into anything, the issue queue is open.
Happy coding!