← All artifacts
interactive

Code Samples

Production code I write by hand, side by side with the same code after an AI-assisted session. The engineering decisions stay mine. The surface area multiplies.

From service architecture to cache optimization to patterns shared across a codebase, my aim is to document thoroughly by default. Meaningful comments, source-linked references, docblocks that explain why rather than what. I learned every tool here by hand before automating any of it, from Git on the CLI to Drupal’s service container.

With AI tools available today, I leverage them to streamline that workflow. Commits land with the right structure in fewer tries. Tests generate against real method signatures. Sibling files that would have waited get updated in the same session. The code samples below show what that looks like.

View the full sample repo on GitHub

The walkthrough below is interactive and faster than reading the repo directly. I would recommend starting here and using the repo link above when you want to inspect a file end to end.

How to read these sections

Each section shows two versions of the same file: BY HAND and WITH AI. On a wide screen they sit side by side. On a phone, tap between BY HAND and WITH AI at the top of each section to swap the code in place. Highlighted bands mark regions worth comparing. Hover or tap a highlighted region to focus it and read the annotation explaining what changed and why. The rest of the code dims so the comparison is clear. Use the Tour toggle above each section to switch the annotations off and read the code on its own.

1. Same service. More shipped.

The BY HAND side is how I write under strict project deadlines: typed signatures, a detailed docblock on the public method, and a TODO where I knew the constructor refactor was the right call but not the priority. It ships. It works. The WITH AI side is what my AI workflow produces when I pass that same service through it: declare(strict_types=1), constructor injection, full PHPDoc on every method, an extracted interface for the test harness, and a Kernel test stub covering the defaults. The logic did not change. The documentation and testability surface did.

<?php

namespace Drupal\app_theme_toggle\Service;

class AppThemeService {

  /**
  * Get the user's theme toggle state.
  *
  * @param int $userID
  *   The user ID.
  *
  * @return string
  *   The user's theme toggle state. Defaults to "light-theme" when the
  *   user has never toggled the preference.
  */
  public function getUserThemeState(int $userID): string {
    // TODO: inject user.data through the constructor when we touch this
    // class again. Calling \Drupal::service() works but hides the
    // dependency.
    $userData = \Drupal::service('user.data');
    return $userData->get('app_theme_toggle', $userID, 'theme_state')
      ?? 'light-theme';
  }

/**
 * Set the user's theme toggle state.
 */
public function setUserThemeState(int $userID, string $themeState): ?string {
  try {
    $userData = \Drupal::service('user.data');
    $userData->set('app_theme_toggle', $userID, 'theme_state', $themeState);
    // Invalidate the cache so the next page render picks up the change.
    $tag = 'user:' . $userID . ':theme_state';
    \Drupal::service('cache_tags.invalidator')->invalidateTags([$tag]);
    return $themeState;
  }
  catch (\Throwable $e) {
    \Drupal::logger('app_theme_toggle')->error($e->getMessage());
    return NULL;
  }
}

// helper, used internally
protected function buildCacheTag(int $userID): string {
  return 'user:' . $userID . ':theme_state';
}
}
48 lines
01 / 02 BY HAND The service ships and works. Typed signatures, a full docblock on the public method, and clear separation between the getter and setter. What it does not have: strict_types, constructor injection, or an interface. Those were scoped out because the refactor would have touched the service definition YAML, every existing test mock, and required a new interface file. Under strict project deadlines, shipping the feature took priority over the ceremony around it.
02 / 02 BY HAND \Drupal::service() is Drupal's service locator. It works, but it hides the dependency from the constructor signature, which means the class cannot be instantiated in a test without bootstrapping the full container. I knew this when I wrote it. The TODO names the exact refactor, the reason it matters, and what the next engineer needs to do. That specificity is intentional: a TODO that says "fix this later" helps nobody.
<?php

declare(strict_types=1);

namespace Drupal\app_theme_toggle\Service;

use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\user\UserDataInterface;

