Most Drupal development is not typing — it is deciding, over and over, whether a requirement belongs in configuration, in a contributed module, in custom code, or in the theme. Those decisions are invisible on launch day and expensive at the first major upgrade, when every shortcut has to be understood again by somebody who was not there when it was taken.
This page sets out how that work is approached here: 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 own or something worth bringing help in for.
Four kinds of work that get called "development"
- Site building — content types, fields, views and display modes, configured in 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 modules — behaviour Drupal and contrib 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.
Real projects need some of each. The skill is knowing which layer a requirement belongs in, because logic in the wrong layer is what makes a site hard to maintain.
Why "does it work?" stopped being enough
Drupal has 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, and they mean code written against older idioms accumulates upgrade debt quietly. It keeps working right up to the major version where it does not, and then the whole bill arrives at once. The better question about custom code is therefore not whether it runs, but whether it is written the way current Drupal expects — because that is what sets the cost of the next upgrade.
The first decision: configuration, contrib, or code
The cheapest custom code is the code nobody writes. The order of preference:
- 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 little attention. The workflow around it is covered in Drupal configuration and management.
- A contributed module second. If a well-maintained project already solves the problem, use it — somebody else is carrying the maintenance and the security coverage.
- Custom code last, and deliberately, when the requirement is genuinely specific to your business.
Both failure directions are real. A custom module for something Views already does creates permanent maintenance work. Fifteen contributed modules installed to avoid writing forty lines of code creates a different burden: fifteen more things to update and to test at every upgrade. The test is whether the requirement is generic or specific. Generic requirements already have generic solutions.
Modules provide behaviour, themes provide presentation
A useful boundary: modules do behaviour, themes do presentation, configuration describes structure. When those blur, maintenance gets harder.
Markup and CSS inside a module is the most common violation. It works, but presentation changes then need a developer instead of a themer, and the module cannot be reused on a site with a different theme. Modules should emit render arrays and ship templates a theme can override.
The reverse happens too: business logic inside .theme preprocess functions. It runs, 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, and any custom entity type you define. The Entity API gives you access control, revisions, translation, validation, caching and a consistent query layer.
Code that bypasses it, usually with direct SQL against tables like node_field_data, gives all of that up silently. Core is explicit enough about this that an entity query with no access decision throws outright: Drupal\Core\Entity\Query\Sql\Query raises "Entity queries must explicitly set whether the query should be access checked or not."
// 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(); Dropping to the database layer is legitimate for large reporting queries and migrations. It should be a commented exception, not the default way data is read.
Hooks are becoming classes
Hooks were procedural functions named after your module for most of Drupal's history. Current versions let you implement them as methods on a class, discovered through an attribute defined in core/lib/Drupal/Core/Hook/Attribute/Hook.php:
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().
} } Worth adopting for new code. Hook classes are services, so dependencies arrive through the constructor instead of through \Drupal::service(), which makes them testable in isolation — and it gets you ahead of a migration that otherwise happens later under time pressure.
Plugins and the move to attributes
Blocks, field widgets, field formatters and many other extension points are plugins. They used to be discovered by reading docblock annotations; current Drupal reads PHP attributes:
#[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. Core's DefaultPluginManager takes the Attribute class as its fifth constructor argument and the older annotation name as its sixth, and checks which one it was handed. A plugin class carrying only an annotation keeps working while its manager still supports annotation discovery — and stops working entirely when that support is removed. Core does not spring this on you. A plugin manager that hands over no Attribute class raises a deprecation naming removal in Drupal 12, and a plugin class still carrying only an annotation raises one naming removal in Drupal 13. Both are in your deprecation log today, which makes this schedulable work rather than an upgrade-day surprise — provided somebody reads that log.
Maintaining my own contributed Drupal modules and themes has taught me to treat this kind of change as scheduled work rather than an emergency, because the alternative is meeting it on upgrade day 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 for version control:
drush config:export
drush config:import Two things go wrong here routinely. The first is drift: somebody edits 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 when every change flows through it.
The second is the configuration/content boundary. Nodes, media, taxonomy terms and blocks placed as content are content, and content does not travel through configuration management. A page built and signed off on staging that never appears in production is almost always this. A related trap — the difference between a block and block content, and why it duplicates paragraph content — is worked through in Drupal blocks vs block content.
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 decided in the code
Performance is often treated as something to fix at the hosting layer after launch. Much of it is settled 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 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 honestly can be cached for a long time and invalidated precisely when its content changes — the mechanics of that at the CDN are in Drupal, Cloudflare Purge and long cache TTLs.
Testing what you build
Drupal ships a serious testing framework, and custom modules carrying tests are markedly cheaper to maintain. The 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. Match effort to what breaking it would cost.
Mistakes I see most often
- Core or contrib 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 module handling twelve unrelated concerns cannot be tested, reused or removed.
- Caching disabled to fix a bug. It 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 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 rather than marking output safe to silence a warning; keep dependencies updated through Composer so advisories 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 needs no specialist. Adding fields, building views, configuring displays and installing well-documented contributed modules are all reasonable for a competent team to own. Bring 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 only available reviewers are the people who wrote it.
The pattern worth avoiding is waiting until an upgrade has already failed. Understanding a codebase under pressure costs considerably more than reviewing it calmly beforehand.
How an engagement runs
I have worked in web development since 2005 and I am the author of Drupal modules and themes, listed on my drupal.org profile. 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, 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 changed, what was deliberately left alone, and what debt remains. A handover that exists only in someone's memory is not a handover.
Examples are on the portfolio, the engineering side of the role is on the Drupal developer page, and the other service lines are on Drupal services.
Common questions
Can you work on a site you did not build?
That is most of this work. It starts with an inventory rather than an opinion: custom modules, contributed versions, where configuration diverges from the repository.
How long does a custom module take?
The build is rarely the long part. Agreeing the behaviour precisely, and deciding what happens in the failure cases, is what determines the estimate — which is why a written scope is worth the hour it takes.
Do we have to move to the newest Drupal to get help?
No, but expect the first recommendation to be about your version. Working on a site two majors behind means fixing things twice, and that shows up in the cost of everything else.
Will we be locked in?
Code follows Drupal standards, lives in your repository, and comes with documentation and a written handover. If the arrangement ends, another Drupal developer can pick it up — that is the point of writing it the way core expects.
Next steps
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 someone else did that, there are three ways to start:
- A code review — a fixed-scope look at your custom modules with a written report of what breaks 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 describing 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.