We need to talk about how we build WordPress sites. For some reason, the standard advice has become “just throw it in functions.php,” and it’s killing performance and sanity. If adding a simple feature to your WooCommerce checkout feels like open-heart surgery, the problem isn’t your skills—it’s your Layered Architecture, or rather, the total lack of it.
In 14 years of wrestling with the WordPress ecosystem, I’ve seen countless projects collapse under their own weight. We’ve all been there: a 5,000-line functions.php file where business logic, database queries, and HTML rendering are all tangled together in a giant “spaghetti hook.” This is why Layered Architecture is no longer optional for professional developers; it’s a survival requirement.
Why WordPress Needs a Structured Approach
Most WordPress tutorials teach you to hook directly into an action and run your logic there. While this works for tiny snippets, it’s a recipe for disaster in enterprise-grade applications. When you mix your persistence (database) with your presentation (hooks), testing becomes impossible without spinning up a full staging site. Furthermore, any change to the database schema requires you to hunt through dozens of files to find every raw SQL query.
I recently wrote about stopping over-engineering in WordPress, but don’t confuse structure with over-engineering. Structure actually makes your life simpler.
The 5 Layers of a Robust WordPress App
To fix this, we need to slice our application into clear zones of responsibility. This is the heart of Layered Architecture.
- The Interface Layer: These are your entry points. In WordPress, this means your REST API controllers, AJAX handlers, or Action/Filter callbacks. They should only validate input and call the next layer.
- The Application Layer: This is the orchestrator. It doesn’t know about
$_POSTorWP_REST_Request. It just knows how to execute a business workflow (e.g., “Cancel a Subscription”). - The Domain Layer: Your business rules. If a subscription can only be canceled within 14 days, that rule lives here. It’s pure logic, independent of any framework.
- The Repository Layer: The only place where
WP_Queryor global$wpdbshould exist. This layer handles fetching and saving data. - The Infrastructure Layer: Tools that connect to the outside world—sending emails via SendGrid, logging to an external service, or hitting the Stripe API.
Bad Code: The “Everything in One Hook” Approach
This is what I call the “God Function.” It does everything, and it’s impossible to debug without crying.
<?php
add_action( 'wp_ajax_bbioon_cancel_sub', function() {
// 1. Validation mixed with logic
if ( ! isset( $_POST['sub_id'] ) ) wp_die();
// 2. Raw DB access mixed in
global $wpdb;
$sub = $wpdb->get_row( "SELECT * FROM {$wpdb->prefix}subs WHERE id = " . intval($_POST['sub_id']) );
// 3. Business logic mixed in
if ( $sub->status === 'active' ) {
$wpdb->update( "{$wpdb->prefix}subs", ['status' => 'cancelled'], ['id' => $sub->id] );
// 4. Infrastructure mixed in
wp_mail( $sub->email, 'Cancelled', 'Your sub is gone.' );
}
wp_send_json_success();
});
Good Code: Applying Layered Architecture
Now, let’s refactor this. We’ll separate the persistence into a Repository and the logic into a Service. This makes the code readable and extensible.
<?php
// Repository Layer: Only handles DB
class Bbioon_Sub_Repository {
public function find( int $id ) {
global $wpdb;
return $wpdb->get_row( "SELECT * FROM {$wpdb->prefix}subs WHERE id = $id" );
}
public function update_status( int $id, string $status ) {
global $wpdb;
return $wpdb->update( "{$wpdb->prefix}subs", ['status' => $status], ['id' => $id] );
}
}
// Application Layer: Orchestrates the work
class Bbioon_Subscription_Service {
private $repo;
public function __construct( Bbioon_Sub_Repository $repo ) {
$this->repo = $repo;
}
public function cancel_subscription( int $id ) {
$sub = $this->repo->find( $id );
if ( ! $sub || $sub->status !== 'active' ) {
throw new Exception('Invalid sub');
}
$this->repo->update_status( $id, 'cancelled' );
// Call infrastructure layer to send email...
}
}
// Interface Layer: Entry point
add_action( 'wp_ajax_bbioon_cancel_sub', function() {
$service = new Bbioon_Subscription_Service( new Bbioon_Sub_Repository() );
try {
$service->cancel_subscription( (int) $_POST['sub_id'] );
wp_send_json_success();
} catch ( Exception $e ) {
wp_send_json_error( $e->getMessage() );
}
});
Maintaining Sanity: The Core Rules
For Layered Architecture to work, you must respect the boundaries. Specifically, dependencies must flow inward. Your Repository should never know about wp_send_json_success(), and your Application layer should never know about SQL. For more on advanced structures, check out my deep dive into WordPress AI Architecture.
Specifically, keep these three rules in mind:
- Rule 1: Layers only talk to the layer directly below them.
- Rule 2: Business logic (The “Domain”) never depends on WordPress functions (The “Infrastructure”).
- Rule 3: Always return Domain Objects (like a Subscription object) instead of raw
stdClassdatabase results.
Look, if this Layered Architecture stuff is eating up your dev hours, let me handle it. I’ve been wrestling with WordPress since the 4.x days.
The Payoff: Cheaper Maintenance
Adopting this structure might feel like “extra work” initially. Furthermore, it requires a mindset shift from “hacking” to “engineering.” However, the first time you need to switch from local database storage to a third-party API, you’ll thank yourself. Because you isolated the logic, you only change the Repository layer, and the rest of your app keeps running smoothly.
For more official guidance on writing clean PHP for WordPress, I highly recommend checking the introduction to Clean Architecture in PHP. Ship better code, and stop building houses on sand.