Skip to main content
Home
Drupal life hacks

Main navigation

  • Drupal
  • React
  • WP
  • Contact
  • About
User account menu
  • Log in

Breadcrumb

  1. Home

Drupal Buy Me a Coffee Module: Refactoring, Configuration, Kernel Tests, and a Production Form API Bug

By admin, 2 October, 2026
Drupal Buy Me a Coffee module refactoring and Kernel tests

When developing Drupal modules, technical debt tends to accumulate over time. This is especially noticeable in smaller contributed modules that start as a simple integration and gradually gain new configuration options, UI elements, styling, support for multiple Drupal versions, and additional functionality.

In this article, I want to share the work behind versions 1.0.11 and 1.0.12 of my Buy Me a Coffee (BMC) module for Drupal.

The module integrates the Buy Me a Coffee service with Drupal and allows site owners and content creators to display a Buy Me a Coffee button or widget on their websites.

The goal of this work was not to rewrite the module from scratch. Instead, I wanted to improve the existing implementation step by step, fix real Drupal-specific issues, verify the behavior on a running Drupal installation, introduce a focused automated test suite, and then fix a production issue discovered after the 1.0.11 release.

The process turned out to be more valuable than a large, abstract "code cleanup".


Why the Refactoring Was Needed

The original implementation of the module was relatively simple. Over time, however, it gained more functionality:

  • button configuration;
  • widget configuration;
  • predefined colors;
  • custom colors;
  • a color picker;
  • widget visibility settings;
  • different display options;
  • additional CSS and JavaScript.

At the same time, some parts of the implementation still reflected the early version of the module.

Before preparing the next release, I decided to review the module and address the most important architectural and Drupal-specific issues.

The main principle was simple:

Fix real problems first, verify the behavior on a running Drupal installation, and only then add automated tests.

This approach helped keep the refactoring focused on actual behavior rather than theoretical improvements.


Drupal Configuration

One of the first improvements was proper Drupal configuration schema support.

A schema file was added for the module's configuration values.

This is important not only for documentation. The schema tells Drupal what types of values are stored in the configuration object and allows the configuration to integrate properly with Drupal's Configuration API.

The configuration contains values such as:

  • Buy Me a Coffee username;
  • widget visibility;
  • text values;
  • spacing values;
  • button colors;
  • widget colors;
  • display settings.

Default configuration was also added under config/install.

This gives a newly installed module a predictable initial state and makes its configuration better integrated with Drupal's configuration system.


Configuration Validation

User input also received more attention.

For example, spacing settings are numeric values. Setting #type => number in a form provides useful browser-side validation, but it does not replace server-side validation.

Server-side validation was therefore added for spacing values, including appropriate limits for the accepted range.

This is an important principle when working with Drupal Form API:

HTML form restrictions improve the user experience, but they do not replace server-side validation.

The browser can help prevent invalid input, but the server must still treat submitted data as untrusted input.


Improving the Color Picker

Color handling had previously been more fragmented.

The module now uses a reusable bmc_color_picker form element.

It supports both predefined colors and custom color values.

The implementation is based on Drupal's Form API and provides:

  • reusable form logic;
  • centralized value handling;
  • consistent normalization;
  • dedicated color picker UI;
  • validation of custom colors;
  • easier integration with configuration forms.

Instead of treating colors as a collection of unrelated form elements, the color picker is now a reusable Drupal Form API component.

The custom color value is normalized before being stored, while validation ensures that submitted custom colors have the expected hexadecimal format.

This makes the behavior more predictable across different parts of the configuration form.


#states and the Real HTML Output

One of the more interesting problems appeared with Drupal Form API #states.

At the PHP level, the form structure initially suggested that the checkbox had a nested name such as:

bmc_customize_widget[widget_visible]

The initial #states selector was therefore built around that assumption:

:input[name="bmc_customize_widget[widget_visible]"]

However, after rendering the form, the actual HTML generated by Drupal contained:

name="widget_visible"

rather than the expected nested name.

As a result, the original selector did not work.

After checking the actual rendered HTML, the selector was changed to:

:input[name="widget_visible"]

This is a good example of why looking only at PHP render arrays is not always enough.

When using #states, AJAX, JavaScript behaviors, or other client-side functionality, it is useful to inspect the actual HTML generated by Drupal.

The Form API render structure is an abstraction. What matters to the browser is the final markup.


Cache Metadata

Another important part of the refactoring was cache metadata.

The BMC output depends on configuration values such as:

  • username;
  • widget visibility;
  • display settings;
  • colors;
  • spacing.

Therefore, configuration-dependent output needs appropriate cache metadata.

