The standard advice for WordPress code is still “just throw it in functions.php,” and you pay for that later in both performance and patience. If adding one small feature to your WooCommerce checkout feels like open-heart surgery, the problem probably is not your skills. It is the missing Layered Architecture.
Fourteen years in this ecosystem and the failure mode barely changes: a 5,000-line functions.php where business logic, database queries and HTML rendering sit tangled together in one giant “spaghetti hook.” That is what Layered Architecture is for. You cannot safely change code you cannot find, and a file that does everything hides everything.
Why WordPress code needs structure
Most WordPress tutorials tell you to hook into an action and run your logic right there. That is fine for a ten-line snippet. On a large application it falls apart. Mix persistence (the database) with presentation (hooks) and you cannot test anything without spinning up a full staging site. Change one column in the schema and you are grepping dozens of files for raw SQL.
I wrote earlier about stopping over-engineering in WordPress, and this is not a contradiction. Structure is not the same thing as over-engineering. It is what stops you guessing where code lives.
The five layers of a WordPress app
The fix is to slice the application into zones of responsibility. That is all Layered Architecture really means.
- The Interface Layer: Your entry points, so REST API controllers, AJAX handlers, action and filter callbacks. They validate the input and hand off to the next layer, and that is the whole job.
- The Application Layer: The orchestrator. It has never heard of
$_POSTorWP_REST_Request. It knows how to run a workflow such as cancelling a subscription. - The Domain Layer: The business rules. “A subscription can only be canceled within 14 days” belongs here, as plain logic that does not depend on any framework.
- The Repository Layer: Fetching and saving data. This is the only place a
WP_Queryor a global$wpdbis allowed to appear. - The Infrastructure Layer: Anything that talks to the outside world: sending email through SendGrid, logging to an external service, calling the Stripe API.
Bad code: everything crammed into one hook
I call this one the God Function. It does everything, which means that when it breaks you have no idea which part broke.
<?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: the same job, split into layers
The refactor pulls persistence into a Repository and the logic into a Service. The same work happens, but each piece can now be read on its own.
<?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() );
}
});
The rules that keep the layers honest
Layered Architecture only works while the boundaries hold, and that means dependencies flow inward. Your Repository has no business knowing about wp_send_json_success(), and your Application layer has no business knowing SQL. I go further into structure in my write-up on WordPress AI Architecture.
It comes down to three rules:
- Rule 1: A layer only talks to the layer directly below it.
- Rule 2: Business logic (the domain) never calls WordPress functions (the infrastructure).
- Rule 3: Return domain objects, a Subscription for example, rather than the raw
stdClassrows the database hands back.
If untangling this is eating your billable hours, I can take it on. I have been working with WordPress since the 4.x days.
The payoff is cheaper maintenance
Setting this up feels like extra work at first, and it does ask you to think like an engineer rather than a hacker. The payback shows up the first time a client moves from local database storage to a third-party API. Because the logic sits on its own, you rewrite the Repository layer and nothing else has to move.
For a deeper read on clean PHP, there is a solid introduction to Clean Architecture in PHP. After that, open your worst functions.php and start pulling the database calls out of it.