For the Drupal-curious friend

~/articles/drupal-curious-friend
Animated share card for For the Drupal-curious friend

A friend was giving me UI and UX notes on a web app I've been building. The app exists because of a specific irritation: someone sends you a DJ mix as a Google Drive link, you listen to the whole thing, and then you want to tell them the transition at 34:12 is doing something you don't understand. There's nowhere good to say that.

Drive will let you comment on the file. The thing I want to comment on is a moment. Those are different nouns, and the gap between them is the entire product.

Partway through the notes my friend said "I've never worked in Drupal." No complaint in it, just curiosity. So here's the whole thing, written down.

That's half of why this exists. The other half is a talk I saw at a DrupalCamp recently, given by someone who opened by saying they weren't a coder, about a thing they'd built that I've been bringing up ever since.

You don't need to write code to read this. There is code in it, because I'm not going to describe a for loop in prose, but every block has a sentence before it saying what it does. Skip them and you lose detail, not the argument.

One housekeeping note, because unexplained vocabulary is how documentation lies by omission: I define the jargon the first time it shows up. If I miss one, that's a defect, not a test.

Start with the name, because it tells you something

Drupal is Dutch. Druppel means "drop." The site was going to be dorp.org — dorp, "village" — and Dries Buytaert typo'd it as drop.org, liked it, and kept it. The platform a good chunk of the Fortune 500 runs on is named after a misspelling that stuck.

I bring this up because it's the first of about forty naming decisions you're going to have to accept without argument, and it's better to find that funny early.

What it actually is

Drupal is a web application framework with a content modeler bolted to the front. The website is a side effect.

Underneath is Symfony, a PHP toolkit developers use to build custom applications. On top sits a system for describing what kinds of things your site holds. Pages, menus, logins, an editing screen — those fall out of the description. You don't build them.

That changes the job. In most website tools the unit of work is a page. Here the unit of work is a description of your stuff, plus rules about who gets to touch which parts of it. Ask "how do I build this page" and you'll fight the thing for a week. Ask "what are the things, what do I know about each one, who edits them" and it starts doing your work for you.

Three layers. It's a simplification — I'll say where at the end — but it's the one to start from:

  • Entity type — the broad categories. Content, User, Media, Taxonomy. Things the system knows how to store, version, permission-check, and display.
  • Bundle — a variety within a category, with its own fields. article and case study are both Content.
  • Field — one typed piece of information. A date. A link to a User. Repeatable, translatable.

Define a bundle with a few fields and you get, without writing anything: an edit form that checks its own input, full version history with rollback, a permissions grid, a URL, search indexing, translation slots, and a machine-readable feed. That paragraph is the pitch. The rest is detail and complaining.

A digression about the words, which you may skip if you don't care about words

You'll meet node, meaning a piece of content. Not a node in the graph-theory sense. Not Node.js. One syllable, two collisions.

Taxonomy comes from taxis, arrangement, and nomos, law. Drupal uses it to mean tags. That's a lot of Greek for a list of words.

Entity is what you name things when naming has defeated you.

Views is neither a database view nor a page view.

And a warning with practical consequences: Drupal has a feature called Recipes. If you also build a content type called Recipe, you'll confuse yourself for a year. Pick a different example site than a cookbook. I'm using record collections below, partly for this reason.

Three built-in things that carry more weight than they look like they do

Views answers "show me these things, filtered this way, sorted that way" without anyone touching a database. You build it by clicking, then choose where it comes out: a page, a sidebar box, a feed, a data endpoint. One definition, several outputs. This is why Drupal projects contain less custom code than their size suggests.

Your site's setup lives in text files, not in the database. Content types, fields, listings, roles, permissions, image sizes, approval workflows — all of it exports as readable files you commit to version control and apply somewhere else. Structure travels through those files. Content stays put.

$ drush config:export        # setup -> config/sync/*.yml
$ git commit -am "Add case study content type"
$ drush config:import        # on production, apply exactly that

Learning which of your things count as structure and which count as content is the load-bearing idea here. Drupal keeps two families for exactly this: config entities for the structure — a content type and a saved listing are each one — and content entities for the things people write. It's also where the ugly edges are: menu links and tags sit right on the line, and reasonable people argue about them.

JSON:API is built in and needs no setup. Turn it on and anything — a phone, a JavaScript front end, a script, a spreadsheet — can ask your site for content in a standard shape, under the same permissions the website uses. That's the whole "headless" story. Which, while we're here, is a bad name: the CMS keeps its head. You just stop looking at it.