class AppThemeService implements AppThemeServiceInterface {

public function __construct(
  protected UserDataInterface $userData,
  protected CacheTagsInvalidatorInterface $cacheTagsInvalidator,
  protected LoggerChannelFactoryInterface $loggerFactory,
) {}

/**
 * Get the user's theme toggle state.
 *
 * Defaults to "light-theme" when the user has never toggled.
 *
 * @param int $userID
 *   The user ID.
 *
 * @return string
 *   The active theme state string.
 */
public function getUserThemeState(int $userID): string {
  return $this->userData->get('app_theme_toggle', $userID, 'theme_state')
    ?? 'light-theme';
}

/**
 * Set the user's theme toggle state and invalidate the render cache.
 *
 * @param int $userID
 *   The user ID.
 * @param string $themeState
 *   The theme state to persist (e.g. "dark-theme").
 *
 * @return string|null
 *   The persisted state, or NULL on failure.
 */
public function setUserThemeState(int $userID, string $themeState): ?string {
  try {
    $this->userData->set('app_theme_toggle', $userID, 'theme_state', $themeState);
    $this->cacheTagsInvalidator->invalidateTags([$this->buildCacheTag($userID)]);
    return $themeState;
  }
  catch (\Throwable $e) {
    $this->loggerFactory->get('app_theme_toggle')->error($e->getMessage());
    return NULL;
  }
}

/**
 * Build the per-user cache tag for theme state.
 *
 * Centralised so the REST resource and the kernel test invalidate the
 * same string.
 *
 * @param int $userID
 *   The user ID.
 *
 * @return string
 *   A cache tag in the form "user:{uid}:theme_state".
 */
protected function buildCacheTag(int $userID): string {
  return 'user:' . $userID . ':theme_state';
}
}
73 lines
01 / 02 WITH AI During a pass through with AI, the class gained constructor injection via three typed interfaces, strict_types at the file level, and full PHPDoc on every method. It now implements AppThemeServiceInterface, making it testable in isolation without bootstrapping Drupal. Also generated in the same session: the interface file and a Kernel test stub covering the default theme state.
02 / 02 WITH AI The AI agent replaced the service locator with $this->userData, injected at construction through UserDataInterface. The dependency is declared in the constructor signature, typed against an interface, and follows dependency inversion. Any test can now instantiate this service with a mock and no container. The TODO is gone because the refactor is done.
Same logic, same file. Also generated in this session: AppThemeServiceInterface.php, tests/Kernel/AppThemeServiceTest.php (3 cases).

2. The diagnosis is mine. The coverage is not.

The docblock that explains the session-messenger leak is entirely hand-written, and it is the most valuable thing in the file. The code fix is small. What I did not have time for was the regression test covering both branches of the _wrapper_format check, or the CLAUDE.md addendum so the next session does not have to rediscover the raw-query-string gotcha. The WITH AI side has both. The diagnosis is unchanged. The coverage around it is the agent’s contribution.

<?php

declare(strict_types=1);

namespace Drupal\app_modal_ui\EventSubscriber;

use Drupal\Core\Messenger\MessengerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;

/**
* Clears session messenger for modal AJAX responses.
*
* When forms are submitted inside a modal dialog, Drupal's form validation
* and submission handlers add messages to the session-based messenger.
* These messages are normally consumed on the next full page render, but
* in a modal AJAX context they are never consumed, causing them to "leak"
* and appear unexpectedly on the next page the user visits.
*
* This subscriber runs after the response is built and clears any
* remaining session messages for modal requests. The messages are already
* delivered to the client via AJAX MessageCommand and shown in the modal
* by the JS layer.
*/
class AppModalMessengerSubscriber implements EventSubscriberInterface {

public function __construct(
  protected MessengerInterface $messenger,
) {}

public function onResponse(ResponseEvent $event): void {
  // Only act on the main request, not sub-requests rendered through
  // the kernel (BigPipe, ESI, etc.).
  if (!$event->isMainRequest()) {
    return;
  }

  $request = $event->getRequest();
  // Pull the wrapper format from the query string. drupal_modal is set
  // by the dialog open call, drupal_ajax is what ajax.js switches to
  // when a form rebuilds.
  $wrapperFormat = $request->query->get('_wrapper_format');

  // Case 1: original modal open request.
  if ($wrapperFormat === 'drupal_modal') {
    $this->messenger->deleteAll();
    return;
  }

  // Case 2: AJAX form rebuild from a modal context. The wrapper format
  // is now drupal_ajax, but the raw QUERY_STRING still carries the
  // original drupal_modal value.
  if ($wrapperFormat === 'drupal_ajax') {
    $rawQuery = $request->server->get('QUERY_STRING', '');
    if (str_contains($rawQuery, '_wrapper_format=drupal_modal')) {
      $this->messenger->deleteAll();
    }
  }
}
}
60 lines
01 / 01 BY HAND This docblock explains a bug the code alone cannot reveal. When a form submits inside a modal dialog, Drupal's messenger queues status messages into the session. In a modal AJAX context they never get consumed, so they leak onto the next page the user visits. I traced this through the messenger service, the AJAX response pipeline, and the raw QUERY_STRING edge case. Every sentence here is from that investigation. The fix shipped. The regression test did not. Under strict project deadlines, the subscriber worked and I moved on.
<?php

