Being on Drupal 11 doesn't tell you how long your particular release will receive security fixes. A new support policy gives website teams less time to move between minor versions. Here's what to check and how to make room for that work.
Have you ever wanted a full inventory of everything your Drupal site is built from, checked against public security advisories, and exportable as a standard SBOM? There's a module for that.
How old: created in Oct 2023 by Paolo Mainardi (paolomainardi) of SparkFabrik, and rewritten this fall
Versions available: 1.0.0-beta4, which works with Drupal 11.3 and 12
Maintainership
Actively maintained, latest release and latest commit both just this past Sunday
Security coverage: opted in, though there's no stable release yet
Test coverage
Documentation: an in-depth README, plus separate docs on the advisory sources and on scanning in CI
Number of open issues: 4 open issues, none of which are bugs, and one of them is the plan for a stable release
Usage stats:
9 sites
Module features and usage
SBOM stands for software bill of materials, a list of every component a site is built from, with its version, license, and source. Auditors, security teams, and customers are asking for them more and more
DruBOM takes a snapshot that covers the PHP runtime and its extensions, Composer packages, Drupal modules and themes, asset libraries like jQuery and CKEditor 5, and npm, Yarn, and pnpm lockfiles in your themes and modules
Anything it can't read shows up as a coverage gap. Gaps are always listed, never hidden
Advisory checks are opt-in. You can check against drupal.org security advisories, the Packagist advisory database, OSV.dev for npm, and the PHP project's own advisories
Packages from private repositories are never sent out, and you can list private npm scopes to keep them out of advisory checks too
Findings that appear in the CISA or ENISA catalogs of known exploited vulnerabilities get flagged and go to the top of the list
For each finding you can record a decision on whether your site is actually affected, called a VEX statement. A decision applies only to that exact package version and gets confirmed again after each new snapshot
The admin dashboard under Reports has an interactive dependency map, so you can see which Drupal project brought in some deep npm package
For CI, drush drubom:check can fail a pipeline when open findings hit a severity threshold, and the export is a standard SPDX 3.0.1 document ready for auditors
Under the hood, it's all pure PHP now. The original version wrapped the Syft and Grype binaries, and the rewrite dropped them. Running drush updb saves your old reports as files before removing the old settings
There's also a bin/drubom-scan script that builds the SBOM from the codebase files without running any of the site's code. That's useful when an auditor shouldn't have to trust the site, or after a suspected compromise
Caveats: you need a private file system, and few security tools read SPDX 3 yet
Update Status in core is the obvious comparison. Both tell you when something on your site has a security advisory, but Update Status checks drupal.org release data for Drupal projects only. DruBOM builds its inventory from lockfiles and the codebase, so it also covers Composer, npm, libraries, and PHP, and matches advisories against exact versions
Native Observability started as a way to look inside Drupal with a dashboard of its own: route timings, cache and slow queries, measured by the site and read by a person on an admin page. Now an AI agent reads the same data. Ask Claude Desktop "why is this page slow?" and it fetches the numbers through the Model Context Protocol, read-only.
Handing a model the numbers is not enough, though. It reads them with common sense, and on Drupal metrics common sense fails in specific places. In my first blind comparison, 4 answers out of 5 took a drop caused by the page cache for lost traffic, even with the right instructions in front of them.
Better instructions alone did not fix it. The site now computes the signals the model would get wrong, and explains the rest with a vocabulary checked sentence by sentence against the code. In the last round, with the vocabulary on, Claude reached the right conclusion in 30 answers out of 30.
What leaves the site follows a simpler rule than a list of forbidden words: route names go out, never a URL or a parameter value. This article covers the measurements, the mistakes I found along the way, the questions you can ask and what you need to connect an agent to your own site.
Replacing a proprietary CMS does not automatically give governments control over their digital services. Five Government Summit speakers examine the responsibilities that come with adopting open source.
Universities can replace a CMS or reorganise their websites without knowing how difficult it would be to change providers again. The missing evidence lies in contracts, documentation and the cost of transferring technical responsibility.
Benchmarking Drupal: Mitigating Synchronous Database Locking in High-Concurrency Environmentsbhavinhjoshi
Drupal's architecture provides a highly refined routing and rendering ecosystem. When deployed behind caching layers like Varnish or Redis, serving read-heavy content is a solved problem. However, engineering mission-critical platforms, such as SLA-backed procurement portals, ticketing engines, or high-volume transaction systems, requires shifting the architectural focus from read-heavy delivery to high-concurrency write stability.
When utilizing Drupal for state management or complex relational entity saves during peak traffic, default synchronous mechanisms become a critical bottleneck.
The Core Culprit: Synchronous Key-Value Upserts
In a standard deployment, Drupal manages temporary data, system variables, and specific configurations through the State API. By default, this API utilizes the database-backed Key-Value store.
The architectural vulnerability lives directly inside \Drupal\Core\KeyValueStore\DatabaseStorage::doSet(), which handles the underlying database execution for the public set() method.
Every time \Drupal::state()->set() is called, it triggers a synchronous Upsert query. Under the hood, MySQL's InnoDB engine enforces row-level locks (via INSERT ... ON DUPLICATE KEY UPDATE mechanics) to maintain ACID compliance during these operations. Because the default architecture blocks the PHP thread until the database confirms the write, a severe queue forms at the database layer during concurrency spikes.
The Empirical Impact (Drupal 11 & 12 Architecture)
Architectural Note: Because the synchronous DatabaseStorage::set() mechanism and InnoDB locking architecture remain unchanged in the Drupal 12.x branch, this Drupal 11 benchmark serves as a 1:1 representation of Drupal 12's concurrency limitations.
To isolate and prove this behavior, we benchmarked a standard Drupal application handling a high-volume transactional endpoint.
Testing Tool: Apache JMeter.
Load Profile: 200 concurrent threads executing heavy writes simultaneously.
Operation: A synchronous \Drupal::state()->set() wrapped in a database transaction to simulate a complex payload save.
The MySQL process list immediately revealed the bottleneck. Multiple independent database connections stacked up in the process list, all attempting to execute the exact same query on the jmeter_lock_test row simultaneously. Because the first thread locked the row to perform its save, all subsequent threads were trapped waiting for it to release.
Figure 1: MySQL process list demonstrating severe thread queueing and InnoDB row locking.
This database queue directly correlates to a severe degradation in application performance. The application's throughput plummeted to just 4.6 requests per second. Threads queued relentlessly behind the locked row, pushing average application response times to a massive 33,310ms (33.3 seconds), with peak latency hitting 40,181ms.
Ultimately, the database connection pool saturated and began throwing lock wait timeout exceptions. The application buckled, resulting in a 29.99% failure rate where nearly a third of all transactions were dropped entirely.
Figure 2: Apache JMeter Summary Report demonstrating catastrophic latency and a 29.99% error rate.
The Enterprise Mitigation: Asynchronous Decoupling
To scale past this limitation and guarantee 99.99% uptime, the architecture must transition from synchronous database dependence to an asynchronous state model. In our high-concurrency deployments, we mitigate this bottleneck by decoupling the State API from MySQL entirely and offloading it to a highly available Redis cluster, combined with asynchronous message queuing for relational entity writes.
1. In-Memory State Offloading
By overriding the core state service in the application's services.yml, we reroute these synchronous database calls into memory. Redis handles tens of thousands of concurrent atomic operations per second without the locking overhead of a relational database.
# services.yml
services:
# Override the default database-backed state service
state:
class: Drupal\redis\State\State
arguments: ['@keyvalue', '@redis.client.factory', '@redis.settings']
2. Asynchronous Message Queueing
For mission-critical entity writes (e.g., bid submissions, order checkouts), we completely remove the write operation from the primary PHP thread. Instead, we serialize the payload and push it to a RabbitMQ broker via a custom service.
// High-Concurrency Transaction Controller
public function submitTransaction(Request $request) {
$payload = $request->getContent();
// 1. Acknowledge receipt instantly (0ms database lock)
$this->rabbitMqService->publish('transaction_queue', $payload);
// 2. Return 202 Accepted to the user immediately
return new Response('Transaction queued successfully.', 202);
}
The Core Patch Proposal: Implementing DestructableInterface
While external infrastructure like Redis is standard for enterprise deployments, Drupal core should natively implement a Deferred Write-Behind Strategy within the State API to protect all environments out of the box.
By analyzing the production \Drupal\Core\State\State class (which extends CacheCollector), we can surgically remove the synchronous database bottleneck. Rather than executing $this->keyValueStore->set() immediately, the class should queue the updates in memory, allow the CacheCollector to handle the static request caching, and defer the actual database upsert() to the kernel termination phase using Drupal's native DestructableInterface.
// 1. Add the interface
use Drupal\Core\DestructableInterface;
class State extends CacheCollector implements StateInterface, DestructableInterface {
// 2. Add the queue property
protected array $deferredWrites = [];
// 3. Update set() to queue the write rather than blocking the thread
public function set($key, $value) {
if (isset(self::$deprecatedState[$key])) {
@trigger_error(self::$deprecatedState[$key]['message'], E_USER_DEPRECATED);
$key = self::$deprecatedState[$key]['replacement'];
}
$this->registerKeySetDuringRequest($key, $value, parent::get($key));
// Queue the database write instead of executing it synchronously
$this->deferredWrites[$key] = $value;
// Allow CacheCollector to manage the static request cache
parent::set($key, $value);
$this->persist($key);
static::updateCache();
}
// 4. Update setMultiple() to follow the same queuing logic
public function setMultiple(array $data) {
foreach ($data as $key => $value) {
$this->deferredWrites[$key] = $value;
$this->registerKeySetDuringRequest($key, $value, parent::get($key));
parent::set($key, $value);
$this->persist($key);
}
}
// 5. Execute the bulk database write after the HTTP response is sent
public function destruct() {
if (!empty($this->deferredWrites)) {
$this->keyValueStore->setMultiple($this->deferredWrites);
$this->deferredWrites = [];
}
}
}
Architectural Trade-off: Eventual Consistency
Moving writes to the KernelEvents::TERMINATE phase shifts the architecture from strict ACID consistency to Eventual Consistency. By removing the InnoDB row lock, concurrent writes rely on a "Last Write Wins" resolution at the end of the request lifecycle. Furthermore, if a PHP fatal error or server crash occurs before the termination phase, the in-memory state update will be lost despite the user receiving a successful HTTP response. While this trade-off is catastrophic for financial ledgers or strict relational entities, it is the exact right compromise for the State API, which primarily manages non-critical, self-correcting metadata.
By adopting this patch, Drupal core mathematically eliminates state-based database locking during the HTTP request. It consolidates multiple scattered state updates into a single, highly efficient setMultiple() bulk write executing safely in the background, yielding instant zero-lock latency for the end user.
The Bridge Mitigation: A Drop-in Custom Module
Until a write-behind strategy is adopted in core, engineering teams can patch this bottleneck today without requiring complex external infrastructure. By writing a lean custom module (deferred_state), we can decorate the core state service and implement the DestructableInterface ourselves, bypassing the relational lock entirely.
1. The Service Decoration (deferred_state.services.yml)
2. The Asynchronous Class (src/State/DeferredState.php)
namespace Drupal\deferred_state\State;
use Drupal\Core\State\State;
use Drupal\Core\DestructableInterface;
class DeferredState extends State implements DestructableInterface {
protected array $deferredWrites = [];
public function set($key, $value) {
// 1. Maintain CacheCollector request tracking
$this->registerKeySetDuringRequest($key, $value, $this->get($key));
// 2. Queue the write instead of calling $this->keyValueStore->set()
$this->deferredWrites[$key] = $value;
// 3. Update the underlying CacheCollector storage directly
$this->storage[$key] = $value;
$this->persist($key);
}
public function destruct() {
if (!empty($this->deferredWrites)) {
$this->keyValueStore->setMultiple($this->deferredWrites);
$this->deferredWrites = [];
}
}
}
The Engineering Standard
Relying on page caching to scale a platform only masks the symptoms of read-heavy technical debt. True enterprise stability requires engineering the underlying infrastructure to absorb massive, concurrent writes without latency. By isolating the exact code block responsible for database locking and actively decoupling it via deferred termination events or asynchronous messaging, we mathematically eliminate the locking cascade and ensure the technology stack acts as a commercial asset, not a liability.
Tag1 Founding Partner and CEO Jeremy Andrews, introduces Goose 0.19, which adds a live browser dashboard, pass/fail thresholds that can fail a CI build, response times to the microsecond, and peak memory cut from 959 MiB to 214 MiB at 1,000 users. Its companion crate, goose-eggs 0.7, adds helpers for load testing WordPress and WooCommerce sites.
Goose 0.19, the latest release of Tag1's open source load testing tool, lets you watch a load test in your browser while it runs. Response times, errors and active users update every second, so when a site starts to slow down you see it as it happens. Alongside it, goose-eggs 0.7 adds helpers for load testing WordPress and WooCommerce sites, and Goose itself can now fail a CI build when a site gets slower, keeps every response time to the microsecond, and runs in a quarter of the memory or less.
We compared Goose against k6 and Locust, all three in the same configuration of 1,000 users over HTTPS, running on the same two cores against the same web server. Goose sent 21,400 requests a second, against 15,800 for k6 and 8,400 for Locust, using less CPU per request than either and a fraction of the memory. The full results, and how we ran each tool, are found in this blog under the heading Compared with k6 and Locust.
An overview of what's new in Goose 0.19.
Watch a Load Test in Your Browser
There's no new code to write. Any Goose 0.19 load test gets the live web dashboard once you turn on the experimental dashboard feature in Cargo.toml:
goose={version="^0.19",features=["dashboard"]}
Then add --dashboard to the command line and open http://127.0.0.1:5118/. Out of the box the page is read only. To start and stop the test and add or remove users from the browser, add --dashboard-control with either an auth token or --dashboard-no-auth (fine on your own laptop, less fine on a machine you share, and Goose prints a warning whenever you use it). The dashboard listens on localhost unless you tell it otherwise.
A load test of the Drupal Umami demo site, started from the dashboard at 50 users, raised to 400 while it runs, then stopped, and the saved report opened.
A useful new addition is the heatmap. Each column is one second of response times, so you can see a slow tail grow (or a cache warm up) long before it moves the average. You can show it for all requests or pick one request, so if only the checkout page gets slow, you can focus on it.
Figure 1: The Dashboard Heatmap, One Column per Second of Response Times
Below that are tables of every scenario, every transaction and the 100 busiest requests, and an errors table that fills in as errors happen. If you run with coordinated omission mitigation on (--co-mitigation average, for example), the requests table and the headline figures switch to coordinated omission adjusted times once mitigation has recorded an event. When a run ends, the dashboard can save its report into that run's own folder, so the runs panel lists earlier runs to download and compare.
The dashboard is opt in for 0.19. We'd like it to be on by default in 1.0, so please try it out and provide feedback. In the meantime it's cheap to leave on. In our 1,000 user memory tests it added about 2 MiB of memory and no added CPU we could measure. The Goose Book's dashboard reference says how that grows with more request names and longer runs.
Figure 2: The Full Goose Dashboard Mid Run Against Drupal Umami, 200 Users
Load Testing WordPress and WooCommerce
For years Goose has had Drupal helpers in goose-eggs, its companion crate, and an example load test against Drupal's Umami demo site. WordPress runs a much bigger slice of the web (nearly 40% of websites, by W3Techs' count), so goose-eggs 0.7 adds helpers for both WordPress and WooCommerce. The WordPress side logs in and out, searches, comments, creates and updates posts through the REST API, and finds content from the sitemap. The WooCommerce side browses the shop, searches, adds to the cart through WooCommerce's Store API (keeping each user's nonce through cart and checkout, as the Store API requires), checks out, and visits My Account.
The new woocommerce example in goose-eggs uses the helpers to simulate browsing shoppers, buyers and returning customers, and its README lists what a WooCommerce shop needs to run it.
Shoppers browsing, adding to the cart and checking out, with each step's response times filling the dashboard.
Fail the CI Build When a Site Gets Slower
This one is less visual than the dashboard but is useful for day to day testing automation. Goose 0.19 adds thresholds: pass or fail rules given on the command line.
Rules can check any percentile, the average, the maximum, the error rate or requests per second, for the whole test or for one request (p99<2s for GET /checkout). Once all users are up and the test is holding at its target, Goose checks every rule once a second. The first rule to fail stops the test through Goose's killswitch, with a message naming the rule and what it saw. (If you'd rather run the whole test and judge it at the end, add --no-threshold-abort.)
Either way, a failed rule makes Goose return an error, so a load test whose main passes it up with ? (as every example does) exits nonzero, which is all a CI system needs to fail the build.
Two runs against the same site: one whose rules pass and exits 0, then one with a 50 ms p95 the site can't meet, which stops and exits 1.
A GitHub Actions job that runs your load test against staging on every pull request, and fails when the site gets slower, looks like this (it tests whatever staging is running, so deploy the change there first):
name: load-test
on:[pull_request, workflow_dispatch]jobs:goose:runs-on: ubuntu-latest
steps:-uses: actions/checkout@v7
-uses: dtolnay/rust-toolchain@stable
-uses: Swatinem/rust-cache@v2
-name: Run the load test with pass/fail rules
run:|
cargo run --release -- \
--host "${{ vars.STAGING_URL }}" \
--users 50 --increase-rate 10 --run-time 2m \
--quiet --threshold "p95<500ms" --threshold "error-rate<1%" \
--report-file report.html-name: Keep the report, pass or fail
if: always()
uses: actions/upload-artifact@v7
with:name: goose-report
path: report.html
The report is kept whether the job passes or fails, and it has a Thresholds section showing each rule, what it measured, and which one, if any, stopped the run.
Response Times to the Microsecond
Goose used to keep response times in whole milliseconds, which was fine for most websites but too coarse for very fast ones. Against a server answering in about 0.6 ms, 0.18.1 reported an average of 0.01 ms (lower than its own 1 ms minimum) and every percentile up to the 99th as 1 ms. 0.19.0 keeps every time to the microsecond (percentiles come from buckets, so they're never more than 1% off), and reports a median of 0.59 ms and a 99th percentile of 1.05 ms for the same server.
The console, HTML, Markdown and PDF reports and the dashboard show times under 10 ms to the hundredth of a millisecond. (0.19.0's JSON report still wrote its percentiles in whole milliseconds; 0.19.1 adds microsecond fields beside them.)
Figure 3: Goose 0.18.1 Response Times Against a Server Answering in About 0.6 ms, Every Percentile at 1 msFigure 4: Goose 0.19.0 Response Times Against the Same Server, to the Hundredth of a Millisecond
A Quarter of the Memory, or Less
Every release we try to make Goose cheaper to run, but this time the difference was big enough that we wanted real numbers before saying anything. So we ran Goose 0.18.1 and 0.19.0 on the same two cores of an arm64 machine in our Kubernetes cluster, against a small Rust web server on another machine. Each test ran five times per version, 20 minutes a run at 1,000 users and 10 minutes at 5,000, with the builds taking turns one run at a time. At 1,000 users, 0.19.0 with the dashboard on took a turn as well.
With 1,000 users requesting a page as fast as they could, 0.18.1 used 959 MiB of memory and 0.19.0 used 214 MiB. With a more realistic shape (5,000 users, each waiting 1 to 3 seconds between requests) 0.18.1 used 4,480 MiB and 0.19.0 used 763 MiB.
Figure 5: Peak Memory at 1,000 Users for Goose 0.18.1, 0.19.0, and 0.19.0 with the Dashboard
The biggest single piece of the old memory we could trace was TLS, even though these tests never used HTTPS. Every Goose user gets its own HTTP client (so each user keeps its own cookies and session, as a real browser would), and in 0.18.1 each of those clients had OpenSSL load and parse the system's whole certificate bundle into a store of its own. 0.19 moved to reqwest 0.13, which uses rustls, and building a client now costs a fraction of that.
CPU went down too, though less dramatically. In the 1,000 user test, 0.19.0 spent 9% less of its own CPU per request than 0.18.1, and ran 23% fewer instructions per request (at 5,000 users it was also 9% less of its own CPU, with about half the instructions). Requests per second went up about 5%, but we wouldn't lean on that one. Two builds of the same source varied by about 2% on this machine, and the kernel's network processing took more than half of those two cores.
We also left 0.19.0 running for 12 hours with the dashboard open. It sent a little over a billion requests with no failures, and Goose's count matched the web server's own count exactly. Memory went from 219 MiB at the first hour to 225 MiB at the twelfth. Goose keeps a per second history for its graphs, which explains some of that growth, and we're still looking into the rest. Requests per second, CPU per request and the 99th percentile stayed flat the whole time.
Figure 6: Memory over 12 Hours, 0.19.0 with the Dashboard Against an Earlier 0.18.1 Run
Compared with k6 and Locust
All three tools sent requests with no wait between them. Locust ran FastHttpUser with two worker processes, one per core, as its docs recommend for throughput. k6 ran its constant-vus executor and, as k6's guide to running large tests recommends, discarding response bodies to save memory. Most real sites are HTTPS, so the numbers below are over HTTPS with certificates fully checked (Locust's FastHttpUser skips the check unless you turn it on, so we turned it on).
Over HTTPS, 1,000 users, 2 cores
Goose 0.19.0
k6 2.3.0
Locust 2.47.0
Requests per second
21,432
15,838
8,415
CPU per request, tool and kernel
93 µs
126 µs
238 µs
Peak memory
198 MiB
1,388 MiB
919 MiB
The CPU row counts both the tool's own CPU (the user and system time charged to its processes) and the kernel's network processing for its connections (interrupt time not charged to the tool's processes), since both came out of the same two cores.
Over plain HTTP the order was the same for throughput and CPU, but Locust used a little less memory than Goose, 190 MiB against 212. Goose was higher because each of its 1,000 users builds its own HTTP client with a TLS certificate store (about 81 MiB in all) whether or not it ever speaks HTTPS. A load test that doesn't need cookies can avoid most of that cost, since set_client_builder_without_cookies gives every user one shared client.
The throughput and CPU gaps in the table may look smaller than you'd expect from a tool written in Rust. Asking for one tiny page as fast as possible puts a big share of each request's cost in the kernel's network processing, about 50 µs a request, and for Goose that was more than half of its CPU per request.
Counting only each tool's own CPU, Goose needed 41 µs per request over HTTPS, against 76 for k6 and 173 for Locust. Put another way, at 41 µs of its own CPU a request, one core is enough for Goose's own share of about 24,000 verified HTTPS requests a second. A real load test that logs in, fills carts and checks pages gives each tool more of its own work to do, so the gap in real world load tests is even wider.
Other Changes in 0.19
There's a lot more in this release than fits here, and the CHANGELOG has all of it. A few highlights:
Every run can write its reports and logs into its own folder, and logs can be gzipped (a request log at the default gzip compression level was about 60 times smaller than plain JSON).
PDF reports, with the pdf-reports feature.
--baseline-file compares a run against an earlier report, with the difference shown in the HTML, Markdown and PDF reports.
--increase-rate, --increase-time, --decrease-rate and --decrease-time replace --hatch-rate and --startup-time, so a test can ramp down as well as up without writing a test plan.
The telnet and WebSocket Controllers now listen only on localhost by default, and the WebSocket Controller is off unless you ask for it.
Some of these change how existing load tests behave, so if you're coming from 0.18, read the Goose Book's Upgrading From 0.18 page first.
What's Next
Gaggles, Goose's distributed load tests (a manager process coordinating any number of workers), will be reintroduced in the 0.20 release. From there, we will focus on a stable 1.0. If you try the dashboard or thresholds, we'd love to hear how they go, good or bad, in a GitHub issue. If you're new to Goose, the Goose Book is the place to start.
A Drupal n8n integration can send form submissions to a CRM, create Drupal drafts from approved content in other systems, and trigger downstream work after publication. Learn which modules and APIs to use and how to keep the workflows dependable.
Drupack promises a simpler way to run and deploy Drupal, but packaging the runtime is only part of the challenge. Creator Théodore Biadala discusses automated security updates, cross-platform testing, database performance, and the work needed to make the project practical for more developers.
A look under the hood of how Drupal CMS 2.2 recipes speak your language too
Did you know that before Drupal CMS 2.2, if you set up Drupal CMS in German and applied a recipe, its content types, views and roles stayed in English? Recipes are a big part of how Drupal CMS delivers features, so this was a sizable hole in the multilingual experience. Until a few months ago, recipes could not even be translated on localize.drupal.org. So we did a few things to make this possible ahead of DrupalCon Rotterdam. Here are the details.
Drupal 10 reaches end of life on 9 December 2026, the same week Drupal 12 arrives. Here's why your next move is Drupal 11, and why you need to start now.
Drush Deployment Hooks vs. Drupal Update Hooks: Choosing the Right TooladminDrupal
Drupal deployments often involve more than deploying code and importing configuration. Sometimes we need to update existing content, migrate stored data, or perform operations that depend on newly deployed configuration.
Drupal provides update hooks for these tasks, but their execution order introduces an important limitation. Drush deployment hooks address that limitation by allowing code to run after configuration has been imported.
Understanding when each hook executes helps prevent deployment failures and makes updates more predictable.
Understanding Drupal's deployment sequence
The drush deploy command provides a standardized deployment workflow. In current versions of Drush, the essential sequence is:
The distinction matters because Drupal's active configuration may be different at each stage of deployment.
Drupal update hooks: Changes before configuration import
Traditional update hooks, implemented in a module's .install file, are intended for low-level changes such as updating database tables or entity storage definitions.
For example:
/**
* Add a column to a custom database table.
*/functionmymodule_update_10001(){
$schema = \Drupal::database()->schema();
$schema->addField('custom_records', 'external_id', [
'type' => 'varchar',
'length' => 255,
'not null' => FALSE,
]);
}
These hooks run early in the deployment process, when Drupal's database schema and code may not yet be fully synchronized.
Because of this, developers should avoid using normal entity CRUD operations in hook_update_N(). Those operations can depend on entity definitions, services, or database structures that aren't ready.
Drupal also provides hook_post_update_NAME(), implemented in a module's .post_update.php file. Post-update hooks run after the lower-level updates and have access to Drupal's fully bootstrapped APIs.
They're useful for updating existing entities or performing data transformations that don't depend on configuration introduced by the current deployment.
However, both types of Drupal update hooks execute before configuration import.
That's where deployment hooks become useful.
Drush deployment hooks: Working with newly imported configuration
Drush introduces hook_deploy_NAME(), which runs after configuration has been imported and caches rebuilt.
Deployment hooks live in a custom module's .deploy.php file and execute through drushdeploy:hook, typically as the final step of drush deploy.
Consider a common scenario: a deployment introduces a new field on an existing content type, and existing nodes need that field populated.
A traditional update hook isn't appropriate because the field may not exist when drush updatedb runs. A post-update hook has the same problem.
A deployment hook runs after the field configuration has been imported.
For example, in mymodule.deploy.php:
/**
* Populate the new external_id field on existing articles.
*/functionmymodule_deploy_populate_external_ids(array &$sandbox){
$storage = \Drupal::entityTypeManager()
->getStorage('node');
$ids = \Drupal::entityQuery('node')
->condition('type', 'article')
->notExists('field_external_id')
->accessCheck(FALSE)
->execute();
foreach ($storage->loadMultiple($ids) as $node) {
$node->set('field_external_id', 'article-' . $node->id());
$node->save();
}
}
Like post-update hooks, deployment hooks are tracked so they aren't repeatedly executed on subsequent deployments.
This makes them particularly useful for one-time content transformations tied to specific releases.
Choosing the right hook
The decision usually comes down to two questions: what needs to change, and what must already exist for that change to succeed?
Use hook_update_N() for low-level structural changes that must happen before Drupal can safely operate with the new code.
Use hook_post_update_NAME() for entity or data updates that need Drupal's full API but don't depend on newly imported configuration.
Use hook_deploy_NAME() when the operation requires configuration introduced by the same deployment, such as a new field, content type, or taxonomy vocabulary.
There are also operational considerations. Deployment hooks require Drush and a deployment workflow that invokes them. Running drush updatedb alone won't execute them.
Finally, use configuration management for configuration changes whenever possible. A deployment hook should not become a substitute for configuration exports or a general-purpose place to run arbitrary deployment scripts.
Making deployments more predictable
Deployment hooks don't replace Drupal's update system. They extend it by providing an additional execution point after configuration import.
That distinction is especially valuable for complex Drupal sites where changes to structured content frequently require corresponding updates to existing data.
Choosing the right hook helps keep schema changes, configuration management, and content transformations in the correct order. More importantly, it makes deployments easier to test, repeat, and maintain.
Jeremy Andrews, Tag1's Founding Partner and CEO, follows PHP and Drupal from the Symfony-driven rebuild of Drupal 8, through PHP 7's engine rewrite that shrank a typical value from about 48 bytes to 16, to Drupal 11.4, which needs just over a third of the database and cache lookups of Drupal 11.0 on a cold cache. He also shows how fixes from a Tag1 audit of London.gov.uk cut its slowest requests from about 75,000 database queries to 10,000.
This is Part 2 of a two-part series. Part 1 traced PHP's evolution from a C library wrapper through Drupal 7, where the procedural model hit its ceiling. This part covers the Java-fication of Drupal, the engine-level work that saved PHP from itself, and what Drupal and PHP are getting right about performance now.
The year Drupal 8 development started, we were publishing Drupal Watchdog, a print magazine that we shipped to subscribers around the world. I mention this because the magazine's lifespan, 2011 through 2016, almost exactly tracks the development of Drupal 8. We started it because the Drupal ecosystem felt like it could sustain anything. By the time we wound it down, the community had been through the most disruptive transition in the project's history.
Nat Catchpole, one of the Drupal 8 branch maintainers then and a Drupal core and Tag1 developer now, has said this to people in person for years but never in writing: "[My] biggest regret of accepting the D8 maintainer position was that I didn't make a condition of acceptance deletion of the 8.x branch and starting again after a year or so of trying to stabilise and improve Drupal 7. If we'd done that we potentially could have developed the minor/major release cycle we eventually landed on for 8.x or at least started 8.x development on a better footing than immediately after another long major release cycle that had burned people out. I did try to stop the branch being opened in the first place when it was, but wasn't confident enough at the time to try to reverse that once it had happened."
Drupal 7 itself had come out of a three- to four-year release cycle with, as Nat puts it, more than a year of death march at the end, and Drupal 8 was branched before a Drupal 6 site could be safely updated to Drupal 7 (known bugs in the update path could lose data; in one, every comment body went missing). The release cycle Nat wished for is the one Drupal adopted. Since Drupal 8, new features have arrived in minor releases every six months, and a major release mostly removes code that was already marked for removal. That's why moving to Drupal 9, 10, and 11 meant upgrading a site, not rebuilding it.
In his keynote at DrupalCon Rotterdam 2026, Dries Buytaert named Drupal's reputation gap as the project's number one challenge: too many people are judging the Drupal of ten years ago.[1] Performance is one place the gap shows, and some of that reputation was earned in the years this post covers, when Drupal took on a heavy new architecture while PHP was still paying for the trades it made in the 90s.
Those trades (every request starting from scratch, and every value carrying its type at runtime, plus a reference count for strings, arrays, and objects) show up when we profile Drupal sites today. Spotting them takes the systems programmer's view described in Part 1 (looking one layer down, at the PHP engine, at how memory is laid out, and at what the CPU actually does), and working at that layer is one of the ways we find and fix Drupal performance problems. Performance work is how Tag1 started; in January 2008, a few months after we opened, we ran a webinar with MySQL AB on tuning MySQL for Drupal.[2] Drupal and PHP have done a lot about those trades since.
The Java Cure
Between 2010 and 2015, the PHP ecosystem went through a cultural transformation. Applications were getting larger and more complex, and the "include and run" model of the 90s couldn't keep up, so the community looked to Java Enterprise Edition for answers.
I should admit my bias up front. When my daughter was in single digits, someone mentioned in front of her that they programmed in Java, and she promptly volunteered, "Oh, my dad doesn't like Java at all. He thinks it's horrible!" She wasn't wrong. So I know how easy it is to turn the criticism of "Java-fication" into a cartoon, which is why I'll start with the very real problems it was solving. Drupal's hook system was limiting, the class registry was a hack, the lack of standardized autoloading meant every framework shipped its own autoloader, and without a per-project dependency manager you downloaded libraries by hand and committed them to version control. At the scale our larger clients were running, those things got in the way of building software you could maintain.
What arrived solved them: Composer for dependency management, the PSR standards for autoloading and coding style, and Symfony components for HTTP handling and routing. Composer mattered most, because it gave each project its own set of dependencies: it resolved version constraints, generated the autoloader, and made installs reproducible through a lock file. Drupal 8 embraced all of this. There was a push to get Drupal developers "off the island" and rejoin the wider PHP world, and Drupal 8 was eventually marketed as "proudly found elsewhere."[3] The trouble was how much of it Drupal took on at once, and how often Symfony's assumptions about an application didn't match Drupal's.
The Cost of Ceremony
Dependency injection is a valid pattern. Decoupling your classes from their dependencies makes testing easier and makes it possible to swap implementations. Drupal 8 adopted a set of Symfony components rather than the full framework (HttpFoundation, HttpKernel, Routing, EventDispatcher, DependencyInjection, and ClassLoader),[4] and the DependencyInjection component brought a style where configuration became a primary programming activity. Developers wrote YAML files to define services, and each module's mymodule.services.yml became as important as its PHP code.
Tracking the execution flow became an exercise in indirection. A call to \Drupal::service('mymodule.thing')->doSomething() involved looking up the service ID in the container, checking whether the service had already been instantiated, building it from its definition if not, resolving all of its dependencies (and theirs), and finally calling the method. I remember stepping through Drupal 8 in a debugger for the first time and losing track of where I was after about twelve levels of indirection. In Drupal 7, you could grep for a function name and find it. In Drupal 8, you needed to understand the container, the service definitions, the compiler passes, and the event system before you could figure out what code actually ran when a page loaded.
The interface explosion was another symptom. Java's influence brought the convention of creating a MyServiceInterface, a MyServiceBase abstract class, a MyService concrete implementation, and sometimes a null implementation as well. For a language where duck typing had been the norm (if an object has the method you call, the call works, whatever class the object is), this felt like a straitjacket. Each of those four was one more file for the autoloader to find and the opcode cache (OPcache) to hold. Class names started reading like Java parodies: Drupal 8 shipped with a ContainerAwareEventDispatcher, a ContainerFactoryPluginInterface, and a ContainerDerivativeDiscoveryDecorator.[5]
A "Hello World" module went from a three-line .info file and a few lines of .module code in Drupal 7 to a directory structure containing an info file, a routing YAML file, a Controller class, and often a service definition. Drupal 7's hook_menu mapped each path to a page callback function; in Drupal 8 the paths moved into routing.yml files and the callbacks became Controller and form classes. Access rules moved too, from a callback function on each path to requirements on each route, checked by separate services. hook_init was replaced by event subscribers listening to KernelEvents::REQUEST.[6]
For the Drupal community, this was a hard transition. Developers who had been productive contributors for years suddenly needed to learn Symfony's component architecture, the service container, YAML configuration syntax, and object-oriented design patterns they'd never needed before. It was like learning a second framework from scratch. We watched experienced Drupal developers leave the ecosystem during this period, because the jump was too large and too abrupt, even for people who agreed with where it was headed.
At Tag1 we felt this directly. We were a Drupal consulting company, our clients wanted to move to Drupal 8 well before they could, and their in-house developers were retraining in the meantime. What had been a creative, accessible tool started to feel more like enterprise middleware, and for a while building with Drupal felt closer to a day job than something you did because you enjoyed it.
Symfony's HttpFoundation was approved for Drupal core in August 2011, a few months after the Drupal 8 branch opened, and Drupal 8.0.0 was released on November 19, 2015.[7] Drupal 7 had shipped in January 2011, so that was nearly five years without a new major release. By the time Drupal 8 was out, some agencies we knew had moved to other platforms rather than wait. The ones that stayed invested heavily in retraining, and they ended up with a more capable Drupal.
The complexity moved from implementation to architecture. We spent less time reading hook implementations and more time debugging the container. Bootstrapping a Drupal 8 page meant loading a lot of classes and a big compiled container just to return a string of HTML, and memory use went up, not down.
I go back and forth on whether the Java cure was a net improvement. For a small site that needed five modules, Drupal 7 was simpler, and we still support clients running it through D7 Extended Support nearly two years after its January 2025 end of life. For a large enterprise platform with dozens of developers and hundreds of integrations, the structure of Drupal 8 was necessary even if the weight of it was hard to love.
What has changed since is how much of that weight the community has paid down, and the compiled container is a good example. Because every PHP request starts from scratch, Drupal can't keep its service container in memory between requests, so it compiles the container once and caches the result for the requests that follow. The price comes due whenever the container has to be rebuilt, and installing a module rebuilds it. Nat recently wrote up what that meant for Drupal core's own tests. Until Drupal 11.2, an install did 50 or 60 container rebuilds; installing modules in batches brought that to 11 or 12 and took tens of seconds off every install, and Drupal 11.4 made the remaining ones more conditional, cutting a functional test install from 11 container rebuilds to 8, with work under way to reach 2.[8] Core's test suite installs Drupal thousands of times on every run, so the time saved on each install adds up. The drop in core's CI pipelines (the automated test runs on every proposed change), from about 55 minutes to between 5 and 7 minutes, came mainly from running tests in parallel, slowest first, and splitting up the slowest test classes, but the installer work is why the functional tests now reserve 80 CPUs instead of 128 and finish just as fast.
Caching With Metadata
Drupal 8's new architecture also made possible its biggest performance gain. If every request has to start from scratch, the next best thing is to skip as much of that work as possible, so Drupal 8 built caching into how each piece of a page is assembled. Each piece declares three things: cache tags, which name the data it depends on (saving a node invalidates only the pieces tagged with it); cache contexts, which say what it varies by, such as language or user permissions; and a max-age, which says how long it can be cached. With that metadata, Drupal can cache a page in pieces and leave the personalized parts as placeholders. The dynamic page cache keeps a copy of each page with its placeholders still in it, so on a cache hit a request mostly just has to fill in the placeholders. Drupal can fill them in before it sends the page, or send the page first and stream the personalized parts in afterwards, which is what BigPipe does. More and more, it fills them in before sending, using parts it has already rendered and saved in the render cache.[9]
All of this depends on the metadata being accurate, because a cache tag that's broader than it needs to be throws away cached work that was still good. When we audited London.gov.uk for the Greater London Authority, many page requests took from several seconds to over a minute, and the slowest requests on a cold cache ran about 75,000 database queries, with menu rendering a large part of it.[10] The Group module was adding a list cache tag (one invalidated whenever any group relationship changes) to its access checks, so creating a group relationship for any node invalidated the cached menu (almost entirely links to nodes) and every page it sat on. Webform was loading every form on the site just to build its asset library definitions, and APCu (PHP's in-memory cache on each web server) needed tuning. Several of the fixes went upstream: core gained a menu block option to skip computing the active trail (which links lead to the current page) for menus the theme doesn't style, and the Webform and Group fixes were committed to those modules. After the first phase of that work, those slowest requests ran about 10,000 database queries on a cold cache instead of 75,000, and application response times improved by 87%.
The Engine Room
While the framework layer was getting heavier, the PHP internals team was doing something that made me pay attention to the language again in a way I hadn't since reading the PHP source in the early 2000s. They went after the engine itself: smaller structs, better cache locality, and less pointer chasing. The result shipped as PHP 7.0 on December 3, 2015, a couple of weeks after Drupal 8.
The work started in early 2014 as an experimental branch at Zend called phpng (PHP Next Generation) and went public that spring. It was controversial because it touched nearly everything in the engine, but the benchmarks won the argument. In August 2014 the internals team voted 47 to 2 to merge it, and it became the foundation of PHP 7.[11]
The version number has its own history. PHP 6 had been in development for years with the ambitious goal of native Unicode support throughout the engine. The plan was to represent every string internally as UTF-16 using the ICU library, which would have solved the endless character encoding headaches that plagued web developers. But the implementation was enormously complex, it required changes to virtually every string-handling function in the language, and it was slow and memory hungry. In March 2010 the project was abandoned. Some of the work developed alongside it, such as namespaces, had already shipped in PHP 5.3. When it came time to name the next major release in 2014, the internals team voted to skip 6 entirely, in part because books and articles about the never-released PHP 6 were already in circulation.[12]
Zval Compaction
In PHP 5, the zval struct was 24 bytes on a 64-bit system:
That number understates the cost. PHP 5 zvals were almost always allocated on the heap individually, and each one carried an extra garbage collection pointer plus the allocator's own overhead, which worked out to about 48 bytes per zval in practice.[13] The refcount was attached to the zval itself, so even temporary values paid for it, and the value field often pointed at yet another heap allocation, meaning accessing a variable involved double indirection and frequent CPU cache misses.
PHP 7 compacted the zval to 16 bytes. In its 7.0 form:
Integers and floats still live inside the zval, as they did in PHP 5, and null, true, and false are encoded in the type alone, but now none of them need a heap allocation or a refcount. The refcount moved out of the zval and into the thing being pointed to (strings, arrays, objects), so zvals themselves can live on the stack or be embedded contiguously in other structures. That u2 union is a nice systems touch: it's the four bytes of padding the struct would have had anyway, reused for whatever the surrounding context needs, such as the collision chain in a hash table. Nearly every line of PHP touches a variable, so nearly every line got faster when the zvals for the most common types went from roughly 48 bytes in a separate allocation to 16 bytes stored inline, with one fewer pointer to chase. If you've ever profiled a hot path and found the CPU spending its time waiting on cache misses, you understand why this mattered.
The HashTable Rewrite
The HashTable got the same treatment. The PHP 5 HashTable allocated every bucket separately, and each bucket carried four pointers just for linking: two for a doubly-linked list that preserved insertion order and two for the collision chain. Iterating meant chasing pointers from one bucket to the next, with no guarantee that adjacent buckets were anywhere near each other in memory, and every bucket pointed to a zval that was itself a separate allocation.
The phpng rewrite put the whole table in one allocation: a hash index followed by a contiguous array of Bucket structs, with the zval embedded directly in each Bucket. In C++ terms, this was the equivalent of replacing std::list with std::vector. Iterating a PHP 7 array became a linear scan of contiguous memory, and the CPU prefetcher could finally do its job. A write-up of the new implementation measured the per-element cost dropping from 144 bytes in PHP 5.6 to 36 bytes in PHP 7 (32 for packed arrays).[14]
The PHP 7.0 release announcement promised that PHP 7 would be "up to twice as fast as PHP 5.6," and Zend measured Drupal 8 running 72% faster on PHP 7 than on 5.6, with WordPress executing roughly a quarter of the CPU instructions per request.[15] We were running load tests at Tag1 during this period for several clients, and the PHP 7 upgrade was one of those rare changes where the numbers just got better across the board without anyone touching a line of application code. The language was still paying for dynamic types and reference counting on nearly every operation, but the underlying data structures were no longer actively hostile to the hardware.
The AST
PHP 7 also introduced an Abstract Syntax Tree step between parsing and compilation, which the internals team approved in a 47 to 0 vote.[16] Previously the parser emitted opcodes directly, which meant the compiler couldn't see the full picture of what it was compiling. The AST decoupled parsing from code generation, which made smarter optimization passes possible, and together with the companion Uniform Variable Syntax RFC it gave PHP 7 consistent left-to-right rules for variable expressions. The static analyzers most PHP developers know, PHPStan and Psalm, build on the userland PHP-Parser library (userland meaning written in PHP, not C) instead of the engine's own AST; Phan is the one that reads the engine's AST directly, through the php-ast extension. Between them, these tools gave developers a measure of compile-time safety for a language that had never had it, catching type errors and dead code before the application ran.
Modern PHP and Its Governance
The PHP 8.x era, which started in November 2020, brought features developers had long used in other languages: union types, named arguments, attributes, and the match expression in 8.0; enums and fibers in 8.1; property hooks in 8.4; and a pipe operator in 8.5. PHP 8.6 is due on November 19, 2026, and brings partial function application.[17] I learned PHP from Drupal's code in 2002, on PHP 4, and the language today barely resembles it. Getting those features in took years, and sometimes several failed votes.
The RFC Process
PHP evolves through RFCs (Requests for Comments), and since 2019 every RFC's main vote has needed a two-thirds majority to pass (language changes already did).[18] Voting is open to people with php.net accounts who have contributed to the project, plus a few community representatives, and in practice the voting pool is modest. This process has produced some excellent features and some baffling outcomes. There is no BDFL (Benevolent Dictator for Life) and no corporate owner making unilateral decisions. PHP stopped being Rasmus Lerdorf's project alone in the PHP 3 days, when he and the two developers who rewrote its parser decided to build it together, and it has been a group effort ever since (he still contributes, including a fix to the opcode cache as recently as March 2026).[19] The upside of deciding by vote is real community ownership; the downside is that a motivated minority can block a controversial feature, and "no" voters don't always explain their reasoning, which makes iteration difficult.
Property hooks, which let a class run its own code when a property is read or written, are a good example of how long things can take. The first draft of a property accessor syntax was written in 2009. Its successor, the accessors RFC, came to a vote in January 2013 and failed 34 to 22, short of two thirds. A decade later, in January 2023, the related asymmetric visibility RFC (letting anyone read a property while only the class itself can change it) failed 14 to 12. Then in April 2024, property hooks passed 42 to 2, a revised asymmetric visibility RFC passed 24 to 7 that August, and both shipped in PHP 8.4 that November.[20]
The PHP Foundation, founded in November 2021, changed how PHP development is funded. Nikita Popov, who had been working on PHP full time at JetBrains, decided to focus on LLVM instead (he is now LLVM's lead maintainer), and his departure showed how much of the project depended on a handful of people. JetBrains, Automattic, Laravel, Acquia, Zend, Symfony, and others set up the Foundation to pay people to work on PHP internals, and by the start of 2026 it was funding eleven developers.[21]
The Generics Question
The feature most obviously missing from modern PHP is generics. The first generics RFC was written in 2016, targeting PHP 7.1, and never came to a vote. A nearly complete experimental implementation in 2020 and 2021 ran into fundamental problems: PHP checks types at runtime, so generics the runtime actually enforced would mean checking generic parameters on every call, and useful type inference (working out a variable's type without being told) is hard when the compiler only ever sees one file at a time. The prototype stalled.
The community has spent years working around this. PHPStan and Psalm read generic types written into docblocks (structured comments above a function or class), so the analyzer checks them even though the runtime never does. But the language itself can't express something as basic as "this is a collection that only contains User objects" in a way the runtime enforces.
The Foundation has kept pushing. It published a survey of the options in 2024, and in 2025 it floated generics checked only at compile time, limited to interfaces and abstract classes, where PHP can check them once when it loads a class rather than on every call.[22] That design trades full coverage for a version the engine can afford. Then in June 2026 a different proposal, bound-erased generics, was voted down 19 to 7. It would have let code declare generic types while the runtime treated each type parameter as the bound it declared (for example, that T implements Countable), leaving the rest to static analyzers. Opponents worried that syntax the engine doesn't enforce would weaken PHP's type guarantees, and that code written against it would break if enforced generics arrived later.[23] PHP still has no generics.
PHP has gotten fast, but the way it runs code limits which features it can add. Rust, by contrast, handles generics through monomorphization at compile time: the compiler generates specialized code for each concrete type, with zero runtime cost (the price is paid in build times instead, and the Rust project's own annual survey calls slow compilation a perennial concern of its users).[24] PHP settles most types only when the code runs, so it has nothing to specialize ahead of time.
The JIT
PHP 8.0's JIT compiler ran into the limits of web workloads. It built on OPcache and used DynASM (the code generator from LuaJIT) to turn Zend opcodes into native machine code, with two modes: a function JIT and a tracing JIT that watches for hot code paths and compiles those. On paper, that should make PHP much faster.
In practice, Drupal, WordPress, and Laravel spend most of their time waiting on database queries, cache calls, and the network. The PHP code in between is glue, and the functions it relies on (json_encode(), str_replace(), PDOStatement::execute()) are already compiled C. The exception is a cold cache, right after a deploy or a cache clear, when Drupal does real CPU work: processing YAML files and the attributes in its PHP code, compiling Twig templates, and building combined CSS and JavaScript files, then merging all of that into large metadata arrays that have to be written to the cache. Each result is cached as soon as it's built, so few requests pay that cost, though the ones that do are slow.[8] Even though the JIT keeps its compiled code in OPcache shared memory and reuses it from one request to the next, a typical page gives it very little CPU-bound PHP to speed up. The JIT RFC was upfront about this, reporting WordPress going from 315 to 326 requests per second, about 3.5%, and the 8.0 release announcement said typical application performance was "on par with PHP 7.4."[25] PHP 8.4 replaced the original implementation with a new JIT, which put the x86 and ARM back ends behind one shared code generator, but the JIT still isn't enabled by default.[26]
The JIT shines for computational tasks: math-heavy PHP code, parsers, image processing. The same RFC measured more than 4x on a Mandelbrot benchmark. But that's not what most PHP developers build, and it's not what most Drupal sites do.
Features That Landed
PHP 8.x did more for the developer through smaller, more targeted features.
The match expression in 8.0 brought strict comparison and expression-based matching. PHP 8.0 also fixed the worst of loose comparison (0 == "foo" is finally false), but switch compares loosely, so "1e3" still matches case "1000": because both look like the number 1000. match uses === and doesn't have that problem.[27] Enums arrived in 8.1, giving the language proper enumerated types whose cases are objects. They're limited compared to Rust's enums, where each case can carry its own data, but they replaced a generation of class constants pretending to be enums. Real pattern matching (testing a value's shape and pulling it apart in one step) is the subject of an RFC under discussion since December 2025, but it missed the PHP 8.6 feature freeze on September 22, 2026.[17]
Attributes in 8.0 gave PHP a native replacement for the docblock annotation hack that Drupal 8 and Symfony relied on. Previously, metadata lived in comments (/** @Annotation */) that libraries like Doctrine Annotations pulled out through reflection and ran through their own regex-driven tokenizer and parser, then cached. That this worked at all says a lot about PHP developers' willingness to abuse any available mechanism. Attributes moved metadata into native syntax that the engine parses and stores with the compiled code, where reflection can read it without anyone parsing comments.
Fibers, which arrived in 8.1 alongside enums, are PHP's building block for asynchronous code (code that starts something slow, like a database query, and does other work while it waits for the answer). A fiber lets a function pause partway through and resume later, with the program itself deciding when to switch. The limitation is that the standard library is still blocking. A file read, a database query through PDO, or almost any other I/O call stops the whole process until it returns, so no other fiber can run in the meantime. True async requires an event loop, and PHP doesn't ship one. Libraries like Revolt and Amp fill the gap, but the ecosystem hasn't broadly adopted them. A "True Async" RFC that would have brought coroutines and a scheduler into the language was withdrawn in July 2026, and a follow-up RFC proposing the engine-level scheduler core it would have been built on is still under discussion.[28]
The async gap shows how deeply the shared-nothing model (every request starts from scratch and shares nothing with the others) is embedded in the language. In Go, the runtime includes a scheduler and goroutines are a first-class primitive. In Node.js, the event loop is the runtime. In Rust, the async runtime (typically Tokio) is a library, but the language's async/await syntax and the Future trait are built in. In PHP, fibers provide the mechanism for pausing and resuming execution, but everything around them (the event loop, the async I/O, the scheduler) has to come from userland libraries.
Fibers themselves are well engineered and do exactly what they were designed to do, and Drupal has put them to good use. But PHP's ecosystem was built over 30 years of synchronous request-response assumptions, and it can't broadly adopt async patterns until the standard library itself becomes non-blocking, a change of such magnitude that it may never fully happen. Projects like Swoole and FrankenPHP take a different approach: they replace PHP-FPM (the process manager that runs PHP behind a web server) and handle concurrency in the server layer. Swoole is a C extension that runs its own long-running coroutine server and swaps PHP's blocking functions for coroutine-aware versions; FrankenPHP embeds PHP in the Go-based Caddy web server and keeps PHP synchronous inside Go workers. Both are pragmatic, but they move concurrency outside the language.
I started learning Rust in 2020, and my first project was Goose, a load testing tool written in Rust and inspired by Locust, the Python framework. We'd been using Locust for client load tests at Tag1, and early on, before we'd even optimized the code, Goose ended up generating at least eleven times as much traffic as Locust per CPU core.[29] The bigger thing for me was how Rust's async model worked. After years inside PHP's concurrency constraints, it felt like someone had finally gotten the tradeoffs right: real concurrency with memory safety and zero-cost abstractions. It changed how I think about PHP's limitations, and Goose is now the primary tool we use to load test Drupal sites.
The Full Circle
I came to Drupal a few months before 4.0.0, and looking back from 11, its arc mirrors PHP's own: procedural hooks in Drupal 4 through 7, the Symfony-driven enterprise architecture of Drupal 8 and 9, and the gradual simplification in Drupal 10 and 11 as PHP itself provided better tools.
Drupal 9, released on June 3, 2020, was deliberately undramatic: Drupal 8 with deprecated code removed and dependencies updated. The community had learned from the Drupal 7-to-8 chasm that major version upgrades needed to be manageable, and for most sites the upgrade from 8 to 9 meant running Composer and fixing whatever Drupal 8 had already warned them about. After the long Drupal 8 cycle, this was a relief.
Drupal 10, released on December 15, 2022, continued the pattern. It required PHP 8.1, meaning the codebase could start using readonly properties, enums, fibers, and intersection types, and it moved to Symfony 6 and CKEditor 5. Drupal 10.2 then began replacing docblock annotations with attributes for plugin discovery, and added fiber support to the renderer and to BigPipe.[30]
Drupal 11, released on August 2, 2024, moved to PHP 8.3 as its minimum and Symfony 7 underneath, and its 11.1 release that December brought the biggest change to how you write a hook since Drupal 8:
Drupal 7: You defined a function with a magic name, and the framework found it by building function names from strings and checking whether they existed.
functionmymodule_node_insert($node){// Your code here}
Drupal 8: For anything the old hooks couldn't express, you created an EventSubscriber class implementing an interface, registered it as a service in YAML, and defined which events it listened to. A single hook's worth of functionality required a class, a YAML entry, and knowledge of the Symfony event system.
Drupal 11.1 and later: You put a method in a class under your module's src/Hook directory and mark it with an attribute, and the framework discovers it when it builds the container.[31]
namespaceDrupal\mymodule\Hook;useDrupal\Core\Hook\Attribute\Hook;useDrupal\node\NodeInterface;classMymoduleHooks{#[Hook('node_insert')]publicfunctionnodeInsert(NodeInterface$node):void{// Your code here}}
That brings us back around. You're once again labeling a function with what it does, but now it's backed by the engine's native attribute system instead of string concatenation and function_exists() checks. The method is type-hinted, and the hook class is an ordinary autowired service: the container reads its constructor's type hints and passes in the services the class asks for.
Drupal 7's hooks were simple but fragile, and Drupal 8's event subscribers were solid but verbose. Drupal 11's attribute-based hooks are simple and solid, because the language itself now provides machinery that the framework used to build by hand. The framework still carries weight from the Symfony layer and still loads a lot of classes to handle a request, but the developer experience is finally converging on something that's both structured and humane. A new contributor can look at an attribute-based hook, understand what it does, and write one without reading a chapter on the Symfony event system first.
Drupal is also getting faster again, and PHP's newer features are part of the reason. Drupal 11.3, released in December 2025, used fibers and rendered more of each page in placeholders so that database and cache operations could be combined, cutting both by roughly a third on a cold cache. Nat called it the biggest performance improvement in a decade. Core's automated performance tests, which run on Gander (the framework we built with the Google Chrome team), have driven many of Drupal's recent gains: they show when a change makes Drupal faster, sometimes point to the cause of a problem, and keep those gains from slipping back.[32] Drupal 11.4, released in July 2026, halves database queries compared to 11.3 across a wide range of requests, and on a completely cold cache it needs just over a third of the database and cache lookups of Drupal 11.0. Nat's DrupalCon Rotterdam session on the work explains how it was done.[33]
Drupal 12 is in beta now and scheduled for release the week of December 7, 2026. It will require PHP 8.5 and Symfony 8, and Drupal 10 reaches end of life that same week. Drupal's policy is to require the newest stable PHP release when a major version reaches its first beta. PHP 8.5 gets security fixes through the end of 2029.[34]
What PHP Got Right
Rasmus started what became PHP in 1994 as a set of C tools for his personal home page. Today PHP can be strictly typed (if you ask it to be), it has a JIT, enums, fibers, attributes, and property hooks, and according to W3Techs it runs on 69.8% of websites whose server-side language is known.[35] The biggest performance jump came from PHP 5.6 to PHP 7, achieved mostly through the kind of data structure work that systems programmers respect.
PHP survived its own worst tendencies. register_globals was eventually turned off by default and then removed in PHP 5.4. The mysql_* functions that taught a generation of developers to write SQL injection vulnerabilities were deprecated in 5.5 and removed in 7.0, leaving PDO and mysqli with prepared statements.[36] The chaos of every framework shipping its own autoloader, and of libraries downloaded by hand, gave way to Composer and PSR standards. The language governance, messy as it can be (property hooks took fifteen years from first draft to release), has consistently moved in the direction of safety and rigor.
The community deserves credit too. PHP developers are often dismissed by people in other ecosystems, but they produced WordPress, Drupal, Laravel, Symfony, MediaWiki, and much of the rest of the web's content layer. When something is broken, people build workarounds, and the best of them become standard practice. PHPStan and Psalm brought static analysis to a dynamic language because developers wanted type safety and weren't willing to wait for the runtime to provide it. Composer exists because PEAR, PHP's older package installer, put libraries in one system-wide location by default and made it awkward to give each project its own dependencies.
The shared-nothing, request-response model remains PHP's core strength and its core limitation. Every request starts fresh, loads the application, processes the request, returns the response, and throws everything away. There's no shared state to corrupt, no memory leaks that accumulate over hours of uptime, and no concurrent access bugs to debug at 3 AM. But there's also no way to hold a connection pool, maintain a websocket, or handle concurrent streams without bolting on something that works against the fundamental architecture.
Swoole, RoadRunner (Temporal, which runs its PHP workers under RoadRunner, recently gained a Drupal integration Károly Négyesi wrote for a Tag1 client), and FrankenPHP are pushing PHP into long-running process territory (Laravel Octane will run on any of them). In 2025, FrankenPHP moved under the PHP Foundation, so the organization that funds much of PHP's core development now backs long-running PHP too. FrankenPHP remains a separate project from PHP itself, though. When it moved, the Foundation told the internals mailing list there were no plans to merge it into PHP the way PHP-FPM is, and that doing so would take an RFC. FrankenPHP's developers contribute changes upstream, such as one merged in July 2026 that makes PHP's command line available to programs that embed PHP, as FrankenPHP does.[37] Swoole, RoadRunner, and FrankenPHP are all still swimming against the current of how the language and most of its ecosystem were designed.
For a C programmer looking at PHP in 2026, the language is tolerable in a way it wasn't in 2004. The engine is well optimized by people who clearly understand cache lines and struct packing, and the frameworks still carry real weight, though Drupal has spent its last few releases measuring that weight and taking it out.
Whether you should use it depends on what you're building. PHP is excellent for content-driven web applications, request-response APIs, and anything where the shared-nothing model is an advantage rather than a limitation. It's the wrong tool for high-concurrency services, real-time systems, or infrastructure that needs deterministic performance. The language knows this, and the community mostly knows this. The arguments happen at the boundaries, where an application starts as a content site and evolves into something that needs persistent connections or sub-millisecond response times.
Drupal turned 25 in January.[38] I've been part of the community since 2002, long enough to have complained about every major architectural decision and then watched most of them work out in the end. The project survived its own ambition, the loss of a lot of contributors, a release cycle of nearly five years, and the kind of technical debt that would have killed a less persistent community, and it came out the other side with a modern, typed, attribute-driven architecture running on an engine that a C programmer can respect.
Rotterdam has a saying, "Niet lullen maar poetsen" (stop talking, start working), and in his keynote Dries argued that Drupal has been very good at the working part, and now needs to talk louder about what is working.[1] On performance, there's a lot to talk about. When Rasmus profiled Drupal at the OSCMS Summit in 2007, more than half of each request (as I remember it) went to loading Drupal before it could start on the page. Since then PHP 7 rebuilt the engine around the hardware, Drupal 8 built caching into how every piece of a page is rendered, and core now measures itself and uses the numbers to take that cost back out.
If you run a Drupal site today, the 11.3 and 11.4 gains ship with Drupal, so staying current is how you get them, and with Drupal 12 requiring PHP 8.5 a PHP upgrade is coming either way.[34] Look at what your pages cost on a cold cache and not only a warm one: it affects a small share of requests, but it can make a big difference for new visitors and for how responsive a site feels after a deploy or a cache clear.[8] When a page is slow, measure it before guessing. When the fix belongs upstream, send it there, the way the London.gov.uk fixes went into core, Webform, and the Group module.
[11] phpng RFC ("a team of developers led by Dmitry Stogov"); "Move phpng to master" RFC, vote closed August 14, 2014, 47 to 2; PHP 7.0.0 release announcement.
Companies embrace multilingual functionalities to connect more deeply with global audiences. As an enterprise-level CMS, Drupal provides robust multilingual capabilities, making it simple to manage content, interface elements, and underlying configuration across multiple languages.
Add new comment