simplytest.me should be good enough to sell Drupal with
simplytest.me had been sitting in the back of my mind for a long time. I knew it was unstable, and that release information kept falling out of date. It was a constant battle that always needed another round of maintenance, and it kept sliding.
Then Ryan Szrama wrote about someone cold calling the Centarro phone number looking for a way to sell online. Commerce Kickstart already did what the caller needed, so instead of selling him an engagement, Ryan pointed him to simplytest.me to try it.
That was the kick I needed. The person Ryan sent over was a merchant with no Drupal background, deciding whether Drupal was worth his time off a sandbox alone. What he would have found worked, but not reliably, and it looked like a hobby project rather than a product anyone was standing behind.
The list was not short. Core versions stopped updating. The progress page hammered Tugboat. The advanced options had a handful of bugs that broke builds outright. The home page still led with a photo hero and yellow accents, the headline set on top of the photo where it was hard to read.
Over five days, I worked through most of that, using Claude Code for the implementation and Claude Design for the redesign. This is what shipped, and how. The full details are in the simplytestme/website pull requests, #579 through #608.
Start with tests, not the refactor
A large frontend rewrite and a backend cleanup were both coming. The custom modules sat at 52% line coverage, so the first PR was tests. Coverage went to 89%, and CI now fails under 85%. PHPUnit 9 has no built-in threshold, so scripts/coverage-check.php reads the Clover report and lists the least covered files when it fails.
Every new test is Unit or Kernel. Functional tests run in a separate process, so Xdebug never sees that code, and they cannot move the number. Kernel tests run the real container through http_kernel, so routing and controller wiring get covered instead of method bodies in isolation.
Writing tests against existing behavior found five real bugs before any refactoring started. The worst one: ProjectRefresher::processItem() caught EntityValidationException, but entity storage wraps preSave() throwables in EntityStorageException. The catch never fired, and a project failing validation took the whole queue worker down. PHPStan had the dead catch baselined.
try {
$project->save();
}
catch (EntityStorageException $e) {
// Entity storage wraps whatever preSave() threw, so the validation
// exception arrives as the previous exception rather than directly.
$validation_exception = $e->getPrevious();
if (!$validation_exception instanceof EntityValidationException) {
throw $e;
}
$this->logger->error(sprintf(
"Validation errors when saving project %s: %s",
$project->label(),
implode('|', $validation_exception->getViolationMessages())
));
}
The tests also found that every project route was going to Fastly as private. ModifyMaxAgeResponseSubscriber called setMaxAge() before core ran, Symfony appended private, and core skipped setResponseCacheable() because the header was already customized. The fix is entirely in the priority:
public static function getSubscribedEvents(): array {
return [
// In Symfony a higher priority runs earlier, so this has to be negative
// to run last. Core's FinishResponseSubscriber writes Cache-Control at
// priority 0 and http_cache_control adds s-maxage at -10; running before
// either of them means writing a header they then rebuild.
KernelEvents::RESPONSE => ['onResponse', -100],
];
}
Running last brought a second problem into view. The fastly module copies Cache-Control into Surrogate-Control at priority 0, and Fastly prefers Surrogate-Control over s-maxage, so the edge would have kept the site-wide 32-day lifetime. The subscriber rewrites that header's max-age to the CDN lifetime from http_cache_control.
Stop spamming Tugboat
The sandbox progress page polled its status endpoint every 3 seconds with setInterval. Each poll made the controller call the Tugboat API twice. Nothing stopped on a failed build, so a failed sandbox polled Tugboat every 3 seconds for as long as the tab stayed open.
Server-side, an in-progress job still returns an uncacheable JsonResponse, because a job that is still changing has to be re-read on every poll. What changed is the error handling and the finished case. A 404 from Tugboat returns a JSON body saying the sandbox is gone, and any other client or transport failure returns a 502 JSON body instead of an exception page, so the poller gets something it can parse either way.
Finished previews are the one state worth caching, and only briefly:
// A preview is a finished job: its state only changes when the sandbox
// expires, so browsers and the page cache may hold it briefly.
if ($instance_state['type'] === 'preview') {
$response = new CacheableJsonResponse($instance_state);
$metadata = (new CacheableMetadata())
->setCacheMaxAge(self::PREVIEW_MAX_AGE)
->addCacheContexts(['url.path']);
$response->addCacheableDependency($metadata);
// Core writes the site-wide max-age into Cache-Control regardless of
// the response's cacheability metadata; setting it here marks the
// header as customized so the finite lifetime survives.
$response->setMaxAge(self::PREVIEW_MAX_AGE);
return $response;
}
Sixty seconds, and finite on purpose. Sandboxes are deleted two hours after creation, so a permanently cached "ready" would keep redirecting people to a dead preview.
Which leaves the client as the thing that actually has to stop asking. Polling is now a chained setTimeout, so the next request is only scheduled after the current one finishes:
const poll = async () => {
let json;
try {
const res = await fetch(stateUrl);
if (!res.ok) {
throw new Error(`Status request failed with ${res.status}`);
}
json = await res.json();
} catch (e) {
failures += 1;
if (failures >= MAX_FAILURES) {
setError(true);
return;
}
// Back off so a struggling backend gets 6s, 12s, 24s, 48s of air.
schedule(POLL_INTERVAL * 2 ** failures);
return;
}
failures = 0;
setState(json);
// A preview means the job finished; a failed job never becomes one.
// Either way the state is final and polling must stop.
if (json.type === "preview") {
return;
}
schedule(POLL_INTERVAL);
};
Failed builds, 404s, and previews are all terminal states. Errors back off exponentially and give up after five straight failures.
Stop spamming Drupal.org
This one was worse, and it was the real reason core versions had gone stale. simplytest.me generated around 36,000 requests a day against the Drupal.org JSON API (api-d7):
- The refresher fetched every project's usage count individually: 250 projects every 10 minutes, requeued after 4 hours.
- Cron crawled paginated core release listings for five majors.
- The autocomplete imported from Drupal.org on every unmatched search string.
That is one environment. simplytest.me runs on amazee.io's Lagoon, which builds an environment per pull request, and those ran drush cron too. Every open Dependabot PR was another copy of the core release crawl.
Then requests started failing. Our user agent is supposed to be allowed through, but something was blocking one or two of our egress IPs anyway. Cron's core version updates failed silently.
The fix removes every background and implicit api-d7 request. What remains is explicit user action.
Refresher diet. ProjectRefresher updates release data only, using conditional GETs against updates.drupal.org rather than the API that was failing. The usage count refresh is deleted: usage only orders autocomplete results, and relative popularity does not change fast enough to justify that traffic. Cron queues projects stale by a week instead of 4 hours, capped at 250 instead of 1250.
Freshness on demand. The versions and compatibility endpoints refresh a project's releases inline when they are more than 6 hours stale. Data is fresh exactly when someone looks at a project.
Explicit lookup. The autocomplete only searches known projects. When nothing matches, the form offers "Look up on drupal.org", backed by a POST endpoint flood-limited to 20 lookups an hour per client. Deep links like /configure?project=pathauto go through the same endpoint, so maintainers linking to their own project still get an import.
Core versions from release history. CoreVersionManager::updateData() reads the release history feed instead of crawling api-d7:
$channel = $major_version === 7 ? '7.x' : 'current';
$release_xml = $this->fetcher->getProjectData('drupal', $channel, self::STATE_KEY_SUFFIX);
The current channel carries every 8+ major in one document, so a single conditional request refreshes them all, and the later calls for other majors short-circuit on the 304. That fixed #3593391 without needing to know what was blocking us. I asked in #drupal-infrastructure afterward, and Neil Drumm didn't see any 403s from our user agent at the WAF, so whatever it was didn't show up there either. By then, the traffic was gone and updates were working again, so I didn't press it.
Redesign with Claude Design
I gave Claude Design two screenshots (the home page and the form with advanced options open), connected it to the repo so it could read the existing React and PostCSS, and one prompt:
Help me redesign Simplytest.me, the service that lets users test out Drupal. The design and form aren't very friendly.
What came back first was questions, not designs. My answers:
audience: First-timers evaluating Drupal, agency folks doing client demos, trainers and camp presenters
problems: Form is intimidating, advanced options are a dumping ground, hero image hurts legibility, weak visual hierarchy
scope: Rethink the whole page
form_shape: Card with progressive disclosure
screens: Home / launch form, advanced options expanded
brand: Open to a full rebrand
variations: One strong direction
notes: Centarro is using this as a way to sell Drupal Commerce, it can be a way to easily test Drupal CMS, and I want to add the ability to test Drupal Site Templates easily
Before anything new got drawn, I had it recreate the current site from the theme's React components and PostCSS, so there was a baseline to compare against. Then a second round on visual direction:
vibe: Clean product marketing. Light, generous space, big type
palette: Keep Drupal blue, drop the yellow
type: Sans + mono pairing
hero_hierarchy: Curated demos first, search second
background: Solid or subtle-gradient hero
That was all the input. The output was a handoff bundle: the redesign as HTML with six labeled screens (home, browse site templates, advanced options, building, sandbox ready, build failed), the current-site recreation, and a README with exact tokens. Every color has a hex and a use. Every type role has size, weight, and tracking. The README is explicit that the HTML is a design reference, not production code, and that the target is the existing React components and Tailwind theme with no new framework.
I handed that bundle to Claude Code and asked it to rebuild the frontend from the handoff. PR #586 came in at +11,047/-35,461. What changed:
- The home page leads with tiles (Drupal CMS, Commerce Kickstart, Umami), and the project search comes second. The
OneClickDemoplugin gaineddescription,weight, andrecommendedproperties so the tiles are data-driven. - Advanced options are grouped into Environment, Patches, and Extra projects, each with an explanation column.
- The progress page has dedicated Build, Ready, and Failed screens. The five-step checklist derives from stage markers already in the build log, so no backend progress work was needed.
- The failed screen offers "Edit and try again" and "Launch without the patch" prefills. The ready screen offers a shareable relaunch link.
- Header, hero, and footer are hardcoded inTwig. The theme settings form and layout block config are gone. Claro is no longer the base theme.
- Laravel Mix and Gulp are replaced by one Vite build. Plus Jakarta Sans and Space Mono are self-hosted. All tokens live in
tailwind.config.js.
The site templates tile ships with a disabled "Browse templates" button. The picker modal is designed but deliberately not built until template launches are supported.
Fix the patch bugs
Two Drupal.org issues, one of which had been open since March 2023.
#3348026: selecting an additional project without patching it broke the build. The form renders a patch field for every project and posts them verbatim, so every unpatched project submitted an empty string, and the generator turned each one into a real patch entry. composer-patches takes an empty URL at face value and fails the whole update. The documented workaround was to paste a throwaway patch URL into every field.
private function getSubmittedPatches(array $patches): array {
$urls = [];
foreach ($patches as $patch) {
$patch = trim((string) $patch);
if ($patch !== '') {
$urls[] = $patch;
}
}
return $urls;
}
Dropping empties in PreviewConfigGenerator rather than the form covers the JSON POST, ?patch= prefill links, and one-click demos in one place.
#3588836: patches that used to apply stopped applying. Sandboxes required szeidler/composer-patches-cli at ~1.0, which now resolves to a version allowing cweagans/composer-patches 2.x. Sandboxes silently moved to 2.x. 1.x tried several -p levels and fell back to GNU patch, which tolerates fuzz. 2.x uses git apply at one fixed depth, and git apply has no fuzz at all.
The CLI only existed to edit JSON we already generate, so it is gone. The module writes patches.json directly, which unlocks the expanded format the CLI could not emit:
{"patches": {"drupal/core": [{
"description": "STM patch 5876.diff",
"url": "https://git.drupalcode.org/project/drupal/-/merge_requests/5876.diff",
"depth": 2,
"extra": {"freeform": {
"executable": "patch",
"dry_run_args": "-p%s -d %s --dry-run --no-backup-if-mismatch -i %s",
"args": "-p%s -d %s --no-backup-if-mismatch -i %s"
}}
}]}}
Depth is explicit per package. The freeform patcher falls back to GNU patch only after the git patchers refuse, restoring the 1.x behavior without pinning to 1.x. Merge request .patch URLs are rewritten to .diff, because the .patch form is a full commit series and any file it touches outside the patch depth aborts the apply. Failed patches now say why, with the actual Hunk #1 FAILED line on the failure page instead of Composer's usage synopsis.
Validate what the launch endpoint accepts
The launch endpoint accepted any non-blank string for the core version and install profile. The form only ever sends known releases and one of three profiles, but the endpoint is an anonymous JSON POST and the form is not its only possible client. Tugboat accepts a preview before the installer runs, so a made-up profile still produced a "successful" launch, and the value is interpolated straight into drush si.
The install profile is now a Choice constraint over standard, minimal, and demo_umami. The core version gets a CoreVersion constraint that checks the release exists in the same table the dropdown reads:
#[Constraint(
id: 'CoreVersion',
label: new TranslatableMarkup('Drupal core version', [], ['context' => 'Validation']),
)]
final class CoreVersionConstraint extends SymfonyConstraint {
public string $message = 'There is no Drupal core release with the version @version.';
}
Attribute-based discovery, not the annotation the older PatchesUrl constraint still uses. The validator resolves CoreVersionManager through ContainerInjectionInterface. It returns early on an empty string, since NotBlank already reports that and one message per field is enough.
This mattered for the next change. Anything submitted here ends up in the sandbox's Composer command and on a public page.
Record what gets launched
#3541515 asked for usage analytics. The issue pointed at Tugboat's statistics endpoints. Those answer how much: active previews, build durations, failure rates. They cannot answer what. Every preview is named simplytest and built from the base branch. The project, version, and core release live inside the build config, which Tugboat does not index. So the composition data has to come from us.
Every launch now writes a row through LaunchRecorder, called from InstanceManager::launchInstance(). That location is deliberate: one-click demos go straight from their controller to the instance manager and never touch the launch form. InstanceManager is the one point both paths reach. Failed launches are recorded too, because a launch that never reached Tugboat leaves nothing behind there. A failed insert is logged and never thrown, so analytics cannot turn a working launch into an error page.
The record holds what was launched, never who. No IP, no user agent, no session. Patch counts, not patch URLs, because a patch URL names the issue somebody is testing. The schema test asserts that stays true:
// The launch record deliberately holds nothing that identifies a person.
$columns = array_keys($table['fields']);
self::assertNotContains('hostname', $columns);
self::assertNotContains('uid', $columns);
self::assertNotContains('patches', $columns);
self::assertContains('patch_count', $columns);
The report is at simplytest.me/statistics: totals for 7 days, 30 days, and all time, a per-day chart, and breakdowns by project, core release, install profile, and project type. It is plain aggregate queries in a service, not Views, rendered through the site's first single directory component. The report is cached by time, never by tag, since tagging would invalidate it on every launch.
What is next
Site templates and Drupal recipes. Both are Drupal.org projects, so the existing shortname lookup already finds them. What does not fit is the shape of a launch. Today, that is one project on a core version, and a recipe launch is core with a set of recipes applied on top- three of them, if that is what someone wants to try.