declare(strict_types=1);

namespace Drupal\app_modal_ui\EventSubscriber;

use Drupal\Core\Messenger\MessengerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;

/**
* Clears session messenger for modal AJAX responses.
*
* When forms are submitted inside a modal dialog, Drupal's form validation
* and submission handlers add messages to the session-based messenger.
* These messages are normally consumed on the next full page render, but
* in a modal AJAX context they are never consumed, causing them to "leak"
* and appear unexpectedly on the next page the user visits.
*
* This subscriber runs after the response is built and clears any
* remaining session messages for modal requests. The messages are already
* delivered to the client via AJAX MessageCommand and shown in the modal
* by the JS layer.
*/
class AppModalMessengerSubscriber implements EventSubscriberInterface {

public function __construct(
  protected MessengerInterface $messenger,
) {}

public function onResponse(ResponseEvent $event): void {
  if (!$event->isMainRequest()) {
    return;
  }

  $request = $event->getRequest();
  $wrapperFormat = $request->query->get('_wrapper_format');

  if ($wrapperFormat === 'drupal_modal') {
    $this->messenger->deleteAll();
    return;
  }

  // During AJAX form rebuilds, Drupal's ajax.js changes the wrapper
  // format to drupal_ajax, but the original drupal_modal is still in
  // the raw query.
  if ($wrapperFormat === 'drupal_ajax') {
    $rawQuery = $request->server->get('QUERY_STRING', '');
    if (str_contains($rawQuery, '_wrapper_format=drupal_modal')) {
      $this->messenger->deleteAll();
    }
  }
}
}
54 lines
01 / 01 WITH AI With AI in the loop, the diagnosis stayed untouched and the missing coverage shipped in the same session. A unit test now covers both branches of the _wrapper_format check: the direct drupal_modal case and the AJAX rebuild edge case where the wrapper format changes mid-request. A CLAUDE.md addendum documents the raw-query-string behavior so the next engineer or agent session starts with context instead of rediscovery.
Same diagnosis, same file. Also generated: tests/Unit/AppModalMessengerSubscriberTest.php (2 cases), CLAUDE.md 'Gotchas' addendum.

3. One decision, five files, one session

I extracted the trait. That was my call: three copies of the same isModal() check across sibling form classes, and the fourth copy made it a refactor I owed the next engineer. The BY HAND side is what I would realistically ship solo under strict project deadlines: one form cleaned up, four siblings still inlined, trait saved for “next sprint.” The WITH AI side is the same extraction propagated to all five forms, with each form’s $modalSuccessMessage set correctly, the trait file tested, and a README section added so the pattern is discoverable without reading the trait.

<?php

namespace Drupal\app_records\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\CloseModalDialogCommand;
use Drupal\Core\Ajax\MessageCommand;
use Drupal\Core\Ajax\RedirectCommand;

