Build your first module 3: a form, a service, and the database
This is the part where the module starts doing its job. You define a database table, wrap it in a service, register that service in the container (fixing part 2’s error), build the RSVP form, and write your first object-oriented hook to clean up after deleted nodes.
What you’ll build
Section titled “What you’ll build”Part 2 ended with a controller demanding a service called event_rsvp.storage. This part supplies it: a RsvpStorage class around a custom event_rsvp table, registered in event_rsvp.services.yml. On top of it you build RsvpForm, the page where visitors respond at /node/{node}/rsvp, and EventRsvpHooks, a hook class that deletes a node’s RSVPs when the node itself is deleted. At the end, RSVPs flow in through the form and out through part 2’s report.
Prerequisites
Section titled “Prerequisites”- Parts 1 and 2 completed: the module is installed, the routes exist, and the controller is in place.
- The DrupalCon article node from part 2.
Step 1: Choose the storage and define the table
Section titled “Step 1: Choose the storage and define the table”Before any code, decide where the data lives. Drupal gives a module three main homes for its data, and the choice shapes everything downstream:
- Configuration holds administrator-defined structure and settings: simple config objects like the settings this module gains in part 4, and config entities like content types and views. It is built to be exported to YAML, reviewed in git, and deployed between environments. That is exactly why it is wrong for data that arrives in production: every RSVP would become a deployment artifact.
- Content entities hold user-generated content. They buy you fields, access control, revisions, translations, and Views integration, and they cost the most machinery: base field definitions, handlers, link templates. Reach for them when editors manage the data or when you need any of those subsystems; Define a custom entity type shows how.
- A custom database table holds high-volume rows with a fixed structure and none of the entity features. You write the schema and every query yourself, and in exchange nothing sits between your code and the data.
RSVPs are the third case: who, which event, when, in high volume, never edited, never fielded, never translated. If the requirements later grow toward “attendees manage their responses” or “editors list RSVPs in Views”, that is the signal to graduate to a content entity. Config, content, state, and sessions treats the full decision, including the state and session stores this module does not need.
Modules declare tables by implementing hook_schema() in their .install file. Create web/modules/custom/event_rsvp/event_rsvp.install:
<?php
/** * @file * Install, update and uninstall functions for the Event RSVP module. */
declare(strict_types=1);
/** * Implements hook_schema(). */function event_rsvp_schema(): array { $schema['event_rsvp'] = [ 'description' => 'Stores one row per RSVP to an event node.', 'fields' => [ 'id' => [ 'description' => 'Primary key: unique RSVP ID.', 'type' => 'serial', 'unsigned' => TRUE, 'not null' => TRUE, ], 'nid' => [ 'description' => 'The {node}.nid of the event.', 'type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE, 'default' => 0, ], 'uid' => [ 'description' => 'The {users}.uid of the attendee, 0 for anonymous.', 'type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE, 'default' => 0, ], 'mail' => [ 'description' => "The attendee's email address.", 'type' => 'varchar', 'length' => 254, 'not null' => TRUE, 'default' => '', ], 'created' => [ 'description' => 'Unix timestamp of when the RSVP was submitted.', 'type' => 'int', 'not null' => TRUE, 'default' => 0, ], ], 'primary key' => ['id'], 'unique keys' => [ 'rsvp_once' => ['nid', 'mail'], ], 'indexes' => [ 'nid' => ['nid'], ], ]; return $schema;}The schema array names each column, its type, and its constraints in database-agnostic form; Drupal translates it to the CREATE TABLE statements each engine (MySQL, PostgreSQL, or SQLite) uses. Two structural choices matter here:
- The
rsvp_onceunique key on(nid, mail)means the database itself enforces one RSVP per address per event. The form also validates this (step 5), and the constraint is the data-integrity backstop: if two identical submissions race past validation, the second insert fails with a database error instead of storing a duplicate row. - The
nidindex supports the queries the module actually runs, which all filter by node.
One thing to notice: this file is procedural, and that is not a style slip. Core requires install and update hooks such as hook_schema(), hook_install(), and hook_update_N() to stay plain functions in the .install file. The full list lives in the docblock of the Hook attribute class. Every other hook in this module is a class method (step 6).
Step 2: Write the storage service
Section titled “Step 2: Write the storage service”Next, the class the controller has been waiting for. A service is an ordinary PHP object the container knows how to construct; this one encapsulates every query against the event_rsvp table so the rest of the module never touches SQL. Create web/modules/custom/event_rsvp/src/RsvpStorage.php:
<?php
declare(strict_types=1);
namespace Drupal\event_rsvp;
use Drupal\Component\Datetime\TimeInterface;use Drupal\Core\Cache\CacheTagsInvalidatorInterface;use Drupal\Core\Database\Connection;
/** * Reads and writes RSVP records using the Database API. * * Registered as the "event_rsvp.storage" service in event_rsvp.services.yml. */class RsvpStorage {
/** * Constructs the RSVP storage. * * @param \Drupal\Core\Database\Connection $database * The active database connection. * @param \Drupal\Component\Datetime\TimeInterface $time * The time service. * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cacheTagsInvalidator * The cache tags invalidator. */ public function __construct( protected readonly Connection $database, protected readonly TimeInterface $time, protected readonly CacheTagsInvalidatorInterface $cacheTagsInvalidator, ) {}
/** * Records an RSVP for a node. * * @param int $nid * The event node ID. * @param int $uid * The attendee's user ID, 0 for anonymous visitors. * @param string $mail * The attendee's email address. * * @return int * The ID of the new RSVP record. */ public function add(int $nid, int $uid, string $mail): int { $id = $this->database->insert('event_rsvp') ->fields([ 'nid' => $nid, 'uid' => $uid, 'mail' => $mail, 'created' => $this->time->getRequestTime(), ]) ->execute(); // Anything rendered from this data — like the RSVP count block — tags // itself with this cache tag, so invalidating it here keeps every // cached copy accurate. See RsvpStorage::cacheTag(). $this->cacheTagsInvalidator->invalidateTags([static::cacheTag($nid)]); return (int) $id; }
/** * Checks whether an email address already responded to a node. * * @param int $nid * The event node ID. * @param string $mail * The email address to check. * * @return bool * TRUE if an RSVP exists for this node and email address. */ public function exists(int $nid, string $mail): bool { $count = $this->database->select('event_rsvp', 'r') ->condition('r.nid', $nid) ->condition('r.mail', $mail) ->countQuery() ->execute() ->fetchField(); return (int) $count > 0; }
/** * Counts the RSVPs recorded for a node. * * @param int $nid * The event node ID. * * @return int * The number of RSVPs. */ public function countForNode(int $nid): int { $count = $this->database->select('event_rsvp', 'r') ->condition('r.nid', $nid) ->countQuery() ->execute() ->fetchField(); return (int) $count; }
/** * Loads all RSVP records for a node, newest first. * * @param int $nid * The event node ID. * * @return object[] * RSVP rows with id, nid, uid, mail and created properties. */ public function listForNode(int $nid): array { return $this->database->select('event_rsvp', 'r') ->fields('r', ['id', 'nid', 'uid', 'mail', 'created']) ->condition('r.nid', $nid) ->orderBy('r.created', 'DESC') ->execute() ->fetchAll(); }
/** * Deletes every RSVP recorded for a node. * * @param int $nid * The event node ID. */ public function deleteForNode(int $nid): void { $this->database->delete('event_rsvp') ->condition('nid', $nid) ->execute(); $this->cacheTagsInvalidator->invalidateTags([static::cacheTag($nid)]); }
/** * Returns the cache tag for one node's RSVP data. * * @param int $nid * The event node ID. * * @return string * The cache tag, for example "event_rsvp:node:42". */ public static function cacheTag(int $nid): string { return 'event_rsvp:node:' . $nid; }
}The constructor asks for three services and stores them as readonly promoted properties:
\Drupal\Core\Database\Connectionis the active database connection. Itsinsert(),select(), anddelete()methods build queries fluently and safely; user input enters throughcondition()andfields()values, never through string interpolation. The table name is unprefixed in code; the connection applies any site prefix.\Drupal\Component\Datetime\TimeInterfacereplaces callingtime()directly. The payoff comes in part 7: a test can control what “now” means.\Drupal\Core\Cache\CacheTagsInvalidatorInterfaceis the write half of Drupal’s cache-tag system.add()anddeleteForNode()end by invalidating the tag fromcacheTag(), which declares “the RSVP data for this node changed” to every cache in the system. Nothing listens to that tag yet; part 5 builds the block that does, and part 2’s report page already attached the same tag to its render array.
cacheTag() is the single source of truth for the tag’s format (event_rsvp:node:42). Producers and consumers of the tag both call it, so they can never drift apart.
Step 3: Register the service and create the table
Section titled “Step 3: Register the service and create the table”A class only becomes a service when the container is told about it. Create web/modules/custom/event_rsvp/event_rsvp.services.yml:
services: event_rsvp.storage: class: Drupal\event_rsvp\RsvpStorage arguments: - '@database' - '@datetime.time' - '@cache_tags.invalidator' # Alias the class name to the service so autowired classes — like the # #[Hook] implementations in src/Hook — can receive it by type hint. Drupal\event_rsvp\RsvpStorage: '@event_rsvp.storage'The event_rsvp.storage entry gives the service its ID, its class, and its constructor arguments; each @name is a reference to another service. This is exactly the list the constructor in step 2 declares, in order.
The second entry is an alias: it registers the fully qualified class name as another ID for the same service. Autowired classes (such as the hook class in step 6, or any class using \Drupal\Core\DependencyInjection\AutowireTrait) receive dependencies by matching constructor type hints against service IDs that are class or interface names. Without the alias, a type hint of RsvpStorage would resolve to nothing; with it, autowiring finds the storage service by its type. Two lines now, and no class in this module ever needs hand-wired arguments for it. The services and dependency injection page covers aliasing and autowiring in full.
The container compiles this file into the running container on a cache rebuild. The table, though, only gets created by hook_schema() at install time, so a rebuild is not enough: reinstall the module.
ddev drush pm:uninstall -y event_rsvpddev drush pm:install -y event_rsvpUninstalling drops the module’s tables and deletes its configuration. On a tutorial site that costs nothing; on a production site you would add the table with an update hook instead, which Custom database tables and update paths demonstrates.
Step 4: Start the form class
Section titled “Step 4: Start the form class”The route event_rsvp.rsvp_form from part 2 points its _form default at Drupal\event_rsvp\Form\RsvpForm. Form classes implement four methods: an ID, a builder, a validator, and a submit handler. Create src/Form/RsvpForm.php, starting with the same head every class so far has had: <?php, declare(strict_types=1);, the namespace Drupal\event_rsvp\Form, and use statements for FormBase, FormStateInterface, RsvpStorage, NodeInterface, and ContainerInterface.
The class extends \Drupal\Core\Form\FormBase and injects the storage service with the identical constructor-plus-factory pattern from part 2’s controller:
final class RsvpForm extends FormBase { public function __construct( protected readonly RsvpStorage $rsvpStorage, ) {} public static function create(ContainerInterface $container): static { return new static( $container->get('event_rsvp.storage'), ); }Then the form ID, which namespaces this form’s render element names and alter hooks across the whole site:
public function getFormId(): string { return 'event_rsvp_rsvp_form'; }The pattern transferring unchanged from controller to form is the lesson: routed forms are constructed through create() exactly like routed controllers, and dependency injection looks the same everywhere.
Step 5: Build, validate, and submit
Section titled “Step 5: Build, validate, and submit”Now the three working methods, in the order Drupal calls them. First the builder:
/** * {@inheritdoc} */public function buildForm(array $form, FormStateInterface $form_state, ?NodeInterface $node = NULL): array { // Stash the node ID so validate and submit handlers can read it back. $form['nid'] = [ '#type' => 'value', '#value' => $node?->id(), ]; $form['mail'] = [ '#type' => 'email', '#title' => $this->t('Email address'), '#description' => $this->t("We'll only use this to confirm your spot."), '#required' => TRUE, '#default_value' => $this->currentUser()->getEmail() ?? '', ]; $form['actions'] = [ '#type' => 'actions', ]; $form['actions']['submit'] = [ '#type' => 'submit', '#value' => $this->t('RSVP'), ]; return $form;}Because the route upcasts {node}, Drupal passes the loaded node straight into buildForm() as the $node argument; any route parameter whose name matches a buildForm() parameter arrives this way. The #type => 'value' element stores the node ID inside the form processing pipeline without rendering anything, which is how the validate and submit handlers (which do not receive route parameters) get it back. #type => 'email' gives you browser-side and server-side address validation for free, and #default_value pre-fills the address for logged-in users.
Validation handles what the element types cannot, the business rules:
/** * {@inheritdoc} */public function validateForm(array &$form, FormStateInterface $form_state): void { $nid = (int) $form_state->getValue('nid'); $mail = (string) $form_state->getValue('mail'); // The #type 'email' element already validated the address format. // Business rules belong here: one RSVP per address per event. if ($this->rsvpStorage->exists($nid, $mail)) { $form_state->setErrorByName('mail', $this->t('%mail has already responded to this event.', ['%mail' => $mail])); }}setErrorByName() attaches the error to the mail element, blocks submission, and re-renders the form with the message. The %mail placeholder escapes and highlights the value; never concatenate user input into a translated string.
The submit handler only runs when validation passed:
/** * {@inheritdoc} */public function submitForm(array &$form, FormStateInterface $form_state): void { $this->rsvpStorage->add( (int) $form_state->getValue('nid'), (int) $this->currentUser()->id(), (string) $form_state->getValue('mail'), ); $message = $this->config('event_rsvp.settings')->get('confirmation_message'); $this->messenger()->addStatus($message); $form_state->setRedirect('entity.node.canonical', ['node' => $form_state->getValue('nid')]);}It delegates the write to the storage service (the form knows nothing about SQL), shows a confirmation, and redirects to the node. The confirmation text comes from $this->config('event_rsvp.settings'), configuration that does not exist until part 4, so for now the message renders as an empty status box. You will watch that fix itself in the next part.
Try it: visit /node/1/rsvp. Submit an email address, land back on DrupalCon with an (empty) green status message, and reload /admin/reports/event-rsvp/1 to see the row. Submit the same address again and validation stops you.
A note on access: the route requires the rsvp to events permission, which is still undefined. As in part 2, your administrator account passes anyway; regular users get a 403 until part 6 defines the permission and grants it.
Step 6: Implement your first hook
Section titled “Step 6: Implement your first hook”One loose end: delete an event node and its RSVPs become orphaned rows. Drupal has two mechanisms for “react when something happens elsewhere”: hooks and event subscribers. They cover different moments rather than competing; kernel and configuration operations dispatch events, while entity lifecycle moments such as presave and delete fire hooks, so a hook is the tool here (Hooks and events maps the whole boundary). Since Drupal 11.1, hooks are methods on container-registered classes, marked with the #[Hook] attribute. Create src/Hook/EventRsvpHooks.php:
<?php
declare(strict_types=1);
namespace Drupal\event_rsvp\Hook;
use Drupal\Core\Hook\Attribute\Hook;use Drupal\event_rsvp\RsvpStorage;use Drupal\node\NodeInterface;
/** * Hook implementations for the Event RSVP module. * * Since Drupal 11.1, hooks live in autowired classes with the #[Hook] * attribute instead of procedural functions in the .module file. */class EventRsvpHooks {
/** * Constructs the hook implementation class. * * @param \Drupal\event_rsvp\RsvpStorage $rsvpStorage * The RSVP storage service. */ public function __construct( protected readonly RsvpStorage $rsvpStorage, ) {}
/** * Implements hook_ENTITY_TYPE_delete() for node entities. */ #[Hook('node_delete')] public function nodeDelete(NodeInterface $node): void { // Don't leave orphaned RSVP records behind when an event is deleted. $this->rsvpStorage->deleteForNode((int) $node->id()); }
}What makes this work:
- Any class under
src/Hook/with#[Hook]attributes is discovered automatically and registered as an autowired service. No services.yml entry, no.modulefile. #[Hook('node_delete')]declares the method as the module’s implementation ofhook_ENTITY_TYPE_delete()for nodes: the attribute argument is the hook name without thehook_prefix. Drupal calls it whenever a node is deleted, passing the node.- The constructor type-hints
RsvpStorage, and autowiring resolves it through the class-name alias you added in step 3. The two halves of this part meet here.
Hook discovery is cached, so rebuild:
ddev drush cache:rebuildTo see it fire, create a throwaway second article (“Test event”, it becomes node 2), RSVP to it at /node/2/rsvp, then delete it at Administer → Content/admin/content using the Delete action. The hook removes its RSVPs in the same operation.
Troubleshooting
Section titled “Troubleshooting”The report page still shows the ServiceNotFoundException
Section titled “The report page still shows the ServiceNotFoundException”The container has not been rebuilt, or the services file is misnamed. The file must be event_rsvp.services.yml next to the info file, with services: as its top-level key. Run ddev drush cache:rebuild.
“Base table or view not found: event_rsvp”
Section titled ““Base table or view not found: event_rsvp””The table was never created because the module was not reinstalled after you added hook_schema(). Repeat step 3’s uninstall/install pair.
The RSVP form returns 403 or 404
Section titled “The RSVP form returns 403 or 404”A 404 means the node ID in the URL does not exist (node: \d+ plus entity upcasting turn bad IDs into 404s). A 403 means you are not logged in as an administrator; the permission this route requires is defined in part 6.
The hook never fires
Section titled “The hook never fires”Hook implementations are cached in the container. Any time you add a hook class or a #[Hook] method, run ddev drush cache:rebuild. Also confirm the file path matches the namespace: Drupal\event_rsvp\Hook\EventRsvpHooks must live at src/Hook/EventRsvpHooks.php.
The status message after submitting is empty
Section titled “The status message after submitting is empty”Expected at this stage: the confirmation text lives in configuration the module does not ship yet. Part 4 adds it.
Next steps
Section titled “Next steps”- Part 4: configuration and schema gives the module settings, including that missing confirmation message.
- Build and alter forms is the full Form API how-to, including Ajax.
- Custom database tables and update paths covers schema changes after the first install.
- Convert procedural hooks to classes goes deeper on
#[Hook], ordering, and converting legacy modules.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.