Most Drupal advice online is wrong, and here's how to spot it

Drupal 7 shipped in 2011 and was the end of the old architecture. Drupal 8 shipped in 2015 as a rewrite onto Symfony, and it broke compatibility completely. Drupal 7 sites were rebuilt, not upgraded.

That decision explains the platform's whole shape. It cost Drupal the hobbyist market — overall share of websites went from about 5.5% in 2014 to about 1% now, while WordPress consolidated around 60% — and it won the large-organization market, around 4.7% of the top 5,000 enterprise domains. Pfizer, Cisco, Lufthansa, the UN, GovCMS, most large universities.

It also means ten years of forum answers describe software that no longer exists. Filters:

  • Check the date. Before 2016, different product. Close the tab.
  • Code tells: hook_menu, drupal_set_message(), db_query(), .info files, variable_get(), jQuery.once(). Also anyone telling you to install "the Media module" or "the JSON:API module." Both have been part of core for years. That advice is a tell, not a suggestion.
  • Patches are dead. Core patch testing was switched off on July 1, 2024. Contribution happens through merge requests on GitLab. Instructions to download a .patch file and apply it are describing 2019.

Since 2015 it's been semantic versioning on a schedule: a minor release every six months that adds things, a major every two years that removes what it warned you about. So 9 to 10 to 11 to 12 is housekeeping, much of it automated by a tool called Rector. When someone tells you Drupal upgrades mean rebuilding the site, they're quoting a decision from eleven years ago and haven't checked since.

Where it stands, August 2026

  • 11.4 — current stable, 11.4.5, out 2026-08-06. Start here.
  • 12.0 — not out. Targeted for the week of 2026-12-07. It has slipped once already.
  • 10.6 — last of the 10 line. Support ends 2026-12-09. If you've inherited a 10 site, that date is the conversation, and it's closer than it looks.
  • 7 — dead since January 2025. Paid life support exists. Finding one now is archaeology.

The next list is for developers. Everyone else: Drupal replaced most of its internal patterns in the last three years, so if code you find looks nothing like mine, it's old. Skip ahead.

  • Hooks are classes. #[Hook('node_presave')] on a method in src/Hook/, since 11.1. No .module file, no service registration. They autowire.
  • Plugins use attributes, not annotations, since 10.2.
  • Constructor injection. \Drupal::service('foo') inside a class is a smell.
  • Single Directory Components, stable in 10.3. One folder per component, with a prop schema that's validated rather than aspirational.
  • Recipes, stable in 11.1. A folder of config plus recipe.yml, applied to an existing site. Replaces install profiles and the old Features module. Not idempotent by default and not tracked as installed — one-shot scaffolding, not deployment. Learn that on day one rather than day forty.
  • Drupal CMS is a separate product on top of core. 1.0 in January 2025, 2.0 in January 2026. Core is the framework; Drupal CMS is the opinionated assembly of it, shipping the Gin admin theme and Drupal Canvas (drag-and-drop editing, renamed from Experience Builder). It exists to win back the site builders the 2015 rewrite lost.

Thirty minutes to a running site

You need Docker, which runs a small self-contained Linux machine on your computer so you don't hand-install PHP and MySQL like it's 2006. DDEV handles the Drupal part. These lines make a folder, tell DDEV what it is, start it, fetch Drupal, and open it:

$ mkdir drupal-curious && cd drupal-curious
$ ddev config --project-type=drupal11 --docroot=web
$ ddev start
$ ddev composer create-project drupal/cms
$ ddev launch

Use the browser installer this first time. Drupal CMS's installer is where you pick prebuilt pieces — blog, events, SEO, cookie consent — and watching a site assemble itself from them explains recipes faster than I can.

For plain core with nothing decided for you, swap one line:

$ ddev composer create-project drupal/recommended-project
$ ddev drush site:install -y && ddev drush uli

Thirty minutes assumes Docker already works. If it doesn't, that's the afternoon, and it isn't Drupal's fault.

The first hour

