This is part 2 of a series. [Part 1 made the case for splitting WordPress into Classic and Next.] This post gets into the runtime itself. What Classic keeps, and what Next burns down.
A quick aside on PSR, since I’m going to use it a lot
Before I get into any of this, I want to define one acronym that’s going to come up over and over in this series: PSR.
PSR stands for PHP Standards Recommendations. They’re a set of interfaces and standards published by an organization called PHP-FIG (the PHP Framework Interop Group). The idea is pretty simple. Instead of every PHP framework reinventing the same primitives in slightly incompatible ways, the major projects (Symfony, Laravel, Drupal, and a bunch of others) agree on common interfaces for things like logging, HTTP requests, dependency injection containers, and event dispatchers. Then any library that implements those interfaces can be swapped in or out without rewriting your code.
The ones I’m going to keep mentioning:
- PSR-3 is the logging interface. If your code accepts a
LoggerInterface, you can plug in Monolog, or a Sentry adapter, or anything else that implements PSR-3. - PSR-4 is the autoloading standard. It’s the reason you can
composer require something/whateverand just start using its classes without writingrequirestatements everywhere. - PSR-7 is the HTTP message interface. Request and response objects.
- PSR-11 is the dependency injection container interface.
- PSR-14 is the event dispatcher interface.
- PSR-15 is the HTTP middleware interface (the request handler pipeline).
- PSR-17 is the HTTP factories interface.
- PSR-18 is the HTTP client interface.
These are the interfaces every modern PHP project speaks. WordPress, by and large, does not. That’s the gap I’m going to keep pointing at.
One thing I want to address up front, because it’s going to come up a lot in this post and the rest of the series: adopting PSR does not mean “becoming Laravel.” PSR is the point. Drupal adopted Symfony components in 2015. Magento 2 uses them. Shopware, Nextcloud, Statamic. Nobody calls those projects “just Symfony” because the runtime primitives are the commoditized floor, not the product. What makes WordPress WordPress lives above the runtime: the editorial model, the block model, the plugin distribution contract, multisite, the media library, the roles and capabilities system, the REST surface that millions of things already speak. None of that comes for free on Laravel. You’d spend six months scaffolding a pale imitation of it. The argument for Next isn’t “we need a framework.” It’s “we already have a product, and the runtime under it should be the one the rest of the PHP world has converged on.” I’ll come back to this point specifically when I describe each piece, because the “you just built Laravel” objection is going to keep surfacing and it deserves a real answer every time.
Okay, with that out of the way, let’s talk about the kernel.
The current runtime, honestly described
A WordPress request, circa 2026, is still shaped like this. index.php calls wp-blog-header.php, which calls wp-load.php, which calls wp-settings.php, which lazily constructs $wpdb, loads wp-config.php (a PHP file with no schema that defines constants), and initializes a half-dozen globals ($wp, $wp_query, $wp_rewrite, $wp_filter, $post, $pagenow, $shortcode_tags, $allowedposttags, and friends). Then it fires muplugins_loaded, loads mu-plugins, fires plugins_loaded, loads plugins, fires setup_theme, loads the theme, fires after_setup_theme, then init, then wp_loaded, then parse_request, then send_headers, then template_redirect, then template_include, and finally renders something.
Every one of those is a string-keyed global event bus (WP_Hook) that takes a string-or-array callback and resolves it via call_user_func_array. There is no dependency injection container. There is no typed event object. There is no request object. $_GET, $_POST, and $_SERVER are read directly at dozens of call sites. There is no response object. Output is echo‘d and ob_start‘d.
I want to be fair here. Every single one of those choices was defensible in 2003 and I want to acknowledge that this stuff worked, and worked at incredible scale, for a long time. That’s a real achievement and I don’t want to wave it away.
There is also a specific thing the current runtime is extremely good at that I need to say out loud before I propose replacing it: it’s cheap to boot. WordPress lazy-loads almost everything procedurally. There’s no container to hydrate, no service graph to resolve, no reflection cache to warm. On a shared host with opcache enabled and nothing else going for it, a WordPress cold boot is measured in low tens of milliseconds. That is not nothing. Any replacement runtime has to answer for that, and I’m going to try to.
What Next’s kernel looks like
A WP Next request goes through a PSR-15 middleware pipeline on top of a PSR-11 container with typed events on a PSR-14 dispatcher. Concretely:
// wp-next/src/Kernel.phpnamespace WP\Kernel;use Psr\Container\ContainerInterface;use Psr\Http\Message\{ServerRequestInterface, ResponseInterface};use Psr\Http\Server\RequestHandlerInterface;use WP\Events\EventDispatcher;final readonly class Kernel implements RequestHandlerInterface { public function __construct( private ContainerInterface $container, private MiddlewarePipeline $pipeline, private EventDispatcher $events, ) {} public function handle(ServerRequestInterface $request): ResponseInterface { $this->events->dispatch(new Event\RequestReceived($request)); return $this->pipeline->process($request, $this->router()); }}
wp-config.php dies. Its replacement is .env for secrets plus a compiled config/*.php (Symfony-style) for structure. A fresh install’s .env looks like this:
APP_ENV=productionAPP_KEY=base64:...DB_URL=mysql://user:pass@localhost:3306/wp?charset=utf8mb4CACHE_URL=redis://localhost:6379/0QUEUE_URL=redis://localhost:6379/1MAIL_URL=smtp://...SITE_URL=https://example.com
The container is built once, cached as compiled PHP, and service-locates only for edge cases. Constructor injection everywhere. Tagged services for the things core currently does via hooks and global registries (block types, REST controllers, CLI commands, cron schedules, meta boxes, query vars). This is, almost literally, what Drupal 8 did in 2015 with Symfony’s Dependency Injection component. The direction was right. The cost was the ecosystem migration pain, which I’ll get to in part 5.
Now, the honest objection: a hydrated container plus a middleware pipeline plus a compiled service graph is not free. If you benchmark a naive Symfony-style kernel on a shared host with no opcache tuning and no preload, you will lose to WordPress on cold boot every time. I’ve spent enough time at hosts to know that this is the first thing a host-side engineer is going to ask about, and I want to answer it seriously rather than pretending the problem doesn’t exist.
The short version is that the performance story for Next depends on three things being true at the same time. One, opcache preload is not optional. Next assumes PHP 8.2+ with preload configured, and ships a preload script that warms the container and the routing table. Two, the container is compiled, not runtime-reflected. Reflection-based autowiring is a build-time step, not a request-time cost. Symfony has done this for a decade and it works. Three, the middleware pipeline is lean by default. The kernel ships with the minimum middleware to boot, and everything else (including the hook bridge to Classic-style subscribers) is opt-in. I’m going to get into the specific budget in part 4, including what I think a defensible p95 cold-boot target is and what happens to the proposal if that target is missed. For now I just want to flag that this is a constraint I’m taking seriously, not a trade I’m pretending doesn’t exist.
The hook system, reimagined as typed events
The thing about add_action is that it isn’t slow. It really isn’t. The problem is that it’s the primary API surface for every piece of WordPress that matters, and it’s completely untyped, magically string-addressed, and basically opaque to static analysis. You cannot statically determine the shape of what the_content filters receive without reading core. You cannot refactor a filter name across core and 60,000 plugins. You cannot deprecate a filter argument cleanly. PhpStorm can kind-of-sort-of infer some of this, but only because of hand-maintained stubs.
Here’s the before:
// Classic, what every plugin does todayadd_filter( 'the_content', 'my_plugin_add_banner', 20, 1 );function my_plugin_add_banner( $content ) { if ( is_single() && get_post_type() === 'post' ) { return $content . render_banner(); } return $content;}
Every problem is right there. Globals read inside the filter. $content is ?string-shaped if you’re lucky. The priority is a magic integer. The hook name is a string. Errors surface as a white screen of death.
And here’s the Next version:
// WP Nextuse WP\Content\Events\RenderingPostContent;use WP\Events\Attribute\Subscribe;final class BannerSubscriber { public function __construct(private BannerRenderer $renderer) {} #[Subscribe(priority: 20)] public function onRender(RenderingPostContent $event): void { if ($event->post->type !== PostType::Post || !$event->context->isSingular) { return; } $event->appendHtml($this->renderer->render($event->post)); }}
RenderingPostContent is a readonly event class with typed $post: Post, $context: RenderContext, and mutator methods (appendHtml, prependHtml, replaceHtml) instead of return-by-reference. Subscribers are autowired and tagged via the container. The dispatcher is PSR-14. Stoppable events implement StoppableEventInterface. This is Symfony’s EventDispatcher plus PHP attributes, nothing exotic. Laravel’s event system does approximately the same thing with a different idiom.
The add_filter('the_content', ...) one-liner in a dropped-in functions.php is not just a technical API. It is the on-ramp. It is how my career in WordPress started. It is how most careers in WordPress started. A non-developer or a true beginner can paste five lines into functions.php, refresh the page, see the effect, and they are hooked. Dependency injection, autowiring, interfaces, service tagging, attributes. None of those are hostile on their own, but together they are a wall. I want to be extremely clear that WP Next raises the barrier to entry for the plugin developer. Not a little. A lot.
I want to say something about AI here, because I think it’s genuinely relevant and not just a trend I’m gesturing at. A hobbyist in 2026 who wants to modify a post’s rendered content doesn’t have to know what dependency injection is to get a working result. They can describe what they want to Claude or Copilot or Cursor, paste the generated subscriber class into the right file, and see it work. I’m not saying this replaces learning. I’m saying the distance between “I have an idea” and “I have working code” has collapsed in a way that wasn’t true when functions.php was the only on-ramp in town. The old on-ramp was low-floor because the syntax was forgiving. The new on-ramp can be low-floor because the tooling does the forgiving. That’s a real thing, and it changes the calculus on raising the floor for plugin developers, because the people who would have been blocked by a steeper floor in 2015 have a lot more help available to them in 2026 than they used to.
I don’t think that’s a bug. I think it’s the honest bargain of the split.
Classic keeps the one-liner forever. If you want to drop an add_action in a theme’s functions.php and have something happen, Classic is the place that works exactly like it has since 2005, and it gets security updates for a decade or more. That on-ramp is not going away. Next is for the tier above that: the people building Woo extensions that gross millions a year, the agencies porting custom post types across a dozen client sites, the product teams running membership sites at scale. That population has been asking for typed events, real contracts, and IDE-assisted refactoring for a long time. Giving it to them means the wall is real.
The argument for Next is not that the ceiling gets higher. It’s that the floor gets higher, and that on Next that is the right trade. The ecosystem that shows up in blog posts and conference talks and job listings has a developer experience that reflects 2026, not 2005. The ecosystem that runs the 43% keeps the five-line functions.php snippet on Classic. Two populations, two products, one editorial soul. That’s the whole thesis of this series restated in a specific engineering decision.
For the narrow case where a Classic-style plugin needs a typed event surface on Next, a thin Hooks::on('rendering_post_content', fn(...) => ...) facade can stay. It isn’t the primary API. It’s a bridge, and it’s shaped to make migration pragmatic, not to preserve the old ergonomics as a first-class path.
Value objects instead of stdClass
get_post( 42 ) returns WP_Post, which is essentially a stdClass-ish wrapper with public mutable properties typed string|int|null in docblocks only. post_status is a string. post_type is a string. comment_status is 'open'|'closed', except historically it was also '', and also whatever filter mutated it.
PHP 8.1 gave us enums. PHP 8.2 gave us readonly classes. This is, to me, the most obvious low-effort, high developer-experience win available to the project right now:
enum PostStatus: string { case Publish = 'publish'; case Future = 'future'; case Draft = 'draft'; case Pending = 'pending'; case Private_ = 'private'; case Trash = 'trash'; case AutoDraft = 'auto-draft'; case Inherit = 'inherit'; public function isPublic(): bool { return match($this) { self::Publish, self::Future, self::Private_ => true, default => false, }; }}final readonly class Post { public function __construct( public int $id, public PostType $type, public PostStatus $status, public CommentStatus $commentStatus, public PingStatus $pingStatus, public \DateTimeImmutable $createdAt, public \DateTimeImmutable $modifiedAt, public UserId $authorId, public string $title, public string $slug, public string $content, public string $excerpt, public ?PostId $parentId, public int $menuOrder, public PostMeta $meta, ) {}}
Now $post->status === PostStatus::Publish is type-checked. The match is exhaustive. You cannot typo 'publsh'. Custom statuses register as a new enum backed by the same interface. Taxonomies, user roles (enum Role: string { case Administrator, Editor, Author, Contributor, Subscriber; }), comment types, all get the same treatment.
Now, what does this break? Pretty much every $post->post_status === 'publish' comparison in every plugin. Every global $post; $post->post_title = … mutation. Every get_post()->post_meta access. This is massive, and it’s exactly why this change belongs in Next, not Classic. Classic keeps WP_Post as-is forever. Next exposes Post as the first-class type.
Now, the thing I owe an honest answer on. I wrote $meta: PostMeta on that class like it was a free field. It is the opposite of a free field. The WordPress wp_postmeta table is an EAV (entity-attribute-value) schema where every custom field is a row, meta values are serialized PHP blobs, and there is no guarantee on cardinality, type, or size. A single post can have thousands of meta rows. Plugins store multi-megabyte structures in single meta_value blobs. Autoloaded options have eaten hosts alive for years.
You cannot hydrate all of that into a readonly value object at read time without creating the N+1 query disaster that the critic of this proposal will, correctly, point out. And you cannot lazily hydrate it without contradicting the readonly-immutable guarantee I just made in the code above. There is a real impedance mismatch between modern PHP’s value-object semantics and the EAV schema wp_postmeta locks us into. I see it. I’m going to get into the schema-level answer in part 4, because it is a performance and a data-model conversation, and it’s load-bearing enough that giving it half a paragraph here would be worse than flagging it honestly and coming back to it. The short version, so I don’t leave you hanging: PostMeta is a typed accessor, not a pre-hydrated bag, and behind it is a repository pattern with batched loads and schema-declared meta keys that upgrade the EAV model from “anything goes” to “anything declared goes.” Part 4 gets specific.
A one-way shim (Post::fromWpPost(WP_Post)) exists for code that crosses the boundary between Classic and Next. I want to be up front that that shim is doing more work than one line of code should have to do, and part 4 is where the ugly side of it gets named and priced.
Composer-first, PSR-4, and the death of class-foo.php
The class-foo.php naming convention (from the WordPress PHP Coding Standards) exists because the autoloader doesn’t. In 2026, PSR-4 has been the universal default for 12 years. Every other serious PHP project (Drupal, Laravel, Symfony, Magento 2, Nextcloud, Shopware, Statamic, Craft) uses it. WordPress core ships a hand-maintained require list in wp-settings.php. This is not really a technical debate at this point. It’s habit.
WP Next ships with a real composer.json at the root. Core is a package. Plugins are packages. wp plugin install becomes a thin wrapper around composer require wpackagist-plugin/woocommerce that writes to composer.json, resolves dependencies, runs composer install, and fires activation hooks. Roots and Bedrock have been demonstrating that this works in production for over a decade. The johnpbloch/wordpress-core-installer hack goes away because core is the root package.
“But the 5-minute install.” Yes, preserved, with effort. The download is a prebuilt artifact (core plus vendor/ bundled), not a “run composer on your shared host.” wp-cli install detects shared hosting and uses the bundled artifact. It detects a managed or local environment and uses Composer. The install gets faster, not slower, because wp-settings.php‘s 150 require statements become one autoloader init.
Configuration without magic
wp-config.php is a PHP file with side effects. It defines constants. Some constants gate features (WP_DEBUG, SCRIPT_DEBUG, DISABLE_WP_CRON). Some set secrets (AUTH_KEY and friends). Some set paths. Some are read in hot loops (WP_CACHE). Some are set by plugins (yes, really, plugins define() things hoping core reads them later). There is no schema, no validation, no layering, no environment awareness beyond whatever the site owner manually forks.
This is actually a topic close to my heart. I built a small plugin called WP Governance recently to try to address part of this problem from the outside. The right place to fix it, though, is in core.
WP Next’s config model:
config/ app.php # returns array, typed keys, defaults, compiled at build cache.php database.php mail.php services.php # DI definitions, Symfony-style packages/ woocommerce.php.env # secrets plus environment.env.local # git-ignored local overrides
Read via a typed Config service: $config->string('app.url'), $config->int('cache.ttl.default'), $config->enum('app.env', Environment::class). Compiled to a single PHP array at cache-warm. Constants survive only as thin backwards compatibility shims pointing at the config service.
Logging: PSR-3, or stop pretending
WP_DEBUG_LOG = true appends everything to wp-content/debug.log forever. No rotation, no levels beyond what PHP’s error_log gives, no structure, no correlation IDs. For a publisher site doing a million requests a day, this is a postmortem tool, not a logging system. Any non-trivial WordPress host either ships a mu-plugin that pipes to Monolog or intercepts error_log at the PHP ini level. I’ve seen this pattern at every major host I’ve worked with or near.
WP Next: PSR-3 LoggerInterface in the container, Monolog as the default adapter, structured JSON output, levels honored, handlers pluggable (file, syslog, stderr, Sentry, Datadog). wp_log(), error_log(), and trigger_error in core all route through the logger. Every service that needs logging asks for it via constructor injection. This is one afternoon of work given PSR-3 compliance. The blocker here is policy, not engineering.
The CLI story
WP-CLI is a gift that Andreas Creten and Cristi Burcă started back in 2011, and that Daniel Bachhuber and Alain Schlesser have stewarded for the better part of a decade since. We should treat it that way. But it’s a bolt-on. It lives outside core, it reimplements the runtime to run headless, and it has no first-class command registration API inside core itself. Plugins ship wp-cli.php files that are conditionally included. Drupal has Drush in a similar position but it’s closer to core. Laravel has Artisan as a first-class part of the framework. Commands are classes in app/Console/Commands, autowired, discoverable, testable.
WP Next should absorb the WP-CLI model as first-class. A wp command becomes a class:
#[Command(name: 'post:list', description: 'List posts')]final class PostListCommand { public function __construct(private PostRepository $posts) {} public function __invoke( #[Option] ?PostStatus $status = null, #[Option] int $limit = 20, ): int { /* ... */ }}
Built on Symfony Console (which is what Artisan and Drush already use under the hood). WP-CLI continues to work in Classic. Next ships wp as the native runner.
Where this leaves us
If you’ve followed me this far, you might be thinking: this is a lot of plumbing to argue about. It is. But the plumbing is quietly responsible for a lot of the things that get blamed on other parts of WordPress. Hosts patching the same things in parallel. WP_Query accumulating another decade of special cases. The REST API spending thirty seconds on a cold boot. An engineer arriving from Laravel, Symfony, or modern Drupal and having to unlearn a few things before they can be productive. Those aren’t separate problems. They all have the same root.
I want to walk back something I said in an earlier draft of this post, because I’ve been thinking about it and I don’t think I had it right. I’d written that the current plumbing is why a junior developer can be productive in Laravel in a week but struggles in WordPress for months. The more I look at that sentence, the more I think it’s describing the wrong person. The current plumbing is actually what lets a non-developer be productive in WordPress in an afternoon. That’s a real gift, and it’s a lot of why WordPress got to 43% of the web in the first place. The thing the current plumbing doesn’t do as well is the next job, the one that comes after the afternoon. Extending WordPress as a professional product, with a team, a budget, a QA process, and a multi-year contract behind it. That’s the job WP Next is for. Classic is for the afternoon. Both of those jobs deserve a tool that’s honestly built for them, and I think that’s what the split is actually about.
In the next post, I want to talk about what’s happening above the plumbing, in the admin and the editor. That’s where Gutenberg lives, and where the conversation gets a lot more nuanced, because Gutenberg has earned a lot of credit even as the toolchain underneath it is starting to creak.
More soon.
AI Disclosure: Featured image generated by Gemini, and Claude assisted with the refinement of my choices, arguments, and editing of this post and series.

Leave a comment