Drupal development covers a wide range of work, and the word means different things to different people. To a site owner it often means "make the website do this new thing." To a developer it means deciding whether that new thing belongs in configuration, in a contributed module, in custom code, or in the theme — and living with that decision for the next several years.
That decision is the part that costs money later. A site assembled from whatever was quickest at the time still works on launch day. It becomes expensive at the first major version upgrade, when every shortcut has to be understood again by somebody who was not there when it was taken.
This page describes how I approach Drupal development, what belongs in custom code, the mistakes that show up most often in codebases I am asked to review, and how to tell whether a piece of work is something your team should do or something worth bringing in help for.
What Drupal Development Actually Covers
It helps to separate four kinds of work that often get grouped under one heading:
- Site building — content types, fields, views, and display modes, configured through the admin interface and exported as configuration.
- Theming — templates, CSS, and the presentation layer. A separate discipline with its own rules, covered on the Drupal themer page.
- Custom module development — behaviour that Drupal and contributed modules do not already provide: business logic, integrations, custom entities, access rules, queue workers.
- Integration — connecting Drupal to payment providers, CRMs, search services, data warehouses, or internal APIs.
Most real projects need some of each. The skill is knowing which layer a requirement belongs in, because putting logic in the wrong layer is what makes a site hard to maintain.
Why This Matters More Than It Used To
Drupal has changed considerably. Recent versions have moved steadily toward modern PHP: dependency injection instead of global functions, PHP attributes instead of docblock annotations for plugin discovery, and object-oriented hook implementations instead of procedural functions in a .module file.
These are improvements. They also mean that code written against older idioms accumulates upgrade debt quietly. It keeps working, right up until a major version where it does not, and then the bill arrives all at once.
The practical consequence is that "does it work?" is no longer a sufficient standard for custom code. The better question is whether the code is written the way current Drupal expects, because that is what determines the cost of the next upgrade.
The First Decision: Configuration, Contrib, or Code
The cheapest custom code is the code you do not write. Before opening an editor, the order of preference is:
- Configuration first. If a content type, a view, or a display mode solves it, solve it there. Configuration is exportable, reviewable, and survives upgrades with minimal attention.
- A contributed module second. If a well-maintained module already solves the problem, use it. Someone else is carrying the maintenance burden and the security coverage.
- Custom code last — and deliberately, when the requirement is genuinely specific to your business.
The failure mode in both directions is real. Writing a custom module for something Views already does creates permanent maintenance work. Installing fifteen contributed modules to avoid writing forty lines of code creates a different burden: fifteen more things to keep updated and to test at every upgrade.
The judgement is in whether the requirement is generic or specific. Generic requirements have generic solutions already. Specific ones usually deserve code you control.
Where Custom Modules Belong — and Where They Do Not
A useful boundary: modules provide behaviour, themes provide presentation, and configuration describes structure. When those blur, maintenance gets harder.
Markup and CSS in a module is the most common violation. It works, but it means presentation changes require a developer instead of a themer, and it means the module cannot be reused on a site with a different theme. Modules should emit render arrays and provide templates that a theme can override, not hard-code appearance.
The reverse also happens: business logic inside .theme preprocess functions. It runs, but it is invisible to anyone reading the module layer, and it disappears the moment the site is re-themed.
Work With the Entity API, Not Around It
Nearly everything in Drupal is an entity: nodes, users, taxonomy terms, media, custom entity types you define yourself. The Entity API gives you access control, revisions, translation, validation, caching, and a consistent query layer.
Code that bypasses it — usually direct SQL against tables like node_field_data — gives up all of that silently. It ignores access checks, misses cache invalidation, and breaks when the storage schema changes underneath it.
// Bypasses access control, revisions, and cache invalidation.
$titles = $database->query("SELECT title FROM node_field_data WHERE status = 1")
->fetchCol();
// Uses the entity query, which respects access and cacheability.
$nids = $entityTypeManager->getStorage('node')->getQuery()
->condition('status', 1)
->accessCheck(TRUE)
->execute();There are legitimate reasons to drop to the database layer — large reporting queries and migrations among them — but it should be a deliberate exception with a comment explaining why, not the default way data gets read.
Hooks Are Becoming Classes
For most of Drupal's history, hooks were procedural functions named after your module. Recent versions support implementing them as methods on a class, discovered through an attribute:
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;
class NodeHooks {
#[Hook('node_presave')]
public function nodePresave(NodeInterface $node): void {
// Logic that used to live in my_module_node_presave().
}
}This is worth adopting for new code. Hook classes are services, so they receive their dependencies through the constructor instead of reaching for \Drupal::service(), which makes them testable in isolation. It also gets you ahead of a migration that will otherwise have to happen later, under time pressure.
Plugins and the Move to Attributes
Blocks, field widgets, field formatters, and many other extension points are plugins. Historically they were discovered by reading docblock annotations. Newer Drupal reads PHP attributes instead:
#[FieldWidget(
id: 'my_module_color_widget',
label: new TranslatableMarkup('Color picker'),
field_types: ['string'],
)]
class ColorWidget extends WidgetBase {}If you maintain contributed modules, this is the change most likely to bite. A plugin class carrying only an annotation continues to work while the plugin manager still supports annotation discovery — and then stops working entirely when that support is removed. The failure is a fatal error, not a deprecation notice, so it will not show up in a log you can ignore for a release or two.
Maintaining 27+ contributed modules has taught me to treat this kind of change as scheduled work rather than as an emergency, because the alternative is discovering it on the day of an upgrade across every site at once.
Configuration Management Is Part of Development
Configuration — content types, fields, views, permissions — lives in the database on a running site and is exported to YAML files for version control:
drush config:export
drush config:importTwo things routinely go wrong here.
The first is drift: someone changes a view in the production admin interface, nobody exports it, and the next deployment silently reverts their change. Configuration only works as a source of truth if every change flows through it.
The second is the boundary between configuration and content. Nodes, blocks placed as content, media items, and taxonomy terms are content, and content does not travel through configuration management. It is a common and expensive surprise: a page built and tested on a staging site does not appear in production, because it was never configuration in the first place.
If your team is fighting configuration drift or unexplained reverts on deployment, that is usually a workflow problem rather than a Drupal problem — and it is worth a short conversation before it becomes habit.
Performance Is a Development Concern
Performance is often treated as something to fix at the hosting layer after launch. Much of it is decided while the code is being written.
Drupal's render system caches aggressively, but only when the code tells it what a piece of output depends on. Every render array can declare cache tags, contexts, and a max-age. Omit them and you get one of two outcomes: output cached too aggressively and served stale, or code that disables caching entirely to be safe and makes every request expensive.
$build = [
'#markup' => $text,
'#cache' => [
'tags' => $node->getCacheTags(),
'contexts' => ['user.permissions'],
],
];Getting cacheability right in the code is what makes edge caching safe further out. A page that declares its dependencies correctly can be cached for a long time and invalidated precisely when its content changes.
Testing What You Build
Drupal ships a serious testing framework, and custom modules that carry tests are markedly cheaper to maintain. The practical value is not proving the code works today — it is knowing at the next core update whether it still does.
Not everything needs the same treatment. Business rules with real consequences, access logic, and anything touching payments or personal data deserve tests. A block that renders three fields does not need the same investment. Match effort to what breaking it would cost.
Common Mistakes I See in Drupal Codebases
- Core or contrib modules edited directly. The change disappears at the next update. Use hooks, plugins, or a patch tracked in
composer.json. - Everything in one custom module. A single module handling twelve unrelated concerns cannot be tested, reused, or removed.
- Caching disabled to fix a bug. This converts a correctness problem into a permanent performance problem, and the original bug is still there.
- Access checks in the theme layer. If a template decides who sees something, the data was already loaded and is often still reachable through JSON:API or a view.
- Configuration changed in production and never exported. Guarantees the change will be reverted, usually at the worst moment.
- Composer bypassed. Modules downloaded and unzipped by hand cannot be updated predictably and drop out of security tooling.
None of these are exotic. They are ordinary decisions that were reasonable under deadline and never revisited.
Security Belongs in the Code
Most Drupal security incidents are not exotic exploits. They are unapplied updates and custom code that skipped a check.
The habits that matter: run access checks on entity queries rather than filtering results afterwards; use the database abstraction layer with placeholders rather than building SQL strings; let Twig autoescape output rather than marking it safe to silence a warning; keep dependencies updated through Composer so security advisories actually reach you.
Custom code is where these get skipped, because contributed modules have many more eyes on them than the module written for one site three years ago.
When to Handle It Yourself, and When to Bring in Help
Plenty of Drupal work does not need a specialist. Adding fields, building views, configuring displays, and installing well-documented contributed modules are all reasonable for a competent team to own.
It is worth bringing in help when:
- A major version upgrade is due and the site carries custom modules nobody currently on the team wrote.
- Performance problems persist after caching and hosting have been tuned, which usually means the cause is in the code.
- An integration touches money, personal data, or anything with a compliance obligation.
- The same bug keeps returning in different forms — a sign the problem is architectural rather than local.
- A code review is needed before a launch, and the people who would review it are the people who wrote it.
The pattern worth avoiding is waiting until an upgrade has already failed. Understanding a codebase under pressure is considerably more expensive than reviewing it calmly beforehand.
How I Approach a Drupal Development Engagement
In my 20+ years of Drupal development, the projects that go well share a shape.
They start by reading the existing site rather than proposing a rebuild — what is configuration, what is custom, what is contributed and how far behind it is, and where the previous team took shortcuts and why. Most of what looks irrational in an inherited codebase turns out to have had a reason.
Work then goes in smallest-risk-first order, so the site stays deployable throughout rather than entering a long period where nothing can ship. And the result is written down: what was changed, what was deliberately left alone, and what debt remains. A handover that exists only in someone's memory is not a handover.
Examples of this work are on the portfolio, and the Drupal developer page covers the broader engineering side.
Next Steps
If you are working through this yourself, the highest-value starting point is an inventory: list every custom module, note which ones nobody currently understands, and check how far behind your contributed modules are. That list is usually the whole upgrade plan in outline.
If you would rather have someone else do that work, there are three ways to start:
- A code review — a fixed-scope look at your custom modules with a written report of what will break at the next major upgrade.
- A defined piece of development — a specific module, integration, or upgrade with an agreed scope.
- Ongoing development support — a standing arrangement for teams that need Drupal expertise without a full-time hire.
You can request a quote with a description of your site and what you are trying to achieve, or get in touch if you would rather talk it through first. Either way, it helps to know your current Drupal version, roughly how many custom modules you carry, and what is prompting the work.