Four things, in this order. Each one shows a different quarter of the system.

  1. Describe something. Structure → Content types → Add. A "field note" with a date, a tag, an image. Make three. Notice what showed up unrequested: version history, an edit form, permissions, a URL pattern.
  2. List it. Structure → Views → Add. A page of field notes, filtered by tag, sorted by date, paged. Then add a second output of the same listing as a sidebar box. One definition, two places, no code.
  3. Get a feed. Turn on JSON:API, visit /jsonapi/node/field_note?filter[status]=1&page[limit]=3. Nobody wrote that. It obeys the permissions from step 1.
  4. Write code. Developers only. A folder at web/modules/custom/field_notes/, a short field_notes.info.yml, and one class. This says: before a field note saves, if the species box is empty, keep it unpublished.
<?php

declare(strict_types=1);

namespace Drupal\field_notes\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;

/**
 * Hook implementations for the Field Notes module.
 */
final class FieldNotesHooks {

  #[Hook('node_presave')]
  public function nodePresave(NodeInterface $node): void {
    if ($node->bundle() === 'field_note' && $node->get('field_species')->isEmpty()) {
      $node->setUnpublished();
    }
  }

}

That's the entire registration story on 11.1 and up. Drop it in, clear cache, it fires. Need a logger? Add it as a constructor argument and it's handed to you.

The example I keep bringing up

At a DrupalCamp recently I watched a talk by someone who opened by saying they weren't a developer. What they'd built was an app for their county: a catalog of every species of plant and tree in it, classified, searchable, and watching for invasive ones — which is the part with something at stake. They'd added QR codes and PDF report export, because the people using it work outdoors and have to hand paper to somebody afterwards.

I don't know how they built it, and that's fine, because from the outside the shape is legible. A species is one thing and finding one growing in a particular spot is another — the same release-versus-copy split as the project below. The classification is taxonomy, which here is finally the literal correct use of the word. Browse and filter is Views. Invasive-species watch is a filtered listing that can email someone. QR codes work because every record already has a URL nobody had to invent. PDF export is a contrib module maintained by strangers. None of that is programming. All of it is describing.

That's the case I bring up most when someone asks what Drupal is for, and it isn't the enterprise logos. Someone had a problem — a county's worth of plants, some of which shouldn't be there — and the distance between having the idea and having the thing was short enough that a self-declared non-coder walked it. You mostly need the problem. The endless part is the use cases, not the learning curve.

Two projects worth more than a tutorial

Tutorials build a blog. You know what a blog is. These two land on the parts of Drupal with no obvious equivalent elsewhere. The first needs no programming and is the warm-up. The second is the app from the top of this post.

Catalog your records

Start here, and the reason shows up in the first ten minutes.

A release is not a copy. Selected Ambient Works 85–92, Apollo, 1992, is a release. The scuffed 2008 repress on my shelf with the split sleeve that I paid fourteen dollars for at Dusty Groove is a copy. Title, artist, year, tracklist belong to the release. Condition, pressing, price, where and when, and who has it right now belong to the copy. You can own three copies of one release.

Squash those into one spreadsheet row and you'll regret it in year two. I know because I did it in a spreadsheet first. Describing it properly takes four minutes:

  • release — a content type: title, year, tracklist, sleeve image. The work.
  • copy — a content type pointing at a release. The object.
  • Artist, Label — tag lists. Renameable, reusable, each gets a listing page free.
  • Format, condition — dropdowns with fixed options. Can't be mistyped, easy to filter.
  • Tracklist — a repeating group of position, title, duration.

Everything after that is dividend, and none of it is code. Views hands you browse-by-label, everything-from-1994, records-I-haven't-played-in-a-year, what-I-spent-last-year, and a wantlist, at about ninety seconds each. Media handles sleeve scans and thumbnails. Permissions let the collection be public while the prices stay private, which here is a checkbox.

Then a few things pull you into code, in a sensible order.

Getting existing data in. You have a Discogs export or a shelf. Drupal's built-in Migrate system, plus migrate_plus, migrate_tools, and migrate_source_csv, turns a spreadsheet into real records declaratively. Re-runnable, and reversible when you get it wrong, which you will. It's the least glamorous good thing in Drupal.

The Discogs lookup. A small module: take a catalog number, ask the Discogs API — an API being a way for one program to ask another program questions — and fill in the release. Eighty lines or so. That's the shape of most custom Drupal work: a little logic on top of a structure you didn't have to build.

Then the part that makes people get it: the feed is already on, so from your phone in a record shop, /jsonapi/node/release?filter[field_catno]=AMB3922 answers "do I already own this?" No app, no server, no login system.

Scanning barcodes, and why it might not be for you

Typing catalog numbers into a phone in a record shop is a thing you do twice before quietly abandoning the project. Point the camera at the barcode instead.