class RecordEditForm extends FormBase {

protected function isModal(): bool {
  $request = \Drupal::request();
  $format = $request->query->get('_wrapper_format');
  if ($format === 'drupal_modal') {
    return TRUE;
  }
  if ($format === 'drupal_ajax') {
    $raw = $request->server->get('QUERY_STRING', '');
    if (str_contains($raw, '_wrapper_format=drupal_modal')) {
      return TRUE;
    }
  }
  return FALSE;
}

public function buildForm(array $form, FormStateInterface $form_state) {
  // ... build fields ...
  if ($this->isModal() && isset($form['actions']['submit'])) {
    $form['actions']['submit']['#ajax'] = [
      'callback' => '::ajaxSubmit',
      'event' => 'click',
    ];
    $form['#attached']['library'][] = 'core/drupal.dialog.ajax';
  }
  return $form;
}

public function ajaxSubmit(array &$form, FormStateInterface $form_state) {
  $response = new AjaxResponse();
  $response->addCommand(new CloseModalDialogCommand());
  $response->addCommand(new MessageCommand('Record saved.', NULL, ['type' => 'status']));
  $destination = \Drupal::request()->query->get('destination') ?? '/';
  $response->addCommand(new RedirectCommand($destination));
  return $response;
}
}
49 lines
01 / 02 BY HAND Six use statements importing AJAX command classes that this form builds by hand: AjaxResponse, CloseModalDialogCommand, MessageCommand, RedirectCommand, plus FormBase and FormStateInterface. Every sibling form in this module carries the same six imports. The fourth copy is what crossed the threshold from acceptable repetition to a refactor I owed the codebase. Under strict project deadlines, I extracted the trait from this one form and left the other four for next sprint.
02 / 02 BY HAND The AJAX submit handler builds the response manually: close the modal dialog, show a status message, read the destination from the query string, redirect. Three commands, same order, same logic in every sibling form. The only variation is the message string. Under strict project deadlines, this was the form I cleaned up. The other four still carry their own hand-built versions.
<?php

declare(strict_types=1);

namespace Drupal\app_records\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\app_modal_ui\AppModalTrait;

class RecordEditForm extends FormBase {

use AppModalTrait;

protected string $modalSuccessMessage = 'Record saved.';

public function buildForm(array $form, FormStateInterface $form_state) {
  // ... build fields ...
  return $this->addModalSupport($form);
}

public function ajaxSubmit(array &$form, FormStateInterface $form_state): AjaxResponse {
  return $this->buildDefaultModalAjaxResponse($form_state);
}
}
26 lines
01 / 02 WITH AI One pass through my AI workflow collapsed six imports to two: FormBase and AppModalTrait. The trait encapsulates the full modal AJAX pattern behind a clean API. Net result: thirty duplicated lines across five sibling forms became five one-line trait inclusions, each declaring only its own $modalSuccessMessage.
02 / 02 WITH AI The agent propagated the extraction to all five forms and replaced each hand-built response with a single method call. It set each form's success message, wrote trait-level tests, and added a "Modal forms" section to the README. Work that would have taken three or four focused sprints to schedule landed in one session.
Same form, same extraction. Also propagated: 4 sibling forms, AppModalTrait tests, README 'Modal forms' section.

How I write commits and comments by hand

My natural mode is verbose. When I write a comment, it usually explains why, not what, and when the why depends on a workaround I read somewhere, I include the source: a Stack Overflow answer, a Drupal.org issue, a GitHub issue, the README of the offending module. The next engineer on the file should be able to walk the same path I walked. Commits work the same way. Subject line says the change. The body says the reason and links the artifact that informed it. I would rather over-explain in writing than send someone hunting for context that lives in my head.

Contrary to popular convention, I do not write strict Conventional Commits, and on that point I share the reasoning laid out in this post by a fellow engineer . The format was designed to make changelog automation easier in an era when humans were the only readers of the log. My one carry-over from the convention is that commits always have a body explaining the why. With AI in the loop, a verbose body is more useful, not less: it costs more tokens but it gives the next session (and the next reviewer) a real anchor when something breaks. The accuracy is worth the tokens.

How AI streamlines my commits

The discipline does not change. The execution gets faster. With an AI agent in the loop I get better commit subjects in fewer tries, bodies that hit the why without padding, and PHPdoc blocks generated against the actual signature and the actual surrounding pattern instead of a template. References to the Stack Overflow answer, Drupal issue, or in-repo .md spec stay verbatim because they are the load-bearing parts of the commit. The agent does not write the source citation; it makes sure I do not lose it during cleanup.