The module now uses the configuration cache tag:

config:bmc.settings

This allows Drupal to invalidate relevant cached output when the configuration changes instead of requiring a full site cache flush.

This is particularly important for production Drupal sites where page and render caching are enabled.

Cache metadata is easy to overlook in small modules, but it becomes important as soon as rendered output depends on configuration.


Handling Missing Configuration

Another issue appeared while reviewing configuration access.

An earlier implementation attempted to use:

$configFactory->getOptional()

to handle missing configuration.

After checking the actual Drupal 11 API, it became clear that ConfigFactoryInterface does not provide such a method.

The normal configuration API was also tested to verify its behavior.

The resulting code is simpler:

$config = $this->configFactory->get('bmc.settings');

The application logic then safely handles missing configuration values.

This was another useful reminder:

Check the actual framework API instead of relying on assumptions about what an API might provide.

This becomes particularly important when supporting multiple Drupal versions and working with APIs that may have changed over time.


The Block Plugin

BMC uses Drupal's Block API to provide the Buy Me a Coffee button.

Several improvements were made to the block implementation:

  • route URLs are generated through Drupal's URL API;
  • cache metadata is attached to the render array;
  • manual HTML rendering was removed where it was unnecessary;
  • the placeholder is built using Drupal render arrays;
  • configuration values are passed through Drupal's normal rendering pipeline.

One important principle here is to avoid manually converting user-controlled or configuration values into HTML and then inserting them directly into #markup.

Whenever possible, it is better to let Drupal's render system handle escaping and rendering.

This keeps the implementation closer to Drupal conventions and reduces unnecessary HTML construction inside PHP code.


Help Page

The module's help page was also improved.

Instead of manually constructing HTML using Markup::create(), the implementation now uses Drupal render arrays.

For example, an HTML element can be represented as:

[
  '#type' => 'html_tag',
  '#tag' => 'p',
  '#value' => $text,
]

External links explicitly define:

target="_blank"
rel="noopener noreferrer"

During the refactoring, an interesting Drupal render-system behavior was discovered: not every render array can simply be nested into #children where a string value is expected.

This was detected during actual runtime testing rather than through static inspection.

The implementation was therefore adjusted to use a translated string with the rendered link where appropriate.

Again, runtime verification proved useful because the issue was related to how Drupal actually processes the render array rather than simply how the PHP structure looked.


Permissions

Administrative routes use the dedicated permission:

administer bmc

This provides a more specific access control mechanism for BMC configuration.

The permission is defined in bmc.permissions.yml and is used by the relevant routes.

When changing permissions, it is important to verify both:

  1. that the permission is correctly defined;
  2. that the relevant routes actually require it.

A permission existing in bmc.permissions.yml does not by itself guarantee that a route is protected correctly.


Why Kernel Tests?

After completing the main refactoring, the next question was how to make sure that the existing behavior did not regress.

For this purpose, a minimal set of Drupal Kernel tests was added.

Kernel tests are useful here because they boot the Drupal kernel and provide access to real Drupal services without requiring a browser.

For this module, that is enough to test a substantial part of the core behavior.

The 1.0.11 release contains 12 Kernel tests with 64 assertions.

The tests were deliberately focused on behavior rather than implementation details.


Configuration Tests

BmcConfigTest verifies:

  • default configuration;
  • the presence of the configuration schema;
  • configuration updates.

The default configuration test checks that a newly installed BMC configuration has predictable values.

The schema test verifies that the module's configuration schema file is present.

The update test verifies that configuration can be written and subsequently read through Drupal's Configuration API.


Block Tests

BmcButtonBlockTest verifies:

  • the placeholder when no username is configured;
  • script output when a username is configured;
  • expected data attributes;
  • relevant cache metadata.

This provides coverage for both the unconfigured and configured states of the block.


Widget Tests

BmcWidgetTest verifies:

  • the widget is not attached when disabled;
  • the widget is attached on normal pages;
  • the widget is not attached to administrative pages;
  • expected widget data attributes are present.

The tests verify the generated attachment structure rather than trying to execute the external Buy Me a Coffee JavaScript.

This is an appropriate boundary for a Kernel test: Drupal is responsible for producing the correct render attachment; the external service's JavaScript behavior belongs outside this test suite.


Help Page Test

BmcHelpTest verifies the help page render structure and the attributes of the external link.

The test checks that the expected HTML elements are present and that the external link contains:

target="_blank"
rel="noopener noreferrer"

The test intentionally focuses on the controller's render structure rather than performing a browser-level request.


Permission Test

BmcPermissionTest verifies the existence of:

administer bmc