Caveat first, because it decides whether to build this at all: most vinyl has no barcode. They only became common on records through the 1980s. A 2019 reissue scans. A 1968 Blue Note has nothing to scan, and you're back to the catalog number and the matrix scratched in the run-out. Mostly originals? Skip this. Mostly reissues? This is the difference between a catalog you keep and one you abandon.

The scanning is browser work and there's less of it than you'd expect. Chrome and Edge on Android — which is the phone-in-a-record-shop case — ship a native BarcodeDetector. Point it at the rear camera and it returns digits. Safari, Firefox, and most desktop browsers don't have it, so you fall back to a WebAssembly ZXing build — a few hundred kilobytes, works everywhere. Two requirements that will eat your afternoon: the page has to be HTTPS for camera access, and permission is per-visit on some platforms.

The browser half, roughly:

const stream = await navigator.mediaDevices.getUserMedia({
  video: { facingMode: 'environment' },
});
const detector = new BarcodeDetector({ formats: ['ean_13', 'upc_a', 'upc_e'] });

const codes = await detector.detect(videoEl);
if (codes.length) {
  lookup(codes[0].rawValue);   // -> your Drupal route
}

Now the trap, which is a naming problem wearing a data problem's clothes. A UPC-A is an EAN-13 with a leading zero. Same number, two names, twelve digits or thirteen depending on who's counting. Scanners report it both ways. Discogs stores barcodes inconsistently — with spaces, with the zero, without it. So normalize: strip everything that isn't a digit, then search both the twelve- and thirteen-digit forms. Skip that and about half your scans miss silently, which is a miserable bug to chase because nothing appears to be broken.

The Drupal side needs no new machinery. Discogs searches by barcode (/database/search?barcode=…), so the route you wrote for catalog numbers grows another entry point. What it does with the answer is where the data model pays out a second time:

  • Release already in the catalog? Create nothing. Say "you own two copies of this," offer a third. That's the sentence you actually wanted in the shop, and it's only available because release and copy were separate from the start.
  • Unknown release? Create it from the Discogs data, then create the copy pointing at it.
  • Several matches? Show them and let the person pick. One barcode covers multiple pressings routinely. Guessing quietly is how a catalog fills up with wrong data.

Two practical notes. Discogs wants a token and allows sixty requests a minute, so put the token in settings.php, not in exported config — secrets don't belong in committed files. And if you're scanning a whole shelf in one sitting, don't look up live: collect the codes, hand the list to Drupal's queue system, and let it grind through them while you keep scanning.

In Drupal terms that's a field widget with a scan button, one lookup route, and a queue worker. Call it a weekend, most of it spent on the barcode normalizing rather than on anything Drupal invented.

The one from the top: a mix you can actually talk about

Back to the Google Drive problem. A mix arrives as a link, you listen to it, and the only place to put your reaction is a chat window, where you type "34:12 !!!" and hope the other person scrubs to it. The unit is wrong. You want to attach a remark to a point in time, and every tool in that chain attaches remarks to files.

So: a mix gets a page, the page has a waveform, and comments hang off moments in the audio instead of off the bottom of the post.

The related work of mine here is IronChefOfMusic, a Drupal 11 remix community site, mid-relaunch, with a custom remix_blocks module handling audio. The waveform work is still ahead of me, not behind. Treat what follows as a design, not a build log.

The naive version is a post with a play button, and you don't need Drupal for that. The version worth building turns on one decision: the tracklist is structured data, not a paragraph typed into the body.

A "mix" gets an audio file, a date, a venue, and a repeating tracklist where each row is a timestamp plus artist, title, label. That decision cascades:

  • Timestamps become markers on the waveform. Click a track, the playhead moves. Player and tracklist are the same data, so they can't disagree.
  • You can ask "every mix with a track on this label" or "which artists do I play most." A paragraph of text can't answer that, no matter how neatly you formatted it.
  • Track rows can point at the release records from the previous project. That's where this stops being a website and becomes a database of your taste.

The player is a Single Directory Component, which is a long name for "one folder holding everything this thing needs":

components/mix-player/
├── mix-player.component.yml    # what it accepts, and this is checked
├── mix-player.twig
├── mix-player.css
└── mix-player.js

The first file is a contract. It says the player takes an audio file, optional precomputed waveform data, and cue points, and Drupal validates that, so handing the component to a non-developer is safe:

# mix-player.component.yml
name: Mix player
props:
  type: object
  required: [src]
  properties:
    src:
      type: string
      title: Audio file URL
    peaks:
      type: array
      title: Precomputed waveform peaks
    cues:
      type: array
      title: Track cue points
      items:
        type: object
        properties:
          offset: { type: integer }
          label: { type: string }

The JavaScript follows Drupal's one real convention: find every player that isn't initialized yet, and initialize it. The "not yet" matters, because Drupal re-runs this when new content arrives on the page, and starting the same player twice is a bad afternoon.

// once() comes from core/once, declared as a library dependency.
Drupal.behaviors.mixPlayer = {
  attach(context) {
    once('mix-player', '[data-mix-player]', context).forEach((el) => {
      const ws = WaveSurfer.create({
        container: el,
        url: el.dataset.src,
        peaks: JSON.parse(el.dataset.peaks || 'null'),
      });
      // cues -> markers -> click to seek
    });
  },
};

Two pieces of actual engineering in here.

Draw the waveform ahead of time. Hand a browser a two-hour mix and it downloads and decodes the whole file to draw a squiggle. Work the squiggle out on the server at upload time, on a queue so the person uploading isn't staring at a spinner, and store it on the post.

Comments pinned to a moment. The whole reason the app exists, and in Drupal it's one extra field on a comment: a number of seconds. Threading, replies, moderation, spam handling, permissions, the form, and who's allowed to see whose remarks — all of that already exists and already works. Anywhere else this is a new database table, an API, and a user interface. Here it's a field and a template tweak.

That's the clearest answer I have to "what do I get for putting up with the vocabulary." The feature I actually care about was the cheap part, because the boring infrastructure underneath it was already built by other people.

And the honest half of this, given how the conversation started: none of it is UI or UX. Drupal handed me the data model, the comment system, the permissions, and the API. It did not hand me a single good decision about what the thing should look like or how it should feel to use — where the waveform sits, what a comment marker looks like at a glance, what happens on a phone, how you avoid a wall of markers at the drop. My friend was giving me notes on exactly that layer, which is the layer no framework gives you. Drupal removes the excuse of being busy with plumbing. It doesn't do the design.

What will annoy you

The vocabulary is twenty years old and unrepentant. See the digression above. There's no route around it, only a week of friction through it.

Caching, once you write custom code. Build with core's patterns and cache handling comes along for free. Hand-roll a query inside a block and nothing has told Drupal what that block depends on, so it will serve last week's answer and look broken. It isn't broken — it was never told what to watch. Your mix player will do this to you with a stale tracklist.

Theming touches four files to move one thing. Template, preprocess, asset declaration, naming convention. Components fix this and I'd build anything new that way, but any site older than a couple of years has the sprawl.

Contributed modules are not app-store apps. Fewer, larger, longer-lived. Judge one by whether its issue queue has a maintainer answering this year, not by how the project page looks. And check core first: listings, media, layout, revisions, workflows, the text editor, migration, and the data API all ship with it. (The contrib module named "Feeds" is a different thing — importing content from elsewhere — and that one you do install.)

Clicking and committing pull against each other. You can build a lot through admin screens, but anything you click that doesn't make it into those exported files doesn't exist in production. Skip that discipline and you get a site nobody can reproduce.

Why I keep using it

The tour above is features. The reason is duller and holds up better: I stopped rebuilding the same six subsystems, and the replacements are the standard ones.

Things I no longer build, on any project:

  • Accounts, roles, registration, password resets, sessions, flood control.
  • Permissions. "Can this person edit this field, on this content type, while it's awaiting approval" is a hard question. Drupal answers it once and enforces the same answer on the form, in listings, through the feed, and from the command line. Build it yourself and you answer it again per screen, forever, and eventually you answer wrong. That's where the security holes in homegrown systems come from. Not exotic attacks. The eleventh screen.
  • Media. Upload validation, resized versions, a reusable library, focal points, alt text enforcement, right-size-to-right-screen, private files that are actually private. Each one boring. Each one a day of work plus a category of bug.
  • Self-validating forms, version history with rollback, translation slots, scheduled background jobs.

Most mature ecosystems can get you there. Fewer get you the second half: it's the same everywhere. Another Drupal developer opens a site I built five years ago and finds things, because the layout is a shared convention rather than my personal style. That's a maintenance property, not a comfort one. It's what makes handing a site over in year seven ordinary.