Refactor theme toggle to use constructor injection

The service was calling \Drupal::service('user.data') inside the
method body, which hides the dependency from the constructor
signature and makes the class untestable without bootstrapping
the full container.

Moved to constructor injection with UserDataInterface. Also
added strict_types and a cache tag helper.

See: https://www.drupal.org/docs/drupal-apis/services-and-dependency-injection
11 lines
Refactor AppThemeService to constructor injection

Replace \Drupal::service('user.data') calls with constructor-
injected UserDataInterface. The service locator hid the
dependency from the signature and prevented unit testing
without a full container bootstrap.

Changes:
- declare(strict_types=1)
- Constructor injection: UserDataInterface,
CacheTagsInvalidatorInterface, LoggerChannelFactoryInterface
- Extract AppThemeServiceInterface for test doubles
- Add Kernel test covering default theme state fallback
- Centralize cache tag builder with docblock explaining
why the tag string is shared across REST + test

Ref: https://www.drupal.org/docs/drupal-apis/services-and-dependency-injection
Ref: https://www.drupal.org/project/drupal/issues/2142515
18 lines

How I work with AI

Below is a sample CLAUDE.md (for Codex, AGENTS.md). In practice, the real files are more advanced depending on the project. This one covers the basics: the stack, the conventions, the boundaries around what the agent should and should not do, and the rules around destructive actions.

CLAUDE.md (representative project guardrails)
AGENT GUIDE

CLAUDE.md

Read this before touching code in this repo.

Stack

PHP 8.2+, Drupal 10/11, vanilla JS (no framework), SCSS, Twig, YAML. PSR-12 / Drupal coding standards. Strict types on all new PHP.

Code conventions

  • Names carry the meaning. If you write a comment to explain a name, rename it instead.
  • Functions are short and single-responsibility. Extract once a function exceeds ~30 lines or holds two unrelated branches.
  • Inject dependencies through constructors. Do not call `\Drupal::service()` from inside business logic in new code.
  • New PHP files declare strict types and use typed properties with constructor property promotion.

Comments and documentation

  • Default to writing no comment.
  • Add a comment when why is non-obvious: a hidden constraint, a subtle invariant, a workaround for a specific bug, behavior that would surprise a future reader.
  • Do not restate what the code does. The reader knows the language.
  • Do not track the current task, fix, or ticket in code. That belongs in the commit message.
  • Every public method and class gets a docblock describing purpose and contract.

Commits

  • Subject line is imperative, under 70 characters.
  • Body is for why, not what. The diff shows what.
  • One sentence per commit. If the message needs paragraphs, the commit is too big. Split it.

What you should help with

  • Boilerplate (routing YAML, service definitions, plugin scaffolding).
  • Mechanical refactors (renames, extractions, threading a parameter through).
  • First-pass implementation of a clearly-specified design.
  • Tedious type-juggling and PHPStan-style cleanup.

What you must defer to me on

  • The shape of the solution: what classes exist, what they expose.
  • Architectural decisions: trait vs base class, event subscriber vs hook, when to extract a service.
  • The diagnosis of bugs. Propose fixes after I describe the diagnosis, not before.
  • The "why" of any comment that ends up in the code.

Git safety

  • Never run `git reset --hard` while the working tree has uncommitted modifications outside the files intended to discard.
  • Never push without explicit instruction.
  • Never skip pre-commit hooks (`--no-verify`). If a hook fails, fix the underlying issue.

The CLAUDE.md above is the foundation. I also use a library of custom skills and workflows that are not listed here, but one thing stays constant: I review every Merge Request before accepting the code. Generated code I do not understand does not pass the MR.

For a deeper look at how I arrived at this workflow and how I think about AI in engineering, see Abe’s AI Methodology.

What this walkthrough leaves out

Two deliberate omissions worth naming. Testing is a continuous process I am still refining, and the test files in the source repo do not represent my current practice as well as the production code does. The module and class names you see here are redacted from the originals so they read as a pattern rather than a specific org’s product surface. I am happy to walk through any file in the repo in an interview.

View the full sample repo on GitHub
Search my experience