It also verifies that the permission is provided by the BMC module.

This gives the module a small but useful automated check around access control configuration.


Test Results

The Kernel test suite was run against:

  • Drupal 11.3.13;
  • PHP 8.4.22;
  • PHPUnit 11.5.56.

The result was:

12 tests
64 assertions
OK

Additional PHP syntax checks were performed for the test files, and Drupal cache rebuilds completed successfully.

The PHPUnit configuration was also cleaned up to use a portable relative Drupal core bootstrap path rather than a DDEV-specific absolute path.

The resulting bootstrap reference is relative to the module's location:

../../../../core/tests/bootstrap.php

This resolves to the Drupal installation's:

web/core/tests/bootstrap.php

The test configuration was also aligned with the installed PHPUnit 11 version.

It is important to note that these are Kernel tests.

They do not replace Functional or FunctionalJavascript tests.

For example, the #states behavior was also checked against a real Drupal installation because the final behavior depends on the generated HTML and JavaScript interaction.


A Production Form API Bug

The refactoring and test work uncovered another important lesson shortly after the 1.0.11 release.

A production issue was discovered in the BMC configuration form.

The form failed during submission with a PHP TypeError.

The problem was in the custom spacing validation callback.

The method had been declared as:

protected function validateSpacingField(
  array &$element,
  FormStateInterface $form_state,
  array &$complete_form,
): void {

and registered as a Drupal Form API #element_validate callback.

Drupal's Form API invokes element validation callbacks dynamically.

A protected method cannot be called from that context.

Therefore, submitting the configuration form caused a runtime error even though:

  • the PHP syntax was valid;
  • the class could be loaded;
  • the callback declaration looked syntactically correct.

The fix was deliberately minimal.

The method visibility was changed from:

protected function validateSpacingField(

to:

public function validateSpacingField(

The validation logic itself did not need to change.


Why the Kernel Tests Did Not Catch It

This production bug is also a good illustration of the limitations of a focused Kernel test suite.

The 12 Kernel tests verified configuration, blocks, widgets, help output, and permissions.

They did not submit the complete configuration form through Drupal's Form API.

Therefore, they did not exercise the exact runtime path that triggered the callback visibility error.

This does not make the Kernel tests ineffective.

Instead, it demonstrates why different levels of testing are useful.

A test suite should match the behavior it is intended to protect.

For this module:

PHP syntax
    ↓
Kernel tests
    ↓
Real Drupal runtime
    ↓
Form submission
    ↓
Browser / JavaScript testing where necessary

Each level catches a different category of problem.


Verification of the Hotfix

After changing the method visibility:

  • php -l passed without syntax errors;
  • Drupal cache rebuild (drush cr) completed successfully;
  • the configuration form loaded correctly;
  • the configuration form could be submitted normally on Drupal 11.3.13.

The fix was intentionally limited to the callback visibility.

No unrelated refactoring was introduced.


Version 1.0.11

The result of the initial refactoring work was Buy Me a Coffee 1.0.11.

Version 1.0.11 introduced:

  • improved configuration handling and schema;
  • stronger configuration validation;
  • improved button and widget configuration;
  • corrected #states behavior;
  • improved cache metadata;
  • improved color picker handling;
  • cleaner render arrays;
  • improved help page implementation;
  • reviewed permissions;
  • added Kernel test coverage;
  • updated documentation.

The release was tested against Drupal 11, including configuration forms, block rendering, widget behavior, permissions, and the help page.

The goal was not to rewrite the module, but to improve the existing implementation incrementally while preserving its core functionality.


Version 1.0.12 — Hotfix

Shortly after the 1.0.11 release, the production Form API issue was discovered.

The validateSpacingField() method was registered as a Drupal Form API #element_validate callback but was declared protected.

Because Drupal needs to invoke the callback dynamically, the method must be publicly accessible.

The fix was therefore a one-line visibility change:

public function validateSpacingField(
  array &$element,
  FormStateInterface $form_state,
  array &$complete_form,
): void {

No validation rules were changed.

No new functionality was introduced.

The hotfix simply corrected the callback visibility so that it matched the Drupal Form API callback contract.

Because this was a focused production fix rather than a feature release, it was released as:

Buy Me a Coffee 1.0.12 — Hotfix


What This Bug Taught Me

This was a one-line production fix, but it is a useful reminder when working with Drupal Form API.

A callback registered through #element_validate is not simply an internal helper method.

For example:

'#element_validate' => [
  [$this, 'validateSpacingField'],
],

tells Drupal that the method will be invoked as a callback during form processing.

The method therefore needs to be publicly callable.

This type of problem is not detected by a basic PHP syntax check.

The PHP code is syntactically valid and the class can be loaded successfully. The error appears only when Drupal actually processes the form submission.

This is why testing a Drupal module should ideally happen at multiple levels:

  1. PHP syntax;
  2. automated Drupal tests;
  3. actual Form API execution;
  4. real user workflows;
  5. browser-level JavaScript testing where required.

In this case, the final production fix was only one line, but finding the problem required testing the actual configuration form submission workflow.


Why Not Write Hundreds of Tests?

When refactoring a small Drupal module, it is easy to go too far in the other direction and start testing every line of code.

I chose a more focused approach.

First, I identified the actual user-facing areas:

Configuration
    ↓
Block
    ↓
Widget
    ↓
Help
    ↓
Permissions

Then I added tests around the important behavior of each area.

This gives the project a useful safety net without making the test suite unnecessarily difficult to maintain.

A good test should verify behavior rather than simply reproduce the implementation.

At the same time, the production Form API bug demonstrated where additional Functional testing could provide value.

The lesson is not that every module needs hundreds of tests.

The lesson is that the test level should correspond to the behavior being tested.


What This Refactoring Demonstrated

The most valuable part of this work was not the number of changed lines, but the development process.

The sequence was:

  1. identify real problems;
  2. make small changes;
  3. run Drupal and verify the actual behavior;
  4. use Drupal APIs instead of manual implementations;
  5. add tests for important behavior;
  6. test the real configuration form;
  7. fix the production issue;
  8. release the updated module;
  9. apply a focused hotfix when a runtime issue was discovered.

Several problems were discovered specifically through runtime testing:

  • an incorrect #states selector;
  • an incorrect assumption about the Drupal configuration API;
  • an invalid nested render-array structure;
  • the visibility of a Form API validation callback;
  • the need for appropriate cache metadata.

Some of these issues were visible during code review.

Others only became obvious when Drupal actually rendered the form, processed submitted values, or executed the relevant runtime code.

That distinction is important.

Static inspection can tell you what the code appears to do.

Runtime testing tells you what the application actually does.


Lessons for Drupal Module Development

There are several practical lessons I took from this work.

1. Verify the Framework API

Do not assume that an API method exists because it would be convenient.

Check the actual Drupal version and API documentation.

This is especially important when maintaining code across Drupal releases.

2. Inspect Generated HTML

When working with #states, AJAX, JavaScript behaviors, or CSS selectors, inspect the final HTML.

The PHP form structure is not necessarily identical to the markup generated by Drupal.

3. Use Render Arrays

When Drupal provides a render API for a particular task, use it instead of constructing HTML manually.

This improves consistency with Drupal's rendering system and makes escaping responsibilities clearer.

4. Treat Cache Metadata as Part of Functionality

If output depends on configuration, cache metadata is part of the implementation.

A module can appear to work correctly while still producing stale cached output.

5. Test Callback Contracts

Form API callbacks are runtime contracts.

Visibility, parameters, and callback signatures matter even when the PHP code itself is syntactically valid.

6. Test the Real Workflow

Automated tests are valuable, but they should be complemented by actual runtime verification.

A configuration form that passes a static inspection is not necessarily a configuration form that successfully submits.


Conclusion

Even a small Drupal module benefits from a clean configuration structure, proper use of Drupal APIs, cache metadata, validation, and automated tests.

At the same time, refactoring does not have to mean a complete rewrite.

The BMC experience demonstrated a practical approach:

  1. identify real problems;
  2. fix them incrementally;
  3. verify changes on a running Drupal installation;
  4. use Drupal's APIs instead of reinventing framework functionality;
  5. add tests around important behavior;
  6. test actual user workflows;
  7. release only after the complete workflow has been verified;
  8. treat production issues as feedback about missing test coverage rather than as a reason for uncontrolled rewrites.

The result is not only a cleaner codebase, but also a stronger foundation for future development.

The Buy Me a Coffee Drupal module is available on Drupal.org:

https://www.drupal.org/project/bmc

The 1.0.11 release introduced the main refactoring and Kernel test coverage.

The 1.0.12 release followed as a focused production hotfix for the Form API callback visibility issue.

The most important lesson was simple:

Good Drupal development is not only about writing code that looks correct. It is about verifying how Drupal actually discovers, renders, caches, validates, and executes that code.

That is where many of the most useful bugs — and the most useful lessons — are found.

Tags

  • Drupal
  • Drupal 11
  • Drupal Development
  • Drupal module
  • PHP
  • PHPUnit
  • Form API
  • Kernel Tests
  • Refactoring

Comments

About text formats

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Powered by Drupal