Build a Custom Drupal Module for a Simple Contact Form
Drupal ships with a basic contact module out of the box, but most Australian development teams quickly outgrow it. Whether you are running a small studio in Melbourne or maintaining a government portal in Canberra, business stakeholders tend to want custom fields, tailored confirmation pages, and integration with CRMs. Writing a lightweight custom Drupal module gives you precise control over routing, permissions, and submission handling without the overhead of a full-featured form builder.
The Privacy Act 1988 and the Australian Privacy Principles place real obligations on organisations that collect personal information through web forms. A bespoke module lets you embed consent checkboxes, retention metadata, and audit-friendly logging directly into the submission pipeline. This walkthrough covers the essential files, the routing layer, and a few small touches that keep your implementation aligned with local compliance expectations.
| Approach | Setup time | Flexibility | Best for |
|---|---|---|---|
| Core Contact module | Minutes | Limited | Static sites with one or two forms |
| Webform contributed module | 1–2 hours | Very high | Complex multi-page surveys |
| Custom module | 2–4 hours | Total control | Tailored business logic and integrations |
Preparing the module folder and info file
Every Drupal module begins with a dedicated directory under modules/custom/. Create a folder named custom_contact so the machine name stays short and the namespace remains consistent. Inside, place an info.yml file that tells Drupal what to load.
The YAML payload is straightforward. You declare the module name, type, core version constraint, and any optional dependencies. A typical entry looks like this:
name: Custom Contact
type: module
description: Provides a lightweight contact form with custom validation.
core_version_requirement: ^10 || ^11
package: Custom
Because Australian hosting providers such as Digital Pacific, VentraIP, and Panthur often run slightly older PHP versions on shared plans, pinning the core compatibility range helps avoid surprises during deployment. Keep the package value under "Custom" so the module appears in a predictable group inside the Extend administration page.
Defining routes in the routing.yml file
Routing in Drupal 10 and 11 is handled through a single *.routing.yml file. Each route maps a URL path to a controller, defines permission requirements, and accepts configuration for default values. For a contact form, you will typically expose two paths: one for displaying the form and one for handling the submission result.
Add a file called custom_contact.routing.yml with a single route entry. Set the path to /contact-custom, point the controller at your form class, and require the access content permission so anonymous visitors can submit enquiries. Storing the route under a non-conflicting path reduces the chance of clashing with the default /contact URL used by the core module.
If your site runs on a managed platform or on local infrastructure inside a Sydney data centre, make sure the path value matches what your .htaccess and nginx configurations will route cleanly. A trailing slash mismatch is a common reason a freshly created form returns 404s.
Writing the form class
The form class extends Drupal\Core\Form\FormBase or Drupal\Core\Form\ConfigFormBase depending on whether you need configuration storage. For a simple contact form, FormBase is the right starting point. Implement getFormId(), buildForm(), validateForm(), and submitForm() so the framework knows how to render, validate, and process input.
Inside buildForm(), define an array of form elements. Common fields include name, email, phone, subject, and message. For Australian audiences, a phone field that validates against the +61 country code prevents typos that would otherwise bounce emails back to the sender. Drupal's built-in #type => 'tel' element works well when paired with a custom validation callback.
Submit handlers should sanitise input using \Drupal::service('email.validator') and trim whitespace before persisting anything. When the form is connected to a CRM such as HubSpot or Salesforce, the submit handler is also where you trigger an outbound API call. Logging the submission through \Drupal::logger('custom_contact') produces an audit trail that helps when reviewing access requests under the Notifiable Data Breaches scheme.
Handling validation and consent
Australian privacy law treats an unchecked consent box as no consent at all. A robust module should require an explicit opt-in before the form accepts a submission. Add a checkbox element with #required => TRUE and a label that clearly describes how the data will be used.
Validation logic lives inside validateForm(). Check that the email address matches a sensible pattern, ensure the message body is at least a few characters long, and confirm that the phone number, when supplied, contains ten digits. Drupal's FormStateInterface provides setErrorByName() so individual fields can be flagged with user-friendly messages.
For multilingual sites serving customers across Brisbane, Perth, and Adelaide, consider loading the consent text from a configuration object so it can be translated through the interface translation workflow. Storing the text as plain configuration rather than hard-coding it inside the form class keeps your code accessible to content editors and translators who do not write PHP.
Theming the form output
Out of the box, the form renders using the active admin theme on configuration pages and the front-end theme elsewhere. To control the markup, create a custom_contact.module file and implement hook_theme() to register a custom template suggestion. Then add a templates/custom-contact-form.html.twig file to your module directory.
The Twig template receives a form variable that already contains the fully built render array. You can wrap it in custom markup, add inline labels, or apply CSS classes that match the rest of your site. Australian agencies often work within strict brand guidelines, so a dedicated template makes it easier to align spacing, typography, and colour with existing styles without overriding global form CSS.
If you maintain a separate CSS file, declare it through the module's libraries.yml and attach it via #attached in the form build. This approach respects Drupal's asset aggregation settings and works cleanly with BigPipe on sites served from edge nodes in Sydney or Melbourne.
Wiring up permissions and access control
Permissions are defined inside a custom_contact.permissions.yml file. A single permission labelled "Submit the custom contact form" is usually enough for anonymous and authenticated roles alike. For editorial teams, a second permission such as "View custom contact submissions" lets administrators review entries without granting access to the broader site configuration.
Assigning the permission to the anonymous role ensures visitors landing from a Google search or a paid ad can reach the form without first creating an account. This pattern suits most marketing campaigns run out of Melbourne or Sydney CBD offices, where conversion rates depend on removing every unnecessary step between the click and the submission.
If you later want to expose the submission list to a regional manager, build a simple controller route guarded by a custom permission. The same routing file used for the form page can host an /admin/structure/custom-contact path that lists recent entries, paginated and filterable by date.
Testing, deployment, and local compliance
Before deploying to production, run the module through Drupal's built-in test runner. Create a tests/src/Functional directory and write a test class extending BrowserTestBase. Cover the happy path, a validation failure, and a successful submission to catch regressions when core updates ship new form API changes.
Deployment pipelines in Australia frequently pass through staging environments hosted in different states. Clear caches with drush cr after the module is enabled, and import configuration with drush config:import so the route and permission definitions propagate cleanly. If your organisation uses the Australian Government Hosting Strategy or a panel-approved provider, confirm that the new files do not violate any content security policies enforced by the upstream proxy.
For ongoing compliance, review the data retention settings inside submitForm(). The Australian Privacy Principles require that personal information is destroyed or de-identified once it is no longer needed. Adding a cron hook that prunes submissions older than twelve months keeps the database lean and the legal team comfortable. Should a data breach ever occur, the structured logs produced by the module make it easier to assemble the statement required by the Office of the Australian Information Commissioner. If you get stuck or want to share your version of the module, the contact page on this site points to a direct line for code review and follow-up questions.