For public sites, contributed modules do work I'd otherwise do worse:

  • Search engines — Metatag, Pathauto and Redirect, Simple XML Sitemap. Decent link previews, readable URLs generated for you, and old links that survive a rename.
  • Security — Seckit, Password Policy and TFA, Honeypot or Antibot, Username Enumeration Prevention. Headers, two-factor, and spam blocking that doesn't make visitors identify motorcycles. Closes a column of any audit in an afternoon.
  • Accessibility — Editoria11y. Flags fake headings, "click here," and missing alt text while the author is writing, which is where those problems get made.
  • Cookie consent — Klaro. Required the moment you add analytics or any other non-essential cookies and EU visitors can reach the site, which for a public site means always.

One thing here is different from adding a dependency elsewhere: a volunteer security team issues advisories for covered projects, and coverage shows on the project page. The rule that catches people: an alpha, beta, or release candidate is never covered, even on a covered project. So "is this safe for production" has a checkable answer instead of a feeling, and your site reports when a dependency gets an advisory.

Drupal CMS 2 ships most of that list curated, which is why it's a reasonable default now rather than a beginner ramp.

The incentives are different and you can feel them

I do WordPress work too, so this is a comparison, not a dunk.

A WordPress plugin is often a product. That's a legitimate way to fund software and it carries a product's incentives: a free tier shaped to be not quite enough, upsell banners in the dashboard, features behind a license key, telemetry nobody asked for, and the recurring event where a popular plugin gets acquired and monetized harder. Nobody is behaving badly. The funding model is working as designed. The residue is a site carrying weight nobody chose.

Drupal's modules mostly aren't products. Everything on drupal.org is GPL, there's no paid tier on the project page, and the typical module exists because an agency needed it and pushed it back out. "I needed this" produces different software than "this is my funnel." No nag screens, no gated features, no phoning home by default. The money sits in hosting and services rather than in the gate. That's a statement about where the incentive lives, not about anyone's character.

Same cause, opposite effect, and it's a real cost: nobody is paid to make this pretty. Documentation is uneven, onboarding is charmless, and the module solving your problem may have a project page that looks like 2013. The rough edges and the absent dark patterns come from one source. You don't get to keep only one.

The other half of running lean: Drupal starts small and you add, rather than starting large and removing. A plain install contains what you asked for. And if the front end eventually annoys you, throw it away and keep the content — the feed is already there, and structure, permissions, and workflow keep working behind whatever replaces it. Committing to Drupal is committing to how your content is organized, not to how it looks.

The standards argument got sharper when the AI tools showed up

I use Claude Code daily. This is a working note, not a complaint about robots.

These tools are good at producing something that works. Describe an app, come back after lunch, and it runs. What one person can stand up in a week has moved, and I don't think that's been priced in yet.

The failure isn't correctness. It's idiosyncrasy. Left alone, generated code invents its own approach to everything: its own auth, permission checks sprinkled per screen, its own uploads, its own admin screens, its own naming, its own folder layout. Each decision defensible. Together, a dialect spoken by one codebase and read fluently by the model that wrote it and whoever was in the room.

The bill arrives twice.

Maintenance. Six months later nothing transfers in and nothing transfers out. Every question — where do permissions live, how do I add a field, why is this file here — gets rediscovered by reading.

Adoption, which people underestimate. You can't recruit. Nobody already knows your application, because there's one of it. A contributor's first week goes to conventions that exist nowhere else and teach them nothing reusable. That's a poor offer, and it's much of why open-sourcing a homegrown app rarely produces contributors. Put a module on drupal.org and any Drupal developer reads it on day one, because the layout, the naming, and the plumbing are things they already know.

That's the point of a platform like this: the standards and the community are built in rather than things you'd have to establish. Ratified coding standards, a conventional layout, a security team, a public issue queue, a defined upgrade path, and a lot of people who already know all of it. You inherit an ecosystem's prior agreement by putting the file where everyone looks for it.

Here's the inversion I didn't expect. Constraints make these tools better. A model generating into Drupal has an enormous body of "this is how it's done" to match, and the platform rejects wrong answers mechanically — schema-validated config, real plugin contracts, and phpcs and phpstan stating flatly that the output misses the standard. Generated code can be held to something. Greenfield gives it nothing to be wrong against, which sounds like freedom and behaves like drift.

Two caveats. Models produce stale Drupal enthusiastically — procedural hooks, annotation plugins, all the 2016 patterns from earlier in this post — because that's what a decade of the web trained them on. You need enough knowledge to catch it, and running the checkers isn't optional. And none of this argues against building something custom when the thing isn't a content application. The argument is narrow. It's about year two, and whether anyone can join you.

When it's wrong

Drupal earns its place when several of these hold: content with real structure and relationships; more than a few editors needing different permissions; approval before publication; more than one language; integration with other systems; accessibility or auditability as a requirement rather than a hope; and a maintenance horizon measured in years. That list is what the 2015 rewrite optimized for, which is why the enterprise numbers and the market-share numbers point in opposite directions.

The received answer at the other end — brochure site, one-person blog, eight pages — is that Drupal is overkill. I've given that answer myself, and most of the people still giving it are Drupal users. I think it's aging badly.

Two things moved. Drupal CMS put a curated assembly behind the installer, so a small site no longer starts from an unfurnished framework. And static generation changed what running a Drupal site even means: Tome turns the install into a build tool, content syncs to files in git, and the output is HTML you host free on Cloudflare Pages. The Drupal stays on a private network or a laptop. Nothing faces the internet, so there's no hosting bill, no bot traffic, and no security release you have to apply the same evening. You keep the structured content, the listings, the media handling, the editorial workflow. The machinery that supposedly made this overkill isn't running in production, because nothing is.

The trade is real and it isn't free. Every dynamic feature leaves with the database. Webform doesn't work, so a contact form becomes somebody else's endpoint. Search becomes a client-side index. Comments become a third-party widget or nothing. Scheduled publishing becomes a scheduled rebuild. Assembling those is work, and on a small enough site it can cost more than it saved. The generator is a dependency too: Tome is feature-complete by design and lightly maintained, and as I write this it has no Drupal 12 constraint. Check that before planning an upgrade.

So the honest version of "when it's wrong" is a question about you, not the site. If you already know Drupal, a small site is now a reasonable thing to build in it, and the static route makes it cheap to run for years. If you don't, learning a platform this large to publish eight pages is a bad trade however good the on-ramp has become, and a whole post making it sound tractable doesn't change that arithmetic. It's also still wrong for a product where the website is incidental — that isn't a content application, and none of the above helps. Use what you already know. That advice survives. It just stopped being about Drupal's weight.

If you want to try it

  • Twenty minutes: the DDEV quickstart, then click through the installer.
  • An afternoon: the four steps. Describe, list, feed, code.
  • A weekend: the record catalog. Get release-versus-copy right, build three listings, turn on the feed, stop. If you enjoy the describing part, you'll like Drupal. If you resent it, that's useful information and it cost two days.
  • A month of evenings: the mix page with a working player and comments on moments. It teaches rendering, caching, and assets in the order that hurts least.
  • After that: build something small, then publish it for other people. The drupal.org review process is where the standards actually land. Start with the GitLab CI templates documentation — thirty minutes that saves a day. I have a post on that coming.

Then tell me what was worse than I said.

What not to trust here

I've used this platform a long time and I like it. Calibrate accordingly.

  • The three layers are a sketch. Entity type, bundle, field is the right way in and the wrong place to stop. A field is really two things stacked: storage, defined once for the whole entity type, and a per-bundle instance sitting on it. That's why a field can be a date on one bundle and a date on another, but never a date on one and a link on the other. Some fields aren't configurable at all — title and created are declared in code on the entity itself. You meet all of this the first time a field refuses to do what you want on one bundle only.
  • Version facts are dated to August 2026 and this thing moves every six months. The 12.0 date has already slipped once. Check drupal.org/project/drupal/releases.
  • The DDEV commands drift. They worked last time I ran them. If a line fails, DDEV's docs are right and I'm stale.
  • The mix player is a design, not a build log. I haven't shipped the waveform work, the queue, or the timestamped comments. The third problem I hit will be one I didn't mention, because it always is. The record catalog I'll stand behind — it's small enough to have nowhere to hide.
  • The barcode section is reasoned, not shipped. BarcodeDetector support and Discogs' barcode handling both change. The UPC-A/EAN-13 normalizing is the part I'm confident about, because that one is arithmetic.
  • The module list is my default for a public site, not a prescription. An internal tool behind a login wants a different set, and half of that list would be pointless on it.
  • Market share numbers are third-party surveys. Directionally useful, individually shaky. The shape — lost the small end, won the large end — holds. The decimals don't.